diff --git a/conductor/tracks/on_demand_def_lookup_20260306/plan.md b/conductor/tracks/on_demand_def_lookup_20260306/plan.md index 8510503..467b98d 100644 --- a/conductor/tracks/on_demand_def_lookup_20260306/plan.md +++ b/conductor/tracks/on_demand_def_lookup_20260306/plan.md @@ -36,7 +36,7 @@ Focus: Use existing MCP tool to get definitions ## Phase 3: Inline Display Focus: Display definition in discussion -- [ ] Task 3.1: Inject definition as context +- [~] Task 3.1: Inject definition as context - WHERE: `src/gui_2.py` `_send_callback()` - WHAT: Append definition to message - HOW: diff --git a/config.toml b/config.toml index c44eb89..e6f1a3f 100644 --- a/config.toml +++ b/config.toml @@ -1,6 +1,6 @@ [ai] -provider = "deepseek" -model = "deepseek-chat" +provider = "gemini" +model = "gemini-2.5-flash-lite" temperature = 0.0 max_tokens = 8192 history_trunc_limit = 8000 @@ -34,8 +34,8 @@ separate_tool_calls_panel = false "Tier 4: QA" = true "Discussion Hub" = true "Operations Hub" = true -Message = true -Response = true +Message = false +Response = false "Tool Calls" = true Theme = true "Log Management" = true diff --git a/src/app_controller.py b/src/app_controller.py index 9d0fe71..0267ef5 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -46,11 +46,12 @@ def parse_symbols(text: str) -> list[str]: """ return re.findall(r"@([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)", text) -def get_symbol_definition(symbol: str, files: list[str]) -> tuple[str, str] | None: +def get_symbol_definition(symbol: str, files: list[str]) -> tuple[str, str, int] | None: for file_path in files: - result = mcp_client.py_get_definition(file_path, symbol) - if 'not found' not in result.lower(): - return (file_path, result) + result = mcp_client.py_get_symbol_info(file_path, symbol) + if isinstance(result, tuple): + source, line = result + return (file_path, source, line) return None class GenerateRequest(BaseModel): @@ -1827,6 +1828,15 @@ class AppController: self.last_file_items = file_items self._set_status("sending...") user_msg = self.ui_ai_input + + symbols = parse_symbols(user_msg) + file_paths = [f['path'] for f in file_items] + for symbol in symbols: + res = get_symbol_definition(symbol, file_paths) + if res: + file_path, definition, line = res + user_msg += f'\n\n[Definition: {symbol} from {file_path} (line {line})]\n```python\n{definition}\n```' + base_dir = self.ui_files_base_dir sys.stderr.write(f"[DEBUG] _do_generate success. Prompt: {user_msg[:50]}...\n") sys.stderr.flush() diff --git a/src/gui_2.py b/src/gui_2.py index 68cf4f7..424beb4 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -21,6 +21,8 @@ from src import log_registry from src import log_pruner from src import models from src import app_controller +from src import mcp_client +import re from pydantic import BaseModel from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed @@ -1404,7 +1406,33 @@ class App: if read_mode: imgui.begin_child("read_content", imgui.ImVec2(0, 150), True) if self.ui_word_wrap: imgui.push_text_wrap_pos(imgui.get_content_region_avail().x) - imgui.text(entry["content"]) + content = entry["content"] + last_idx = 0 + pattern = re.compile(r"\[Definition: (.*?) from (.*?) \(line (\d+)\)\](\s+```[\s\S]*?```)?") + for match in pattern.finditer(content): + before = content[last_idx:match.start()] + if before: imgui.text(before) + header_text = match.group(0).split("\n")[0].strip() + path = match.group(2) + code_block = match.group(4) + if imgui.collapsing_header(header_text): + if imgui.button(f"[Source]##{i}_{match.start()}"): + res = mcp_client.read_file(path) + if res: + self.text_viewer_title = path + self.text_viewer_content = res + self.show_text_viewer = True + if code_block: + code_content = code_block.strip() + if code_content.count("\n") + 1 > 50: + imgui.begin_child(f"code_{i}_{match.start()}", imgui.ImVec2(0, 200), True) + imgui.text(code_content) + imgui.end_child() + else: + imgui.text(code_content) + last_idx = match.end() + after = content[last_idx:] + if after: imgui.text(after) if self.ui_word_wrap: imgui.pop_text_wrap_pos() imgui.end_child() else: diff --git a/src/mcp_client.py b/src/mcp_client.py index 5c5e839..12cd56b 100644 --- a/src/mcp_client.py +++ b/src/mcp_client.py @@ -384,6 +384,32 @@ def _get_symbol_node(tree: ast.AST, name: str) -> Optional[ast.AST]: current = found return current +def py_get_symbol_info(path: str, name: str) -> tuple[str, int] | str: + """ + Returns (source_code, line_number) for a specific class, function, or method definition. + If not found, returns an error string. + """ + p, err = _resolve_and_check(path) + if err: + return err + assert p is not None + if not p.exists(): + return f"ERROR: file not found: {path}" + if not p.is_file(): + return f"ERROR: not a file: {path}" + try: + code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) + lines = code.splitlines(keepends=True) + tree = ast.parse(code) + node = _get_symbol_node(tree, name) + if node: + start = cast(int, getattr(node, "lineno")) + end = cast(int, getattr(node, "end_lineno")) + return ("".join(lines[start-1:end]), start) + return f"ERROR: definition '{name}' not found in {path}" + except Exception as e: + return f"ERROR retrieving definition '{name}' from '{path}': {e}" + def py_get_definition(path: str, name: str) -> str: """ Returns the source code for a specific class, function, or method definition. diff --git a/tests/test_gui_symbol_navigation.py b/tests/test_gui_symbol_navigation.py new file mode 100644 index 0000000..12df1b9 --- /dev/null +++ b/tests/test_gui_symbol_navigation.py @@ -0,0 +1,79 @@ +import pytest +from unittest.mock import MagicMock, patch +from src.gui_2 import App + +@pytest.mark.parametrize("role", ["User", "AI"]) +def test_render_discussion_panel_symbol_lookup(mock_app, role): + # Mock imgui, mcp_client, and project_manager as requested + with ( + patch('src.gui_2.imgui') as mock_imgui, + patch('src.gui_2.mcp_client') as mock_mcp, + patch('src.gui_2.project_manager') as mock_pm + ): + # Set up App instance state + mock_app.perf_profiling_enabled = False + mock_app.ai_status = "idle" + mock_app.is_viewing_prior_session = False + mock_app.active_discussion = "Default" + mock_app.project = {"discussion": {"discussions": {"Default": {}}}} + mock_app.disc_entries = [{"role": role, "content": "[Definition: MyClass from src/models.py (line 10)]", "collapsed": False, "read_mode": True}] + mock_app.disc_roles = ["User", "AI"] + mock_app.ui_files_base_dir = "." + mock_app.active_track = None + mock_app.ui_disc_new_name_input = "" + mock_app.ui_auto_add_history = False + mock_app.ui_disc_truncate_pairs = 10 + mock_app.ui_disc_new_role_input = "" + mock_app._disc_entries_lock = MagicMock() + mock_app._scroll_disc_to_bottom = False + mock_app.ui_word_wrap = False + mock_app.show_text_viewer = False + mock_app.text_viewer_title = "" + mock_app.text_viewer_content = "" + + # Mock internal methods to avoid side effects + mock_app._get_discussion_names = MagicMock(return_value=["Default"]) + mock_app._render_text_viewer = MagicMock() + + # Mock imgui behavior to reach the entry rendering loop + mock_imgui.collapsing_header.return_value = True + mock_imgui.begin_combo.return_value = False + mock_imgui.begin_child.return_value = True + mock_imgui.checkbox.return_value = (False, False) + mock_imgui.input_text.side_effect = lambda label, value, *args, **kwargs: (False, value) + mock_imgui.input_text_multiline.side_effect = lambda label, value, *args, **kwargs: (False, value) + mock_imgui.input_int.side_effect = lambda label, value, *args, **kwargs: (False, value) + + # Mock clipper to process the single entry + mock_clipper = MagicMock() + mock_imgui.ListClipper.return_value = mock_clipper + mock_clipper.step.side_effect = [True, False] + mock_clipper.display_start = 0 + mock_clipper.display_end = 1 + + # Mock button click for the [Source] button + # The code renders: if imgui.button(f"[Source]##{i}_{match.start()}"): + # We want it to return True for our entry at index 0. + def button_side_effect(label): + if label == "[Source]##0_0": + return True + return False + mock_imgui.button.side_effect = button_side_effect + + # Mock mcp_client.read_file return value + mock_mcp.read_file.return_value = "class MyClass:\n pass" + + # Execute the panel rendering + mock_app._render_discussion_panel() + + # Assertions + # 1. Assert that the regex correctly identifies the pattern and imgui.button('[Source]##0_0') is called + mock_imgui.button.assert_any_call("[Source]##0_0") + + # 2. Verify mcp_client.read_file('src/models.py') is called upon button click + mock_mcp.read_file.assert_called_with("src/models.py") + + # 3. Verify the text viewer state is updated correctly + assert mock_app.text_viewer_title == "src/models.py" + assert mock_app.text_viewer_content == "class MyClass:\n pass" + assert mock_app.show_text_viewer is True diff --git a/tests/test_symbol_lookup.py b/tests/test_symbol_lookup.py index ceade6d..f8ab7f1 100644 --- a/tests/test_symbol_lookup.py +++ b/tests/test_symbol_lookup.py @@ -32,24 +32,25 @@ class TestSymbolLookup(unittest.TestCase): files = ["file1.py", "file2.py"] symbol = "my_func" def_content = "def my_func():\n pass" + line_number = 10 - with patch("src.mcp_client.py_get_definition") as mock_get_def: + with patch("src.mcp_client.py_get_symbol_info") as mock_get_info: # First file not found, second file found - mock_get_def.side_effect = [ + mock_get_info.side_effect = [ "ERROR: definition 'my_func' not found in file1.py", - def_content + (def_content, line_number) ] result = get_symbol_definition(symbol, files) - self.assertEqual(result, ("file2.py", def_content)) - self.assertEqual(mock_get_def.call_count, 2) + self.assertEqual(result, ("file2.py", def_content, line_number)) + self.assertEqual(mock_get_info.call_count, 2) def test_get_symbol_definition_not_found(self): files = ["file1.py"] symbol = "my_func" - with patch("src.mcp_client.py_get_definition") as mock_get_def: - mock_get_def.return_value = "ERROR: definition 'my_func' not found in file1.py" + with patch("src.mcp_client.py_get_symbol_info") as mock_get_info: + mock_get_info.return_value = "ERROR: definition 'my_func' not found in file1.py" result = get_symbol_definition(symbol, files) self.assertIsNone(result) diff --git a/tests/test_symbol_parsing.py b/tests/test_symbol_parsing.py new file mode 100644 index 0000000..a1c172d --- /dev/null +++ b/tests/test_symbol_parsing.py @@ -0,0 +1,84 @@ +import pytest +from unittest.mock import MagicMock, patch +from pathlib import Path +from src.app_controller import AppController +from src.events import UserRequestEvent + +@pytest.fixture +def controller(): + with ( + patch('src.models.load_config', return_value={ + "ai": {"provider": "gemini", "model": "model-1"}, + "projects": {"paths": [], "active": ""}, + "gui": {"show_windows": {}} + }), + patch('src.project_manager.load_project', return_value={}), + patch('src.project_manager.migrate_from_legacy_config', return_value={}), + patch('src.project_manager.save_project'), + patch('src.session_logger.open_session'), + patch('src.app_controller.AppController._init_ai_and_hooks'), + patch('src.app_controller.AppController._fetch_models') + ): + c = AppController() + # Mock necessary state + c.ui_files_base_dir = "." + c.event_queue = MagicMock() + return c + +def test_handle_generate_send_appends_definitions(controller): + # Setup + file_items = [{"path": "src/models.py", "entry": "src/models.py"}] + controller._do_generate = MagicMock(return_value=( + "full_md", Path("output.md"), file_items, "stable_md", "disc_text" + )) + controller.ui_ai_input = "Explain @Track object" + + # Mock symbol helpers + with ( + patch('src.app_controller.parse_symbols', return_value=["Track"]) as mock_parse, + patch('src.app_controller.get_symbol_definition', return_value=("src/models.py", "class Track: pass")) as mock_get_def, + patch('threading.Thread') as mock_thread + ): + # Execute + controller._handle_generate_send() + + # Run worker manually + worker = mock_thread.call_args[1]['target'] + worker() + + # Verify + mock_parse.assert_called_once_with("Explain @Track object") + mock_get_def.assert_called_once() + + controller.event_queue.put.assert_called_once() + event_name, event_payload = controller.event_queue.put.call_args[0] + assert event_name == "user_request" + assert isinstance(event_payload, UserRequestEvent) + + # Check if definition was appended + expected_suffix = "\n\n[Definition: Track from src/models.py]\n```python\nclass Track: pass\n```" + assert event_payload.prompt == "Explain @Track object" + expected_suffix + +def test_handle_generate_send_no_symbols(controller): + # Setup + file_items = [{"path": "src/models.py", "entry": "src/models.py"}] + controller._do_generate = MagicMock(return_value=( + "full_md", Path("output.md"), file_items, "stable_md", "disc_text" + )) + controller.ui_ai_input = "Just a normal prompt" + + with ( + patch('src.app_controller.parse_symbols', return_value=[]) as mock_parse, + patch('threading.Thread') as mock_thread + ): + # Execute + controller._handle_generate_send() + + # Run worker manually + worker = mock_thread.call_args[1]['target'] + worker() + + # Verify + controller.event_queue.put.assert_called_once() + _, event_payload = controller.event_queue.put.call_args[0] + assert event_payload.prompt == "Just a normal prompt"