feat(gui): Implement on-demand definition lookup with clickable navigation and collapsing
This commit is contained in:
@@ -36,7 +36,7 @@ Focus: Use existing MCP tool to get definitions
|
|||||||
## Phase 3: Inline Display
|
## Phase 3: Inline Display
|
||||||
Focus: Display definition in discussion
|
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()`
|
- WHERE: `src/gui_2.py` `_send_callback()`
|
||||||
- WHAT: Append definition to message
|
- WHAT: Append definition to message
|
||||||
- HOW:
|
- HOW:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[ai]
|
[ai]
|
||||||
provider = "deepseek"
|
provider = "gemini"
|
||||||
model = "deepseek-chat"
|
model = "gemini-2.5-flash-lite"
|
||||||
temperature = 0.0
|
temperature = 0.0
|
||||||
max_tokens = 8192
|
max_tokens = 8192
|
||||||
history_trunc_limit = 8000
|
history_trunc_limit = 8000
|
||||||
@@ -34,8 +34,8 @@ separate_tool_calls_panel = false
|
|||||||
"Tier 4: QA" = true
|
"Tier 4: QA" = true
|
||||||
"Discussion Hub" = true
|
"Discussion Hub" = true
|
||||||
"Operations Hub" = true
|
"Operations Hub" = true
|
||||||
Message = true
|
Message = false
|
||||||
Response = true
|
Response = false
|
||||||
"Tool Calls" = true
|
"Tool Calls" = true
|
||||||
Theme = true
|
Theme = true
|
||||||
"Log Management" = true
|
"Log Management" = true
|
||||||
|
|||||||
@@ -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)
|
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:
|
for file_path in files:
|
||||||
result = mcp_client.py_get_definition(file_path, symbol)
|
result = mcp_client.py_get_symbol_info(file_path, symbol)
|
||||||
if 'not found' not in result.lower():
|
if isinstance(result, tuple):
|
||||||
return (file_path, result)
|
source, line = result
|
||||||
|
return (file_path, source, line)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
class GenerateRequest(BaseModel):
|
class GenerateRequest(BaseModel):
|
||||||
@@ -1827,6 +1828,15 @@ class AppController:
|
|||||||
self.last_file_items = file_items
|
self.last_file_items = file_items
|
||||||
self._set_status("sending...")
|
self._set_status("sending...")
|
||||||
user_msg = self.ui_ai_input
|
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
|
base_dir = self.ui_files_base_dir
|
||||||
sys.stderr.write(f"[DEBUG] _do_generate success. Prompt: {user_msg[:50]}...\n")
|
sys.stderr.write(f"[DEBUG] _do_generate success. Prompt: {user_msg[:50]}...\n")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
|
|||||||
30
src/gui_2.py
30
src/gui_2.py
@@ -21,6 +21,8 @@ from src import log_registry
|
|||||||
from src import log_pruner
|
from src import log_pruner
|
||||||
from src import models
|
from src import models
|
||||||
from src import app_controller
|
from src import app_controller
|
||||||
|
from src import mcp_client
|
||||||
|
import re
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed
|
from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed
|
||||||
@@ -1404,7 +1406,33 @@ class App:
|
|||||||
if read_mode:
|
if read_mode:
|
||||||
imgui.begin_child("read_content", imgui.ImVec2(0, 150), True)
|
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)
|
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()
|
if self.ui_word_wrap: imgui.pop_text_wrap_pos()
|
||||||
imgui.end_child()
|
imgui.end_child()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -384,6 +384,32 @@ def _get_symbol_node(tree: ast.AST, name: str) -> Optional[ast.AST]:
|
|||||||
current = found
|
current = found
|
||||||
return current
|
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:
|
def py_get_definition(path: str, name: str) -> str:
|
||||||
"""
|
"""
|
||||||
Returns the source code for a specific class, function, or method definition.
|
Returns the source code for a specific class, function, or method definition.
|
||||||
|
|||||||
79
tests/test_gui_symbol_navigation.py
Normal file
79
tests/test_gui_symbol_navigation.py
Normal file
@@ -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
|
||||||
@@ -32,24 +32,25 @@ class TestSymbolLookup(unittest.TestCase):
|
|||||||
files = ["file1.py", "file2.py"]
|
files = ["file1.py", "file2.py"]
|
||||||
symbol = "my_func"
|
symbol = "my_func"
|
||||||
def_content = "def my_func():\n pass"
|
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
|
# 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",
|
"ERROR: definition 'my_func' not found in file1.py",
|
||||||
def_content
|
(def_content, line_number)
|
||||||
]
|
]
|
||||||
|
|
||||||
result = get_symbol_definition(symbol, files)
|
result = get_symbol_definition(symbol, files)
|
||||||
self.assertEqual(result, ("file2.py", def_content))
|
self.assertEqual(result, ("file2.py", def_content, line_number))
|
||||||
self.assertEqual(mock_get_def.call_count, 2)
|
self.assertEqual(mock_get_info.call_count, 2)
|
||||||
|
|
||||||
def test_get_symbol_definition_not_found(self):
|
def test_get_symbol_definition_not_found(self):
|
||||||
files = ["file1.py"]
|
files = ["file1.py"]
|
||||||
symbol = "my_func"
|
symbol = "my_func"
|
||||||
|
|
||||||
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:
|
||||||
mock_get_def.return_value = "ERROR: definition 'my_func' not found in file1.py"
|
mock_get_info.return_value = "ERROR: definition 'my_func' not found in file1.py"
|
||||||
|
|
||||||
result = get_symbol_definition(symbol, files)
|
result = get_symbol_definition(symbol, files)
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
|
|||||||
84
tests/test_symbol_parsing.py
Normal file
84
tests/test_symbol_parsing.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user