From a02849b9a336cd7c2a83825664d9f33b9740c260 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Tue, 5 May 2026 00:23:55 -0400 Subject: [PATCH] conductor(checkpoint): Checkpoint end of Phase 2 - Text Input & Control Undo/Redo --- conductor/tests/verify_phase_2.py | 24 ++++++ src/gui_2.py | 130 ++++++++++++++++++++++++++++++ src/history.py | 49 +++++++++++ tests/test_undo_redo_sim.py | 60 ++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 conductor/tests/verify_phase_2.py create mode 100644 tests/test_undo_redo_sim.py diff --git a/conductor/tests/verify_phase_2.py b/conductor/tests/verify_phase_2.py new file mode 100644 index 0000000..03189fb --- /dev/null +++ b/conductor/tests/verify_phase_2.py @@ -0,0 +1,24 @@ +import subprocess +import sys +import os + +def verify_phase_2(): + print("Verifying Phase 2: Text Input & Control Undo/Redo...") + + # Run the simulation test + result = subprocess.run( + ["uv", "run", "pytest", "tests/test_undo_redo_sim.py"], + capture_output=True, + text=True + ) + + if result.returncode == 0: + print("Phase 2 verification PASSED.") + else: + print("Phase 2 verification FAILED.") + print(result.stdout) + print(result.stderr) + sys.exit(1) + +if __name__ == "__main__": + verify_phase_2() diff --git a/src/gui_2.py b/src/gui_2.py index 80a32b9..52249d3 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -103,6 +103,14 @@ class App: def __init__(self) -> None: # Initialize controller and delegate state self.controller = app_controller.AppController() + from src import history + self.history = history.HistoryManager(max_capacity=100) + self._last_ui_snapshot: Optional[history.UISnapshot] = None + self._snapshot_timer: float = 0.0 + self._snapshot_debounce: float = 1.5 + self._pending_snapshot: bool = False + self._is_applying_snapshot: bool = False + # Restore legacy PROVIDERS to controller if needed (it already has it via delegation if set on class level, but let's be explicit) if not hasattr(self.controller, 'PROVIDERS'): self.controller.PROVIDERS = PROVIDERS @@ -114,6 +122,10 @@ class App: self.controller._predefined_callbacks['load_context_preset'] = self.load_context_preset self.controller._predefined_callbacks['set_ui_file_paths'] = lambda p: setattr(self, 'ui_file_paths', p) self.controller._predefined_callbacks['set_ui_screenshot_paths'] = lambda p: setattr(self, 'ui_screenshot_paths', p) + self.controller._clickable_actions.update({ + 'btn_undo': self._handle_undo, + 'btn_redo': self._handle_redo + }) def simulate_save_preset(name: str): from src import models self.files = [models.FileItem(path='test.py')] @@ -287,6 +299,68 @@ class App: def perf_profiling_enabled(self, value: bool) -> None: self.controller.perf_profiling_enabled = value + def _take_snapshot(self) -> history.UISnapshot: + from src import history + import copy + return history.UISnapshot( + ai_input=self.ui_ai_input, + project_system_prompt=self.ui_project_system_prompt, + global_system_prompt=self.ui_global_system_prompt, + base_system_prompt=self.ui_base_system_prompt, + use_default_base_prompt=self.ui_use_default_base_prompt, + temperature=self.temperature, + top_p=self.top_p, + max_tokens=self.max_tokens, + auto_add_history=self.ui_auto_add_history, + disc_entries=copy.deepcopy(self.disc_entries), + files=[f.to_dict() if hasattr(f, 'to_dict') else f for f in self.files], + screenshots=list(self.screenshots) + ) + + def _apply_snapshot(self, snapshot: history.UISnapshot) -> None: + self._is_applying_snapshot = True + try: + self.ui_ai_input = snapshot.ai_input + self.ui_project_system_prompt = snapshot.project_system_prompt + self.ui_global_system_prompt = snapshot.global_system_prompt + self.ui_base_system_prompt = snapshot.base_system_prompt + self.ui_use_default_base_prompt = snapshot.use_default_base_prompt + self.temperature = snapshot.temperature + self.top_p = snapshot.top_p + self.max_tokens = snapshot.max_tokens + self.ui_auto_add_history = snapshot.auto_add_history + self.disc_entries = snapshot.disc_entries + + # Restore files as FileItem objects + from src import models + self.files = [] + for f in snapshot.files: + if isinstance(f, dict): + self.files.append(models.FileItem.from_dict(f)) + else: + self.files.append(models.FileItem(path=str(f))) + + self.screenshots = list(snapshot.screenshots) + self._last_ui_snapshot = snapshot # Update last snapshot to avoid immediate re-push + finally: + self._is_applying_snapshot = False + + def _handle_undo(self) -> None: + if not self.history.can_undo: + return + current = self._take_snapshot() + entry = self.history.undo(current, "Undo Action") + if entry: + self._apply_snapshot(entry.state) + + def _handle_redo(self) -> None: + if not self.history.can_redo: + return + current = self._take_snapshot() + entry = self.history.redo(current, "Redo Action") + if entry: + self._apply_snapshot(entry.state) + def shutdown(self) -> None: """Cleanly shuts down the app's background tasks and saves state.""" try: @@ -1131,8 +1205,64 @@ class App: if pushed_prior_tint: imgui.pop_style_color() + self._handle_history_logic() + if self.perf_profiling_enabled: self.perf_monitor.end_component("_gui_func") + def _handle_history_logic(self) -> None: + if self._is_applying_snapshot: + return + + io = imgui.get_io() + + # 1. Hotkey handling (Undo: Ctrl+Z, Redo: Ctrl+Y or Ctrl+Shift+Z) + ctrl = io.key_ctrl + shift = io.key_shift + + if ctrl: + if imgui.is_key_pressed(imgui.Key.z): + if shift: + self._handle_redo() + else: + self._handle_undo() + elif imgui.is_key_pressed(imgui.Key.y): + self._handle_redo() + + # 2. Debounced snapshotting + current = self._take_snapshot() + if self._last_ui_snapshot is None: + self._last_ui_snapshot = current + return + + # Compare only core fields for performance + changed = ( + current.ai_input != self._last_ui_snapshot.ai_input or + current.project_system_prompt != self._last_ui_snapshot.project_system_prompt or + current.global_system_prompt != self._last_ui_snapshot.global_system_prompt or + current.base_system_prompt != self._last_ui_snapshot.base_system_prompt or + current.use_default_base_prompt != self._last_ui_snapshot.use_default_base_prompt or + current.temperature != self._last_ui_snapshot.temperature or + current.top_p != self._last_ui_snapshot.top_p or + current.max_tokens != self._last_ui_snapshot.max_tokens or + current.auto_add_history != self._last_ui_snapshot.auto_add_history or + len(current.disc_entries) != len(self._last_ui_snapshot.disc_entries) + ) + + if changed: + if not self._pending_snapshot: + self._pending_snapshot = True + self._snapshot_timer = time.time() + self._state_to_push = self._last_ui_snapshot + else: + self._snapshot_timer = time.time() + + self._last_ui_snapshot = current + + if self._pending_snapshot and (time.time() - self._snapshot_timer > self._snapshot_debounce): + if self._state_to_push: + self.history.push(self._state_to_push, "UI State Change") + self._pending_snapshot = False + def _render_base_prompt_diff_modal(self) -> None: if not getattr(self.controller, "_show_base_prompt_diff_modal", False): return diff --git a/src/history.py b/src/history.py index 8734f33..dd86149 100644 --- a/src/history.py +++ b/src/history.py @@ -2,6 +2,55 @@ import typing import time from dataclasses import dataclass, field +@dataclass +class UISnapshot: + """Capture of restorable UI state.""" + ai_input: str + project_system_prompt: str + global_system_prompt: str + base_system_prompt: str + use_default_base_prompt: bool + temperature: float + top_p: float + max_tokens: int + auto_add_history: bool + disc_entries: list[dict] + files: list[dict] + screenshots: list[str] + + def to_dict(self) -> dict: + return { + "ai_input": self.ai_input, + "project_system_prompt": self.project_system_prompt, + "global_system_prompt": self.global_system_prompt, + "base_system_prompt": self.base_system_prompt, + "use_default_base_prompt": self.use_default_base_prompt, + "temperature": self.temperature, + "top_p": self.top_p, + "max_tokens": self.max_tokens, + "auto_add_history": self.auto_add_history, + "disc_entries": self.disc_entries, + "files": self.files, + "screenshots": self.screenshots + } + + @classmethod + def from_dict(cls, data: dict) -> "UISnapshot": + return cls( + ai_input=data.get("ai_input", ""), + project_system_prompt=data.get("project_system_prompt", ""), + global_system_prompt=data.get("global_system_prompt", ""), + base_system_prompt=data.get("base_system_prompt", ""), + use_default_base_prompt=data.get("use_default_base_prompt", True), + temperature=data.get("temperature", 0.0), + top_p=data.get("top_p", 1.0), + max_tokens=data.get("max_tokens", 4096), + auto_add_history=data.get("auto_add_history", False), + disc_entries=data.get("disc_entries", []), + files=data.get("files", []), + screenshots=data.get("screenshots", []) + ) + @dataclass class HistoryEntry: state: typing.Any diff --git a/tests/test_undo_redo_sim.py b/tests/test_undo_redo_sim.py new file mode 100644 index 0000000..ce823d9 --- /dev/null +++ b/tests/test_undo_redo_sim.py @@ -0,0 +1,60 @@ +import pytest +import time +import sys +import os +import json + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) + +from src import api_hook_client + +@pytest.mark.integration +def test_undo_redo_lifecycle(live_gui): + client = api_hook_client.ApiHookClient() + assert client.wait_for_server(timeout=15), "Hook server did not start" + + # 1. Set initial state + client.set_value('temperature', 0.5) + client.set_value('ai_input', "Initial Input") + time.sleep(0.5) # Wait for debounce timer to start if it was triggered + + # Trigger a snapshot by waiting for debounce + time.sleep(2.0) + + # 2. Change state + client.set_value('temperature', 1.5) + client.set_value('ai_input', "Modified Input") + + # Wait for debounce to push "Modified" state to history + time.sleep(2.0) + + # Verify current state + assert client.get_value('temperature') == 1.5 + assert client.get_value('ai_input') == "Modified Input" + + # 3. Undo + print("Sending Undo...") + # Since we don't have a direct 'undo' hook, we can use 'click' if we added a button, + # or we can simulate the hotkey if the hook API supports it. + # Actually, I'll add a 'btn_undo' and 'btn_redo' to clickable actions for testing. + client.click('btn_undo') + + # Wait for state to revert + time.sleep(0.5) + + # Should be back to initial state + # Note: Undo moves 'current' to redo stack and pops from undo. + # If we were at 'Modified', and we undo, we should get 'Initial'. + assert client.get_value('ai_input') == "Initial Input" + assert client.get_value('temperature') == 0.5 + + # 4. Redo + print("Sending Redo...") + client.click('btn_redo') + time.sleep(0.5) + + assert client.get_value('ai_input') == "Modified Input" + assert client.get_value('temperature') == 1.5 + + print("Undo/Redo lifecycle test PASSED.")