diff --git a/conductor/tracks.md b/conductor/tracks.md index 2b8f375..2f13d30 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -85,7 +85,7 @@ This file tracks all major tracks for the project. Each track has its own detail ### Manual UX Controls -1. [ ] **Track: Saved System Prompt Presets** +1. [~] **Track: Saved System Prompt Presets** *Link: [./tracks/saved_presets_20260308/](./tracks/saved_presets_20260308/)* *Goal: Ability to have saved presets for global and project system prompts. Includes full AI profiles with temperature and top_p settings, managed via a dedicated GUI modal.* diff --git a/conductor/tracks/saved_presets_20260308/plan.md b/conductor/tracks/saved_presets_20260308/plan.md index 2b044b4..5d35c57 100644 --- a/conductor/tracks/saved_presets_20260308/plan.md +++ b/conductor/tracks/saved_presets_20260308/plan.md @@ -1,34 +1,34 @@ # Implementation Plan: Saved System Prompt Presets ## Phase 1: Foundation & Data Model -- [ ] Task: Define the `Preset` data model and storage logic. - - [ ] Create `src/models.py` (if not existing) or update it with a `Preset` dataclass/Pydantic model. - - [ ] Implement `PresetManager` in a new file `src/presets.py` to handle loading/saving to `presets.toml` and `project_presets.toml`. - - [ ] Implement the inheritance logic where project presets override global ones. -- [ ] Task: Write unit tests for `PresetManager`. - - [ ] Test loading global presets. - - [ ] Test loading project presets. - - [ ] Test the override logic (same name). - - [ ] Test saving/updating presets. -- [ ] Task: Conductor - User Manual Verification 'Phase 1: Foundation & Data Model' (Protocol in workflow.md) +- [x] Task: Define the `Preset` data model and storage logic. + - [x] Create `src/models.py` (if not existing) or update it with a `Preset` dataclass/Pydantic model. + - [x] Implement `PresetManager` in a new file `src/presets.py` to handle loading/saving to `presets.toml` and `project_presets.toml`. + - [x] Implement the inheritance logic where project presets override global ones. +- [x] Task: Write unit tests for `PresetManager`. + - [x] Test loading global presets. + - [x] Test loading project presets. + - [x] Test the override logic (same name). + - [x] Test saving/updating presets. +- [x] Task: Conductor - User Manual Verification 'Phase 1: Foundation & Data Model' (Protocol in workflow.md) ## Phase 2: UI: Settings Integration -- [ ] Task: Add Preset Dropdown to Global AI Settings. - - [ ] Modify `gui_2.py` to include a dropdown in the "AI Settings" panel. - - [ ] Populated the dropdown with available global presets. -- [ ] Task: Add Preset Dropdown to Project Settings. - - [ ] Modify `gui_2.py` to include a dropdown in the "Project Settings" panel. - - [ ] Populated the dropdown with available project-specific presets (including overridden globals). -- [ ] Task: Implement "Auto-Load" logic. - - [ ] When a preset is selected, update the active system prompt and model settings in `gui_2.py`. -- [ ] Task: Write integration tests for settings integration using `live_gui`. - - [ ] Verify global dropdown shows global presets. - - [ ] Verify project dropdown shows project + global presets. - - [ ] Verify selecting a preset updates the UI fields (prompt, temperature). -- [ ] Task: Conductor - User Manual Verification 'Phase 2: UI: Settings Integration' (Protocol in workflow.md) +- [x] Task: Add Preset Dropdown to Global AI Settings. + - [x] Modify `gui_2.py` to include a dropdown in the "AI Settings" panel. + - [x] Populated the dropdown with available global presets. +- [x] Task: Add Preset Dropdown to Project Settings. + - [x] Modify `gui_2.py` to include a dropdown in the "Project Settings" panel. + - [x] Populated the dropdown with available project-specific presets (including overridden globals). +- [x] Task: Implement "Auto-Load" logic. + - [x] When a preset is selected, update the active system prompt and model settings in `gui_2.py`. +- [x] Task: Write integration tests for settings integration using `live_gui`. + - [x] Verify global dropdown shows global presets. + - [x] Verify project dropdown shows project + global presets. + - [x] Verify selecting a preset updates the UI fields (prompt, temperature). +- [x] Task: Conductor - User Manual Verification 'Phase 2: UI: Settings Integration' (Protocol in workflow.md) ## Phase 3: UI: Preset Manager Modal -- [ ] Task: Create the `PresetManagerModal` in `gui_2.py` (or a separate module). +- [~] Task: Create the `PresetManagerModal` in `gui_2.py` (or a separate module). - [ ] Implement a list view of all presets (global and project). - [ ] Implement "Add", "Edit", and "Delete" functionality. - [ ] Ensure validation for unique names. diff --git a/src/app_controller.py b/src/app_controller.py index abe5a83..288fc28 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -24,6 +24,7 @@ from src import session_logger from src import project_manager from src import performance_monitor from src import models +from src import presets from src.file_cache import ASTParser from src import ai_client from src import shell_runner @@ -297,6 +298,13 @@ class AppController: self._inject_mode: str = "skeleton" self._inject_preview: str = "" self._show_inject_modal: bool = False + self.show_preset_manager_modal: bool = False + self._editing_preset_name: str = "" + self._editing_preset_content: str = "" + self._editing_preset_temperature: float = 0.0 + self._editing_preset_top_p: float = 0.0 + self._editing_preset_max_output_tokens: int = 4096 + self._editing_preset_scope: str = "project" self.diagnostic_log: List[Dict[str, Any]] = [] self._settable_fields: Dict[str, str] = { 'ai_input': 'ui_ai_input', @@ -322,10 +330,19 @@ class AppController: 'ui_new_track_name': 'ui_new_track_name', 'ui_new_track_desc': 'ui_new_track_desc', 'manual_approve': 'ui_manual_approve', - 'inject_file_path': '_inject_file_path', - 'inject_mode': '_inject_mode', - 'show_inject_modal': '_show_inject_modal', - 'bg_shader_enabled': 'bg_shader_enabled' + 'global_system_prompt': 'ui_global_system_prompt', + 'project_system_prompt': 'ui_project_system_prompt', + 'global_preset_name': 'ui_global_preset_name', + 'project_preset_name': 'ui_project_preset_name', + 'temperature': 'temperature', + 'max_tokens': 'max_tokens', + 'show_preset_manager_modal': 'show_preset_manager_modal', + '_editing_preset_name': '_editing_preset_name', + '_editing_preset_content': '_editing_preset_content', + '_editing_preset_temperature': '_editing_preset_temperature', + '_editing_preset_top_p': '_editing_preset_top_p', + '_editing_preset_max_output_tokens': '_editing_preset_max_output_tokens', + '_editing_preset_scope': '_editing_preset_scope' } self._gettable_fields = dict(self._settable_fields) self._gettable_fields.update({ @@ -348,7 +365,20 @@ class AppController: '_inject_mode': '_inject_mode', '_inject_preview': '_inject_preview', '_show_inject_modal': '_show_inject_modal', - 'bg_shader_enabled': 'bg_shader_enabled' + 'bg_shader_enabled': 'bg_shader_enabled', + 'global_system_prompt': 'ui_global_system_prompt', + 'project_system_prompt': 'ui_project_system_prompt', + 'global_preset_name': 'ui_global_preset_name', + 'project_preset_name': 'ui_project_preset_name', + 'temperature': 'temperature', + 'max_tokens': 'max_tokens', + 'show_preset_manager_modal': 'show_preset_manager_modal', + '_editing_preset_name': '_editing_preset_name', + '_editing_preset_content': '_editing_preset_content', + '_editing_preset_temperature': '_editing_preset_temperature', + '_editing_preset_top_p': '_editing_preset_top_p', + '_editing_preset_max_output_tokens': '_editing_preset_max_output_tokens', + '_editing_preset_scope': '_editing_preset_scope' }) self.perf_monitor = performance_monitor.get_monitor() self._perf_profiling_enabled = False @@ -423,7 +453,10 @@ class AppController: } self._predefined_callbacks: dict[str, Callable[..., Any]] = { '_test_callback_func_write_to_file': self._test_callback_func_write_to_file, - '_set_env_var': lambda k, v: os.environ.update({k: v}) + '_set_env_var': lambda k, v: os.environ.update({k: v}), + '_apply_preset': self._apply_preset, + '_switch_project': self._switch_project, + '_refresh_from_project': self._refresh_from_project } def _update_gcli_adapter(self, path: str) -> None: @@ -787,6 +820,11 @@ class AppController: self.ui_auto_add_history = disc_sec.get("auto_add", False) self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "") + self.preset_manager = presets.PresetManager(Path(self.active_project_path) if self.active_project_path else None) + self.presets = self.preset_manager.load_all() + self.ui_global_preset_name = ai_cfg.get("active_preset") + self.ui_project_preset_name = proj_meta.get("active_preset") + gui_cfg = self.config.get("gui", {}) from src import bg_shader bg_shader.get_bg().enabled = gui_cfg.get("bg_shader_enabled", False) @@ -1689,6 +1727,7 @@ class AppController: self.ui_project_git_dir = proj_meta.get("git_dir", "") self.ui_project_system_prompt = proj_meta.get("system_prompt", "") self.ui_project_main_context = proj_meta.get("main_context", "") + self.ui_project_preset_name = proj_meta.get("active_preset") self.ui_gemini_cli_path = self.project.get("gemini_cli", {}).get("binary_path", "gemini") self.ui_auto_add_history = proj.get("discussion", {}).get("auto_add", False) self.ui_auto_scroll_comms = proj.get("project", {}).get("auto_scroll_comms", True) @@ -1726,6 +1765,30 @@ class AppController: if track_history: with self._disc_entries_lock: self.disc_entries = models.parse_history_entries(track_history, self.disc_roles) + + self.preset_manager.project_root = Path(self.ui_files_base_dir) + self.presets = self.preset_manager.load_all() + + def _apply_preset(self, name: str, scope: str) -> None: + if name == "None": + if scope == "global": + self.ui_global_preset_name = "" + else: + self.ui_project_preset_name = "" + return + preset = self.presets.get(name) + if not preset: + return + if scope == "global": + self.ui_global_system_prompt = preset.system_prompt + self.ui_global_preset_name = name + else: + self.ui_project_system_prompt = preset.system_prompt + self.ui_project_preset_name = name + if preset.temperature is not None: + self.temperature = preset.temperature + if preset.max_output_tokens is not None: + self.max_tokens = preset.max_output_tokens def _cb_load_track(self, track_id: str) -> None: state = project_manager.load_track_state(track_id, self.ui_files_base_dir) @@ -2053,6 +2116,7 @@ class AppController: proj["project"]["git_dir"] = self.ui_project_git_dir proj["project"]["system_prompt"] = self.ui_project_system_prompt proj["project"]["main_context"] = self.ui_project_main_context + proj["project"]["active_preset"] = self.ui_project_preset_name proj["project"]["word_wrap"] = self.ui_word_wrap proj["project"]["summary_only"] = self.ui_summary_only proj["project"]["auto_scroll_comms"] = self.ui_auto_scroll_comms @@ -2082,6 +2146,7 @@ class AppController: "temperature": self.temperature, "max_tokens": self.max_tokens, "history_trunc_limit": self.history_trunc_limit, + "active_preset": self.ui_global_preset_name, } self.config["ai"]["system_prompt"] = self.ui_global_system_prompt self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path} diff --git a/src/gui_2.py b/src/gui_2.py index bfc3fc2..cdc8c64 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -14,6 +14,7 @@ from src import cost_tracker from src import session_logger from src import project_manager from src import paths +from src import presets from src import theme_2 as theme from src import theme_nerv_fx as theme_fx from src import api_hooks @@ -94,6 +95,15 @@ class App: self.controller.init_state() self.show_windows.setdefault("Diagnostics", False) self.controller.start_services(self) + self.show_preset_manager_modal = False + self._editing_preset_name = "" + self._editing_preset_content = "" + self._editing_preset_temperature = 0.0 + self._editing_preset_top_p = 1.0 + self._editing_preset_max_output_tokens = 4096 + self._editing_preset_scope = "project" + self._editing_preset_is_new = False + self._presets_list: dict[str, dict] = {} # Aliases for controller-owned locks self._send_thread_lock = self.controller._send_thread_lock self._disc_entries_lock = self.controller._disc_entries_lock @@ -110,7 +120,7 @@ class App: self.node_editor_ctx = ed.create_editor(self.node_editor_config) self.ui_selected_ticket_id: Optional[str] = None self.ui_selected_tickets: set[str] = set() - self.ui_new_ticket_priority: str = "medium" + self.ui_new_ticket_priority: str = 'medium' self._autofocus_response_tab = False gui_cfg = self.config.get("gui", {}) self.ui_separate_message_panel = gui_cfg.get("separate_message_panel", False) @@ -332,6 +342,7 @@ class App: self._render_track_proposal_modal() self._render_patch_modal() self._render_save_preset_modal() + self._render_preset_manager_modal() # Auto-save (every 60s) now = time.time() if now - self._last_autosave >= self._autosave_interval: @@ -850,6 +861,82 @@ class App: imgui.close_current_popup() imgui.end_popup() + def _render_preset_manager_modal(self) -> None: + if not self.show_preset_manager_modal: return + imgui.open_popup("Preset Manager") + if imgui.begin_popup_modal("Preset Manager", True, imgui.WindowFlags_.always_auto_resize)[0]: + imgui.begin_child("preset_list_area", imgui.ImVec2(250, 600), True) + preset_names = sorted(self.controller.presets.keys()) + if imgui.button("New Preset", imgui.ImVec2(-1, 0)): + self._editing_preset_name = "" + self._editing_preset_content = "" + self._editing_preset_temperature = 0.0 + self._editing_preset_top_p = 1.0 + self._editing_preset_max_output_tokens = 4096 + self._editing_preset_scope = "project" + self._editing_preset_is_new = True + imgui.separator() + for name in preset_names: + p = self.controller.presets[name] + is_sel = (name == self._editing_preset_name) + if imgui.selectable(name, is_sel)[0]: + self._editing_preset_name = name + self._editing_preset_content = p.system_prompt + self._editing_preset_temperature = p.temperature if p.temperature is not None else 0.0 + self._editing_preset_top_p = p.top_p if p.top_p is not None else 1.0 + self._editing_preset_max_output_tokens = p.max_output_tokens if p.max_output_tokens is not None else 4096 + self._editing_preset_is_new = False + imgui.end_child() + imgui.same_line() + imgui.begin_child("preset_edit_area", imgui.ImVec2(500, 600), False) + imgui.text("Name:") + _, self._editing_preset_name = imgui.input_text("##edit_name", self._editing_preset_name) + imgui.text("Scope:") + if imgui.radio_button("Global", self._editing_preset_scope == "global"): + self._editing_preset_scope = "global" + imgui.same_line() + if imgui.radio_button("Project", self._editing_preset_scope == "project"): + self._editing_preset_scope = "project" + imgui.text("Content:") + _, self._editing_preset_content = imgui.input_text_multiline("##edit_content", self._editing_preset_content, imgui.ImVec2(-1, 280)) + + imgui.text("Temperature:") + _, self._editing_preset_temperature = imgui.input_float("##edit_temp", self._editing_preset_temperature, 0.1, 1.0, "%.2f") + imgui.text("Top P:") + _, self._editing_preset_top_p = imgui.input_float("##edit_top_p", self._editing_preset_top_p, 0.1, 1.0, "%.2f") + imgui.text("Max Output Tokens:") + _, self._editing_preset_max_output_tokens = imgui.input_int("##edit_max_tokens", self._editing_preset_max_output_tokens) + + if imgui.button("Save", imgui.ImVec2(120, 0)): + if self._editing_preset_name.strip(): + new_p = models.Preset( + name=self._editing_preset_name.strip(), + system_prompt=self._editing_preset_content, + temperature=self._editing_preset_temperature, + top_p=self._editing_preset_top_p, + max_output_tokens=self._editing_preset_max_output_tokens + ) + self.controller.preset_manager.save_preset(new_p, self._editing_preset_scope) + self.controller.presets = self.controller.preset_manager.load_all() + self.ai_status = f"Preset '{new_p.name}' saved to {self._editing_preset_scope}" + imgui.same_line() + if imgui.button("Delete", imgui.ImVec2(120, 0)): + if self._editing_preset_name.strip(): + try: + self.controller.preset_manager.delete_preset(self._editing_preset_name.strip(), self._editing_preset_scope) + self.controller.presets = self.controller.preset_manager.load_all() + self.ai_status = f"Preset '{self._editing_preset_name}' deleted from {self._editing_preset_scope}" + self._editing_preset_name = "" + self._editing_preset_content = "" + except Exception as e: + self.ai_status = f"Error deleting: {e}" + imgui.same_line() + if imgui.button("Close", imgui.ImVec2(120, 0)): + self.show_preset_manager_modal = False + imgui.close_current_popup() + imgui.end_child() + imgui.end_popup() + def _render_projects_panel(self) -> None: if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_projects_panel") proj_name = self.project.get("project", {}).get("name", Path(self.active_project_path).stem) @@ -2827,9 +2914,36 @@ def hello(): def _render_system_prompts_panel(self) -> None: imgui.text("Global System Prompt (all projects)") + preset_names = sorted(self.controller.presets.keys()) + current_global = self.controller.ui_global_preset_name or "Select Preset..." + imgui.set_next_item_width(200) + if imgui.begin_combo("##global_preset", current_global): + for name in preset_names: + is_sel = (name == current_global) + if imgui.selectable(name, is_sel)[0]: + self.controller._apply_preset(name, "global") + if is_sel: + imgui.set_item_default_focus() + imgui.end_combo() + imgui.same_line() + if imgui.button("Manage Presets##global"): + self.show_preset_manager_modal = True ch, self.ui_global_system_prompt = imgui.input_text_multiline("##gsp", self.ui_global_system_prompt, imgui.ImVec2(-1, 100)) imgui.separator() imgui.text("Project System Prompt") + current_project = self.controller.ui_project_preset_name or "Select Preset..." + imgui.set_next_item_width(200) + if imgui.begin_combo("##project_preset", current_project): + for name in preset_names: + is_sel = (name == current_project) + if imgui.selectable(name, is_sel)[0]: + self.controller._apply_preset(name, "project") + if is_sel: + imgui.set_item_default_focus() + imgui.end_combo() + imgui.same_line() + if imgui.button("Manage Presets##project"): + self.show_preset_manager_modal = True ch, self.ui_project_system_prompt = imgui.input_text_multiline("##psp", self.ui_project_system_prompt, imgui.ImVec2(-1, 100)) def _render_theme_panel(self) -> None: diff --git a/src/models.py b/src/models.py index daada0c..89ded05 100644 --- a/src/models.py +++ b/src/models.py @@ -315,3 +315,33 @@ class FileItem: auto_aggregate=data.get("auto_aggregate", True), force_full=data.get("force_full", False), ) + +@dataclass +class Preset: + name: str + system_prompt: str + temperature: Optional[float] = None + top_p: Optional[float] = None + max_output_tokens: Optional[int] = None + + def to_dict(self) -> Dict[str, Any]: + res = { + "system_prompt": self.system_prompt, + } + if self.temperature is not None: + res["temperature"] = self.temperature + if self.top_p is not None: + res["top_p"] = self.top_p + if self.max_output_tokens is not None: + res["max_output_tokens"] = self.max_output_tokens + return res + + @classmethod + def from_dict(cls, name: str, data: Dict[str, Any]) -> "Preset": + return cls( + name=name, + system_prompt=data.get("system_prompt", ""), + temperature=data.get("temperature"), + top_p=data.get("top_p"), + max_output_tokens=data.get("max_output_tokens"), + ) diff --git a/src/paths.py b/src/paths.py index fc951c1..1ef4a8c 100644 --- a/src/paths.py +++ b/src/paths.py @@ -51,6 +51,11 @@ _RESOLVED: dict[str, Path] = {} def get_config_path() -> Path: root_dir = Path(__file__).resolve().parent.parent return Path(os.environ.get("SLOP_CONFIG", root_dir / "config.toml")) +def get_global_presets_path() -> Path: + root_dir = Path(__file__).resolve().parent.parent + return Path(os.environ.get("SLOP_GLOBAL_PRESETS", root_dir / "presets.toml")) +def get_project_presets_path(project_root: Path) -> Path: + return project_root / "project_presets.toml" def _resolve_path(env_var: str, config_key: str, default: str) -> Path: if env_var in os.environ: diff --git a/src/presets.py b/src/presets.py new file mode 100644 index 0000000..2b4a030 --- /dev/null +++ b/src/presets.py @@ -0,0 +1,85 @@ +import tomllib +import tomli_w +from pathlib import Path +from typing import Dict, Any, Optional +from src.models import Preset +from src.paths import get_global_presets_path, get_project_presets_path + +class PresetManager: + """Manages system prompt presets across global and project-specific files.""" + + def __init__(self, project_root: Optional[Path] = None): + self.project_root = project_root + self.global_path = get_global_presets_path() + + @property + def project_path(self) -> Optional[Path]: + return get_project_presets_path(self.project_root) if self.project_root else None + + def load_all(self) -> Dict[str, Preset]: + """Merges global and project presets into a single dictionary.""" + presets: Dict[str, Preset] = {} + + # Load global presets + if self.global_path.exists(): + try: + with open(self.global_path, "rb") as f: + data = tomllib.load(f) + for name, p_data in data.get("presets", {}).items(): + presets[name] = Preset.from_dict(name, p_data) + except Exception: + pass + + # Load project presets (overwriting global ones if names conflict) + if self.project_path and self.project_path.exists(): + try: + with open(self.project_path, "rb") as f: + data = tomllib.load(f) + for name, p_data in data.get("presets", {}).items(): + presets[name] = Preset.from_dict(name, p_data) + except Exception: + pass + + return presets + + def save_preset(self, preset: Preset, scope: str = "project") -> None: + """Saves a preset to either the global or project-specific TOML file.""" + path = self.global_path if scope == "global" else self.project_path + if not path: + if scope == "project": + raise ValueError("Project scope requested but no project_root provided.") + path = self.global_path + + data = self._load_file(path) + if "presets" not in data: + data["presets"] = {} + + data["presets"][preset.name] = preset.to_dict() + self._save_file(path, data) + + def delete_preset(self, name: str, scope: str = "project") -> None: + """Deletes a preset by name from the specified scope.""" + path = self.global_path if scope == "global" else self.project_path + if not path: + if scope == "project": + raise ValueError("Project scope requested but no project_root provided.") + path = self.global_path + + data = self._load_file(path) + if "presets" in data and name in data["presets"]: + del data["presets"][name] + self._save_file(path, data) + + def _load_file(self, path: Path) -> Dict[str, Any]: + if not path.exists(): + return {"presets": {}} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception: + return {"presets": {}} + + def _save_file(self, path: Path, data: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "wb") as f: + f.write(tomli_w.dumps(data).encode("utf-8")) diff --git a/tests/conftest.py b/tests/conftest.py index 7df03f4..3c0ec03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -200,14 +200,14 @@ def live_gui() -> Generator[tuple[subprocess.Popen, str], None, None]: temp_workspace.mkdir(parents=True, exist_ok=True) # Create minimal project files to avoid cluttering root - # NOTE: Do NOT create config.toml here - we use SLOP_CONFIG env var - # to point to the actual project root config.toml (temp_workspace / "manual_slop.toml").write_text("[project]\nname = 'TestProject'\n", encoding="utf-8") (temp_workspace / "conductor" / "tracks").mkdir(parents=True, exist_ok=True) # Resolve absolute paths for shared resources project_root = Path(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - config_file = project_root / "config.toml" + config_file = temp_workspace / "config.toml" + if not config_file.exists(): + config_file = project_root / "config.toml" cred_file = project_root / "credentials.toml" mcp_file = project_root / "mcp_env.toml" @@ -249,6 +249,7 @@ def live_gui() -> Generator[tuple[subprocess.Popen, str], None, None]: env["SLOP_CREDENTIALS"] = str(cred_file.absolute()) if mcp_file.exists(): env["SLOP_MCP_ENV"] = str(mcp_file.absolute()) + env["SLOP_GLOBAL_PRESETS"] = str((temp_workspace / "presets.toml").absolute()) process = subprocess.Popen( ["uv", "run", "python", "-u", gui_script, "--enable-test-hooks"], diff --git a/tests/test_preset_manager.py b/tests/test_preset_manager.py new file mode 100644 index 0000000..d1a059a --- /dev/null +++ b/tests/test_preset_manager.py @@ -0,0 +1,134 @@ +import pytest +from pathlib import Path +from src.presets import PresetManager +from src.models import Preset + +def test_load_all_merged(tmp_path, monkeypatch): + """Tests that load_all correctly merges global and project presets.""" + global_file = tmp_path / "global_presets.toml" + project_root = tmp_path / "project" + project_root.mkdir() + project_file = project_root / "project_presets.toml" + + # Setup global presets + global_file.write_text(""" +[presets.global_only] +system_prompt = "global prompt" +temperature = 0.5 + +[presets.override_me] +system_prompt = "original prompt" +""", encoding="utf-8") + + # Setup project presets + project_file.write_text(""" +[presets.project_only] +system_prompt = "project prompt" +max_output_tokens = 100 + +[presets.override_me] +system_prompt = "overridden prompt" +""", encoding="utf-8") + + monkeypatch.setattr("src.presets.get_global_presets_path", lambda: global_file) + monkeypatch.setattr("src.presets.get_project_presets_path", lambda p: project_file) + + pm = PresetManager(project_root=project_root) + presets = pm.load_all() + + assert len(presets) == 3 + assert presets["global_only"].system_prompt == "global prompt" + assert presets["global_only"].temperature == 0.5 + assert presets["project_only"].system_prompt == "project prompt" + assert presets["project_only"].max_output_tokens == 100 + assert presets["override_me"].system_prompt == "overridden prompt" + +def test_save_preset_global(tmp_path, monkeypatch): + """Tests saving a preset to the global scope.""" + global_file = tmp_path / "global_presets.toml" + monkeypatch.setattr("src.presets.get_global_presets_path", lambda: global_file) + + pm = PresetManager() + preset = Preset(name="new_global", system_prompt="new global prompt", temperature=0.7) + pm.save_preset(preset, scope="global") + + assert global_file.exists() + loaded_presets = pm.load_all() + assert "new_global" in loaded_presets + assert loaded_presets["new_global"].system_prompt == "new global prompt" + assert loaded_presets["new_global"].temperature == 0.7 + +def test_save_preset_project(tmp_path, monkeypatch): + """Tests saving a preset to the project scope.""" + project_root = tmp_path / "project" + project_root.mkdir() + project_file = project_root / "project_presets.toml" + global_file = tmp_path / "global_presets.toml" + + monkeypatch.setattr("src.presets.get_global_presets_path", lambda: global_file) + monkeypatch.setattr("src.presets.get_project_presets_path", lambda p: project_file) + + pm = PresetManager(project_root=project_root) + preset = Preset(name="new_project", system_prompt="new project prompt", max_output_tokens=500) + pm.save_preset(preset, scope="project") + + assert project_file.exists() + # Global file should NOT have been created/modified + assert not global_file.exists() + + loaded_presets = pm.load_all() + assert "new_project" in loaded_presets + assert loaded_presets["new_project"].system_prompt == "new project prompt" + assert loaded_presets["new_project"].max_output_tokens == 500 + +def test_save_preset_project_no_root(): + """Tests that saving to project scope fails if no project root is provided.""" + pm = PresetManager(project_root=None) + preset = Preset(name="fail", system_prompt="fail") + with pytest.raises(ValueError, match="Project scope requested but no project_root provided"): + pm.save_preset(preset, scope="project") + +def test_delete_preset(tmp_path, monkeypatch): + """Tests deleting a preset from both scopes.""" + global_file = tmp_path / "global_presets.toml" + project_root = tmp_path / "project" + project_root.mkdir() + project_file = project_root / "project_presets.toml" + + global_file.write_text(""" +[presets.global1] +system_prompt = "g1" +[presets.both] +system_prompt = "both_g" +""", encoding="utf-8") + + project_file.write_text(""" +[presets.project1] +system_prompt = "p1" +[presets.both] +system_prompt = "both_p" +""", encoding="utf-8") + + monkeypatch.setattr("src.presets.get_global_presets_path", lambda: global_file) + monkeypatch.setattr("src.presets.get_project_presets_path", lambda p: project_file) + + pm = PresetManager(project_root=project_root) + + # Delete from project + pm.delete_preset("both", scope="project") + presets = pm.load_all() + assert "project1" in presets + # "both" should now show the global version because project override is gone + assert presets["both"].system_prompt == "both_g" + + # Delete from global + pm.delete_preset("global1", scope="global") + presets = pm.load_all() + assert "global1" not in presets + assert "both" in presets + + # Delete last project preset + pm.delete_preset("project1", scope="project") + presets = pm.load_all() + assert "project1" not in presets + assert "both" in presets # still in global diff --git a/tests/test_presets.py b/tests/test_presets.py new file mode 100644 index 0000000..6824359 --- /dev/null +++ b/tests/test_presets.py @@ -0,0 +1,77 @@ +import os +import unittest +from pathlib import Path +import tempfile +import shutil +from src.presets import PresetManager +from src.models import Preset + +class TestPresetManager(unittest.TestCase): + def setUp(self): + self.test_dir = Path(tempfile.mkdtemp()) + self.project_root = self.test_dir / "project" + self.project_root.mkdir() + + # Mocking global path is harder since it's hardcoded in paths.py + # But we can at least test project-specific ones and the manager's logic. + # For the sake of this test, we will only test what we can without + # affecting the real global_presets.toml if possible. + + self.manager = PresetManager(project_root=self.project_root) + # Override paths for testing to avoid touching real files + self.manager.global_path = self.test_dir / "global_presets.toml" + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def test_save_and_load_global(self): + preset = Preset(name="test_global", system_prompt="You are a global assistant") + self.manager.save_preset(preset, scope="global") + + presets = self.manager.load_all() + self.assertIn("test_global", presets) + self.assertEqual(presets["test_global"].system_prompt, "You are a global assistant") + + def test_save_and_load_project(self): + preset = Preset(name="test_project", system_prompt="You are a project assistant") + self.manager.save_preset(preset, scope="project") + + presets = self.manager.load_all() + self.assertIn("test_project", presets) + self.assertEqual(presets["test_project"].system_prompt, "You are a project assistant") + + def test_project_overwrites_global(self): + g_preset = Preset(name="shared", system_prompt="Global version") + p_preset = Preset(name="shared", system_prompt="Project version") + + self.manager.save_preset(g_preset, scope="global") + self.manager.save_preset(p_preset, scope="project") + + presets = self.manager.load_all() + self.assertEqual(presets["shared"].system_prompt, "Project version") + + def test_delete_preset(self): + preset = Preset(name="to_delete", system_prompt="Delete me") + self.manager.save_preset(preset, scope="project") + + presets = self.manager.load_all() + self.assertIn("to_delete", presets) + + self.manager.delete_preset("to_delete", scope="project") + presets = self.manager.load_all() + self.assertNotIn("to_delete", presets) + + def test_dynamic_project_path(self): + """Verifies that project_path updates when project_root changes.""" + initial_root = self.test_dir / "project1" + initial_root.mkdir() + manager = PresetManager(project_root=initial_root) + self.assertEqual(manager.project_path, initial_root / "project_presets.toml") + + new_root = self.test_dir / "project2" + new_root.mkdir() + manager.project_root = new_root + self.assertEqual(manager.project_path, new_root / "project_presets.toml") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_saved_presets_sim.py b/tests/test_saved_presets_sim.py new file mode 100644 index 0000000..1934c28 --- /dev/null +++ b/tests/test_saved_presets_sim.py @@ -0,0 +1,103 @@ +import pytest +import time +import tomli_w +import os +import json +from pathlib import Path +from src.api_hook_client import ApiHookClient + +def test_preset_switching(live_gui): + client = ApiHookClient() + + # Paths for presets + temp_workspace = Path("tests/artifacts/live_gui_workspace") + global_presets_path = temp_workspace / "presets.toml" + project_presets_path = temp_workspace / "project_presets.toml" + manual_slop_path = temp_workspace / "manual_slop.toml" + + # Cleanup before test + if global_presets_path.exists(): global_presets_path.unlink() + if project_presets_path.exists(): project_presets_path.unlink() + + try: + # Create a global preset + global_presets_path.write_text(tomli_w.dumps({ + "presets": { + "TestGlobal": { + "system_prompt": "Global Prompt", + "temperature": 0.7 + } + } + })) + + # Create a project preset + project_presets_path.write_text(tomli_w.dumps({ + "presets": { + "TestProject": { + "system_prompt": "Project Prompt", + "temperature": 0.3 + }, + "TestGlobal": { # Override + "system_prompt": "Overridden Prompt", + "temperature": 0.5 + } + } + })) + + # Switch to the local project to ensure context is correct + client.push_event("custom_callback", { + "callback": "_switch_project", + "args": [str(manual_slop_path.absolute())] + }) + time.sleep(2) + + # Trigger reload of presets (just in case) + client.push_event("custom_callback", { + "callback": "_refresh_from_project", + "args": [] + }) + time.sleep(2) # Wait for processing + + # Verify where it thinks the project is + state = client.get_gui_state() + print(f"DEBUG: ui_files_base_dir={state.get('files_base_dir')}") + + # Apply Global Preset (should use override from project if available in merged list) + client.push_event("custom_callback", { + "callback": "_apply_preset", + "args": ["TestGlobal", "global"] + }) + time.sleep(1) + + # Verify state + state = client.get_gui_state() + assert state["global_preset_name"] == "TestGlobal" + assert state["global_system_prompt"] == "Overridden Prompt" + assert state["temperature"] == 0.5 + + # Apply Project Preset + client.push_event("custom_callback", { + "callback": "_apply_preset", + "args": ["TestProject", "project"] + }) + time.sleep(1) + + state = client.get_gui_state() + assert state["project_preset_name"] == "TestProject" + assert state["project_system_prompt"] == "Project Prompt" + assert state["temperature"] == 0.3 + # Select "None" + client.push_event("custom_callback", { + "callback": "_apply_preset", + "args": ["None", "global"] + }) + time.sleep(1) + state = client.get_gui_state() + assert not state.get("global_preset_name") # Should be None or "" + + # Prompt remains from previous application + assert state["global_system_prompt"] == "Overridden Prompt" + finally: + # Cleanup + if global_presets_path.exists(): global_presets_path.unlink() + if project_presets_path.exists(): project_presets_path.unlink()