checkpoint(Saved system prompt presets)

This commit is contained in:
2026-03-09 22:27:40 -04:00
parent d8a4ec121d
commit e2a403a187
11 changed files with 649 additions and 35 deletions

View File

@@ -85,7 +85,7 @@ This file tracks all major tracks for the project. Each track has its own detail
### Manual UX Controls ### Manual UX Controls
1. [ ] **Track: Saved System Prompt Presets** 1. [~] **Track: Saved System Prompt Presets**
*Link: [./tracks/saved_presets_20260308/](./tracks/saved_presets_20260308/)* *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.* *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.*

View File

@@ -1,34 +1,34 @@
# Implementation Plan: Saved System Prompt Presets # Implementation Plan: Saved System Prompt Presets
## Phase 1: Foundation & Data Model ## Phase 1: Foundation & Data Model
- [ ] Task: Define the `Preset` data model and storage logic. - [x] 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. - [x] 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`. - [x] 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. - [x] Implement the inheritance logic where project presets override global ones.
- [ ] Task: Write unit tests for `PresetManager`. - [x] Task: Write unit tests for `PresetManager`.
- [ ] Test loading global presets. - [x] Test loading global presets.
- [ ] Test loading project presets. - [x] Test loading project presets.
- [ ] Test the override logic (same name). - [x] Test the override logic (same name).
- [ ] Test saving/updating presets. - [x] Test saving/updating presets.
- [ ] Task: Conductor - User Manual Verification 'Phase 1: Foundation & Data Model' (Protocol in workflow.md) - [x] Task: Conductor - User Manual Verification 'Phase 1: Foundation & Data Model' (Protocol in workflow.md)
## Phase 2: UI: Settings Integration ## Phase 2: UI: Settings Integration
- [ ] Task: Add Preset Dropdown to Global AI Settings. - [x] Task: Add Preset Dropdown to Global AI Settings.
- [ ] Modify `gui_2.py` to include a dropdown in the "AI Settings" panel. - [x] Modify `gui_2.py` to include a dropdown in the "AI Settings" panel.
- [ ] Populated the dropdown with available global presets. - [x] Populated the dropdown with available global presets.
- [ ] Task: Add Preset Dropdown to Project Settings. - [x] Task: Add Preset Dropdown to Project Settings.
- [ ] Modify `gui_2.py` to include a dropdown in the "Project Settings" panel. - [x] Modify `gui_2.py` to include a dropdown in the "Project Settings" panel.
- [ ] Populated the dropdown with available project-specific presets (including overridden globals). - [x] Populated the dropdown with available project-specific presets (including overridden globals).
- [ ] Task: Implement "Auto-Load" logic. - [x] Task: Implement "Auto-Load" logic.
- [ ] When a preset is selected, update the active system prompt and model settings in `gui_2.py`. - [x] 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`. - [x] Task: Write integration tests for settings integration using `live_gui`.
- [ ] Verify global dropdown shows global presets. - [x] Verify global dropdown shows global presets.
- [ ] Verify project dropdown shows project + global presets. - [x] Verify project dropdown shows project + global presets.
- [ ] Verify selecting a preset updates the UI fields (prompt, temperature). - [x] 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: Conductor - User Manual Verification 'Phase 2: UI: Settings Integration' (Protocol in workflow.md)
## Phase 3: UI: Preset Manager Modal ## 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 a list view of all presets (global and project).
- [ ] Implement "Add", "Edit", and "Delete" functionality. - [ ] Implement "Add", "Edit", and "Delete" functionality.
- [ ] Ensure validation for unique names. - [ ] Ensure validation for unique names.

View File

@@ -24,6 +24,7 @@ from src import session_logger
from src import project_manager from src import project_manager
from src import performance_monitor from src import performance_monitor
from src import models from src import models
from src import presets
from src.file_cache import ASTParser from src.file_cache import ASTParser
from src import ai_client from src import ai_client
from src import shell_runner from src import shell_runner
@@ -297,6 +298,13 @@ class AppController:
self._inject_mode: str = "skeleton" self._inject_mode: str = "skeleton"
self._inject_preview: str = "" self._inject_preview: str = ""
self._show_inject_modal: bool = False 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.diagnostic_log: List[Dict[str, Any]] = []
self._settable_fields: Dict[str, str] = { self._settable_fields: Dict[str, str] = {
'ai_input': 'ui_ai_input', 'ai_input': 'ui_ai_input',
@@ -322,10 +330,19 @@ class AppController:
'ui_new_track_name': 'ui_new_track_name', 'ui_new_track_name': 'ui_new_track_name',
'ui_new_track_desc': 'ui_new_track_desc', 'ui_new_track_desc': 'ui_new_track_desc',
'manual_approve': 'ui_manual_approve', 'manual_approve': 'ui_manual_approve',
'inject_file_path': '_inject_file_path', 'global_system_prompt': 'ui_global_system_prompt',
'inject_mode': '_inject_mode', 'project_system_prompt': 'ui_project_system_prompt',
'show_inject_modal': '_show_inject_modal', 'global_preset_name': 'ui_global_preset_name',
'bg_shader_enabled': 'bg_shader_enabled' '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 = dict(self._settable_fields)
self._gettable_fields.update({ self._gettable_fields.update({
@@ -348,7 +365,20 @@ class AppController:
'_inject_mode': '_inject_mode', '_inject_mode': '_inject_mode',
'_inject_preview': '_inject_preview', '_inject_preview': '_inject_preview',
'_show_inject_modal': '_show_inject_modal', '_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_monitor = performance_monitor.get_monitor()
self._perf_profiling_enabled = False self._perf_profiling_enabled = False
@@ -423,7 +453,10 @@ class AppController:
} }
self._predefined_callbacks: dict[str, Callable[..., Any]] = { self._predefined_callbacks: dict[str, Callable[..., Any]] = {
'_test_callback_func_write_to_file': self._test_callback_func_write_to_file, '_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: 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_auto_add_history = disc_sec.get("auto_add", False)
self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "") 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", {}) gui_cfg = self.config.get("gui", {})
from src import bg_shader from src import bg_shader
bg_shader.get_bg().enabled = gui_cfg.get("bg_shader_enabled", False) 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_git_dir = proj_meta.get("git_dir", "")
self.ui_project_system_prompt = proj_meta.get("system_prompt", "") self.ui_project_system_prompt = proj_meta.get("system_prompt", "")
self.ui_project_main_context = proj_meta.get("main_context", "") 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_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_add_history = proj.get("discussion", {}).get("auto_add", False)
self.ui_auto_scroll_comms = proj.get("project", {}).get("auto_scroll_comms", True) self.ui_auto_scroll_comms = proj.get("project", {}).get("auto_scroll_comms", True)
@@ -1727,6 +1766,30 @@ class AppController:
with self._disc_entries_lock: with self._disc_entries_lock:
self.disc_entries = models.parse_history_entries(track_history, self.disc_roles) 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: def _cb_load_track(self, track_id: str) -> None:
state = project_manager.load_track_state(track_id, self.ui_files_base_dir) state = project_manager.load_track_state(track_id, self.ui_files_base_dir)
if state: if state:
@@ -2053,6 +2116,7 @@ class AppController:
proj["project"]["git_dir"] = self.ui_project_git_dir proj["project"]["git_dir"] = self.ui_project_git_dir
proj["project"]["system_prompt"] = self.ui_project_system_prompt proj["project"]["system_prompt"] = self.ui_project_system_prompt
proj["project"]["main_context"] = self.ui_project_main_context 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"]["word_wrap"] = self.ui_word_wrap
proj["project"]["summary_only"] = self.ui_summary_only proj["project"]["summary_only"] = self.ui_summary_only
proj["project"]["auto_scroll_comms"] = self.ui_auto_scroll_comms proj["project"]["auto_scroll_comms"] = self.ui_auto_scroll_comms
@@ -2082,6 +2146,7 @@ class AppController:
"temperature": self.temperature, "temperature": self.temperature,
"max_tokens": self.max_tokens, "max_tokens": self.max_tokens,
"history_trunc_limit": self.history_trunc_limit, "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["ai"]["system_prompt"] = self.ui_global_system_prompt
self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path} self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path}

View File

@@ -14,6 +14,7 @@ from src import cost_tracker
from src import session_logger from src import session_logger
from src import project_manager from src import project_manager
from src import paths from src import paths
from src import presets
from src import theme_2 as theme from src import theme_2 as theme
from src import theme_nerv_fx as theme_fx from src import theme_nerv_fx as theme_fx
from src import api_hooks from src import api_hooks
@@ -94,6 +95,15 @@ class App:
self.controller.init_state() self.controller.init_state()
self.show_windows.setdefault("Diagnostics", False) self.show_windows.setdefault("Diagnostics", False)
self.controller.start_services(self) 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 # Aliases for controller-owned locks
self._send_thread_lock = self.controller._send_thread_lock self._send_thread_lock = self.controller._send_thread_lock
self._disc_entries_lock = self.controller._disc_entries_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.node_editor_ctx = ed.create_editor(self.node_editor_config)
self.ui_selected_ticket_id: Optional[str] = None self.ui_selected_ticket_id: Optional[str] = None
self.ui_selected_tickets: set[str] = set() 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 self._autofocus_response_tab = False
gui_cfg = self.config.get("gui", {}) gui_cfg = self.config.get("gui", {})
self.ui_separate_message_panel = gui_cfg.get("separate_message_panel", False) 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_track_proposal_modal()
self._render_patch_modal() self._render_patch_modal()
self._render_save_preset_modal() self._render_save_preset_modal()
self._render_preset_manager_modal()
# Auto-save (every 60s) # Auto-save (every 60s)
now = time.time() now = time.time()
if now - self._last_autosave >= self._autosave_interval: if now - self._last_autosave >= self._autosave_interval:
@@ -850,6 +861,82 @@ class App:
imgui.close_current_popup() imgui.close_current_popup()
imgui.end_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: def _render_projects_panel(self) -> None:
if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_projects_panel") 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) 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: def _render_system_prompts_panel(self) -> None:
imgui.text("Global System Prompt (all projects)") 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)) ch, self.ui_global_system_prompt = imgui.input_text_multiline("##gsp", self.ui_global_system_prompt, imgui.ImVec2(-1, 100))
imgui.separator() imgui.separator()
imgui.text("Project System Prompt") 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)) 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: def _render_theme_panel(self) -> None:

View File

@@ -315,3 +315,33 @@ class FileItem:
auto_aggregate=data.get("auto_aggregate", True), auto_aggregate=data.get("auto_aggregate", True),
force_full=data.get("force_full", False), 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"),
)

View File

@@ -51,6 +51,11 @@ _RESOLVED: dict[str, Path] = {}
def get_config_path() -> Path: def get_config_path() -> Path:
root_dir = Path(__file__).resolve().parent.parent root_dir = Path(__file__).resolve().parent.parent
return Path(os.environ.get("SLOP_CONFIG", root_dir / "config.toml")) 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: def _resolve_path(env_var: str, config_key: str, default: str) -> Path:
if env_var in os.environ: if env_var in os.environ:

85
src/presets.py Normal file
View File

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

View File

@@ -200,13 +200,13 @@ def live_gui() -> Generator[tuple[subprocess.Popen, str], None, None]:
temp_workspace.mkdir(parents=True, exist_ok=True) temp_workspace.mkdir(parents=True, exist_ok=True)
# Create minimal project files to avoid cluttering root # 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 / "manual_slop.toml").write_text("[project]\nname = 'TestProject'\n", encoding="utf-8")
(temp_workspace / "conductor" / "tracks").mkdir(parents=True, exist_ok=True) (temp_workspace / "conductor" / "tracks").mkdir(parents=True, exist_ok=True)
# Resolve absolute paths for shared resources # Resolve absolute paths for shared resources
project_root = Path(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) project_root = Path(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
config_file = temp_workspace / "config.toml"
if not config_file.exists():
config_file = project_root / "config.toml" config_file = project_root / "config.toml"
cred_file = project_root / "credentials.toml" cred_file = project_root / "credentials.toml"
mcp_file = project_root / "mcp_env.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()) env["SLOP_CREDENTIALS"] = str(cred_file.absolute())
if mcp_file.exists(): if mcp_file.exists():
env["SLOP_MCP_ENV"] = str(mcp_file.absolute()) env["SLOP_MCP_ENV"] = str(mcp_file.absolute())
env["SLOP_GLOBAL_PRESETS"] = str((temp_workspace / "presets.toml").absolute())
process = subprocess.Popen( process = subprocess.Popen(
["uv", "run", "python", "-u", gui_script, "--enable-test-hooks"], ["uv", "run", "python", "-u", gui_script, "--enable-test-hooks"],

View File

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

77
tests/test_presets.py Normal file
View File

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

View File

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