diff --git a/conductor/tracks.md b/conductor/tracks.md index 568c6f8..d8c1b45 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -56,7 +56,7 @@ This file tracks all major tracks for the project. Each track has its own detail 11. [x] **Track: Track Progress Visualization** *Link: [./tracks/track_progress_viz_20260306/](./tracks/track_progress_viz_20260306/)* -12. [ ] **Track: Manual Skeleton Context Injection** +12. [~] **Track: Manual Skeleton Context Injection** *Link: [./tracks/manual_skeleton_injection_20260306/](./tracks/manual_skeleton_injection_20260306/)* 13. [ ] **Track: On-Demand Definition Lookup** diff --git a/conductor/tracks/manual_skeleton_injection_20260306/plan.md b/conductor/tracks/manual_skeleton_injection_20260306/plan.md index 15d2b2b..9a2ed01 100644 --- a/conductor/tracks/manual_skeleton_injection_20260306/plan.md +++ b/conductor/tracks/manual_skeleton_injection_20260306/plan.md @@ -5,10 +5,10 @@ ## Phase 1: UI Foundation Focus: Add file injection button and state -- [ ] Task 1.1: Initialize MMA Environment +- [x] Task 1.1: Initialize MMA Environment - Run `activate_skill mma-orchestrator` before starting -- [ ] Task 1.2: Add injection state variables +- [~] Task 1.2: Add injection state variables - WHERE: `src/gui_2.py` `App.__init__` - WHAT: State for injection UI - HOW: diff --git a/src/app_controller.py b/src/app_controller.py index c6b7664..62f5099 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -270,6 +270,11 @@ class AppController: self.prior_session_entries: List[Dict[str, Any]] = [] self.test_hooks_enabled: bool = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1") self.ui_manual_approve: bool = False + # Injection state + self._inject_file_path: str = "" + self._inject_mode: str = "skeleton" + self._inject_preview: str = "" + self._show_inject_modal: bool = False self._settable_fields: Dict[str, str] = { 'ai_input': 'ui_ai_input', 'project_git_dir': 'ui_project_git_dir', @@ -293,7 +298,10 @@ class AppController: 'mma_active_tier': 'active_tier', 'ui_new_track_name': 'ui_new_track_name', 'ui_new_track_desc': 'ui_new_track_desc', - 'manual_approve': 'ui_manual_approve' + 'manual_approve': 'ui_manual_approve', + 'inject_file_path': '_inject_file_path', + 'inject_mode': '_inject_mode', + 'show_inject_modal': '_show_inject_modal' } self._gettable_fields = dict(self._settable_fields) self._gettable_fields.update({ @@ -311,10 +319,40 @@ class AppController: 'prior_session_indicator': 'prior_session_indicator', '_show_patch_modal': '_show_patch_modal', '_pending_patch_text': '_pending_patch_text', - '_pending_patch_files': '_pending_patch_files' + '_pending_patch_files': '_pending_patch_files', + '_inject_file_path': '_inject_file_path', + '_inject_mode': '_inject_mode', + '_inject_preview': '_inject_preview', + '_show_inject_modal': '_show_inject_modal' }) self._init_actions() + def _update_inject_preview(self) -> None: + """Updates the preview content based on the selected file and injection mode.""" + if not self._inject_file_path: + self._inject_preview = "" + return + target_path = self._inject_file_path + if not os.path.isabs(target_path): + target_path = os.path.join(self.ui_files_base_dir, target_path) + if not os.path.exists(target_path): + self._inject_preview = "" + return + try: + with open(target_path, "r", encoding="utf-8") as f: + content = f.read() + if self._inject_mode == "skeleton" and target_path.endswith(".py"): + parser = ASTParser("python") + preview = parser.get_skeleton(content) + else: + preview = content + lines = preview.splitlines() + if len(lines) > 500: + preview = "\n".join(lines[:500]) + "\n... (truncated)" + self._inject_preview = preview + except Exception as e: + self._inject_preview = f"Error reading file: {e}" + @property def thinking_indicator(self) -> bool: return self.ai_status in ("sending...", "streaming...") diff --git a/src/gui_2.py b/src/gui_2.py index 7d2c867..ebb70f4 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -744,6 +744,42 @@ class App: else: imgui.input_text_multiline("##tv_c", self.text_viewer_content, imgui.ImVec2(-1, -1), imgui.InputTextFlags_.read_only) imgui.end() + # Inject File Modal + if getattr(self, "show_inject_modal", False): + imgui.open_popup("Inject File") + self.show_inject_modal = False + if imgui.begin_popup_modal("Inject File", None, imgui.WindowFlags_.always_auto_resize)[0]: + files = self.project.get('files', {}).get('paths', []) + imgui.text("Select File to Inject:") + imgui.begin_child("inject_file_list", imgui.ImVec2(0, 200), True) + for f_path in files: + is_selected = (self._inject_file_path == f_path) + if imgui.selectable(f_path, is_selected)[0]: + self._inject_file_path = f_path + self.controller._update_inject_preview() + imgui.end_child() + imgui.separator() + if imgui.radio_button("Skeleton", self._inject_mode == "skeleton"): + self._inject_mode = "skeleton" + self.controller._update_inject_preview() + imgui.same_line() + if imgui.radio_button("Full", self._inject_mode == "full"): + self._inject_mode = "full" + self.controller._update_inject_preview() + imgui.separator() + imgui.text("Preview:") + imgui.begin_child("inject_preview_area", imgui.ImVec2(600, 300), True) + imgui.text_unformatted(self._inject_preview) + imgui.end_child() + imgui.separator() + if imgui.button("Inject", imgui.ImVec2(120, 0)): + formatted = f"## File: {self._inject_file_path}\n```python\n{self._inject_preview}\n```\n" + self.ui_ai_input += formatted + imgui.close_current_popup() + imgui.same_line() + if imgui.button("Cancel", imgui.ImVec2(120, 0)): + imgui.close_current_popup() + imgui.end_popup() except Exception as e: print(f"ERROR in _gui_func: {e}") import traceback @@ -1543,6 +1579,9 @@ class App: with self._send_thread_lock: if self.send_thread and self.send_thread.is_alive(): send_busy = True + if imgui.button("Inject File"): + self.show_inject_modal = True + imgui.same_line() if (imgui.button("Gen + Send") or ctrl_enter) and not send_busy: self._handle_generate_send() imgui.same_line() diff --git a/tests/test_skeleton_injection.py b/tests/test_skeleton_injection.py new file mode 100644 index 0000000..8f05dfb --- /dev/null +++ b/tests/test_skeleton_injection.py @@ -0,0 +1,63 @@ +import os +import pytest +from src.app_controller import AppController +from pathlib import Path + +def test_skeleton_injection_state_variables(): + ctrl = AppController() + assert hasattr(ctrl, "_inject_file_path") + assert hasattr(ctrl, "_inject_mode") + assert hasattr(ctrl, "_inject_preview") + assert hasattr(ctrl, "_show_inject_modal") + assert ctrl._inject_mode == "skeleton" + assert ctrl._inject_preview == "" + assert ctrl._show_inject_modal is False + +def test_update_inject_preview_skeleton(tmp_path): + ctrl = AppController() + mock_file = tmp_path / "mock.py" + # Use 1-space indent in the mock content too for consistency + content = '"""Module docstring"""\ndef foo():\n """Foo docstring"""\n print("hello")\n\nclass Bar:\n """Bar docstring"""\n def baz(self):\n pass\n' + mock_file.write_text(content) + + ctrl._inject_file_path = str(mock_file) + ctrl._inject_mode = "skeleton" + ctrl._update_inject_preview() + + # Skeleton should contain signatures and docstrings but not bodies + assert "def foo():" in ctrl._inject_preview + assert "class Bar:" in ctrl._inject_preview + assert 'print("hello")' not in ctrl._inject_preview + assert "pass" not in ctrl._inject_preview + assert '"""Foo docstring"""' in ctrl._inject_preview + +def test_update_inject_preview_full(tmp_path): + ctrl = AppController() + mock_file = tmp_path / "mock.py" + content = "line 1\n" * 10 + mock_file.write_text(content) + + ctrl._inject_file_path = str(mock_file) + ctrl._inject_mode = "full" + ctrl._update_inject_preview() + + assert ctrl._inject_preview.strip() == content.strip() + +def test_update_inject_preview_truncation(tmp_path): + ctrl = AppController() + mock_file = tmp_path / "large.py" + content = "\n".join([f"line {i}" for i in range(1000)]) + mock_file.write_text(content) + + ctrl._inject_file_path = str(mock_file) + ctrl._inject_mode = "full" + ctrl._update_inject_preview() + + lines = ctrl._inject_preview.splitlines() + # It should be 500 lines + 1 for truncated message + assert len(lines) == 501 + assert "... (truncated)" in lines[-1] + # The 500th line is "line 499" (starting from 0) + assert "line 499" in lines[499] + # The 501st line should be "line 500", but it should be replaced by truncation msg + assert "line 500" not in ctrl._inject_preview