conductor(checkpoint): Checkpoint end of Phase 2 - Text Input & Control Undo/Redo

This commit is contained in:
2026-05-05 00:23:55 -04:00
parent 8513604539
commit a02849b9a3
4 changed files with 263 additions and 0 deletions
+24
View File
@@ -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()
+130
View File
@@ -103,6 +103,14 @@ class App:
def __init__(self) -> None: def __init__(self) -> None:
# Initialize controller and delegate state # Initialize controller and delegate state
self.controller = app_controller.AppController() 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) # 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'): if not hasattr(self.controller, 'PROVIDERS'):
self.controller.PROVIDERS = 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['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_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._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): def simulate_save_preset(name: str):
from src import models from src import models
self.files = [models.FileItem(path='test.py')] self.files = [models.FileItem(path='test.py')]
@@ -287,6 +299,68 @@ class App:
def perf_profiling_enabled(self, value: bool) -> None: def perf_profiling_enabled(self, value: bool) -> None:
self.controller.perf_profiling_enabled = value 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: def shutdown(self) -> None:
"""Cleanly shuts down the app's background tasks and saves state.""" """Cleanly shuts down the app's background tasks and saves state."""
try: try:
@@ -1131,8 +1205,64 @@ class App:
if pushed_prior_tint: if pushed_prior_tint:
imgui.pop_style_color() imgui.pop_style_color()
self._handle_history_logic()
if self.perf_profiling_enabled: self.perf_monitor.end_component("_gui_func") 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: def _render_base_prompt_diff_modal(self) -> None:
if not getattr(self.controller, "_show_base_prompt_diff_modal", False): if not getattr(self.controller, "_show_base_prompt_diff_modal", False):
return return
+49
View File
@@ -2,6 +2,55 @@ import typing
import time import time
from dataclasses import dataclass, field 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 @dataclass
class HistoryEntry: class HistoryEntry:
state: typing.Any state: typing.Any
+60
View File
@@ -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.")