Compare commits

...

4 Commits

10 changed files with 254 additions and 43 deletions

View File

@@ -57,3 +57,4 @@ For deep implementation details when planning or implementing tracks, consult `d
- **Remote Confirmation Protocol:** A non-blocking, ID-based challenge/response mechanism for approving AI actions via the REST API, enabling remote "Human-in-the-Loop" safety.
- **Gemini CLI Integration:** Allows using the `gemini` CLI as a headless backend provider. This enables leveraging Gemini subscriptions with advanced features like persistent sessions, while maintaining full "Human-in-the-Loop" safety through a dedicated bridge for synchronous tool call approvals within the Manual Slop GUI. Now features full functional parity with the direct API, including accurate token estimation, safety settings, and robust system instruction handling.
- **Context & Token Visualization:** Detailed UI panels for monitoring real-time token usage, history depth, and **visual cache awareness** (tracking specific files currently live in the provider's context cache).
- **On-Demand Definition Lookup:** Allows developers to request specific class or function definitions during discussions using `@SymbolName` syntax. Injected definitions feature syntax highlighting, intelligent collapsing for long blocks, and a **[Source]** button for instant navigation to the full file.

View File

@@ -59,7 +59,7 @@ This file tracks all major tracks for the project. Each track has its own detail
12. [x] **Track: Manual Skeleton Context Injection**
*Link: [./tracks/manual_skeleton_injection_20260306/](./tracks/manual_skeleton_injection_20260306/)*
13. [~] **Track: On-Demand Definition Lookup**
13. [x] **Track: On-Demand Definition Lookup**
*Link: [./tracks/on_demand_def_lookup_20260306/](./tracks/on_demand_def_lookup_20260306/)*
---

View File

@@ -33,35 +33,17 @@ Focus: Use existing MCP tool to get definitions
return None
```
## Phase 3: Inline Display
## Phase 3: Inline Display [checkpoint: 7ea833e]
Focus: Display definition in discussion
- [ ] Task 3.1: Inject definition as context
- WHERE: `src/gui_2.py` `_send_callback()`
- WHAT: Append definition to message
- HOW:
```python
symbols = parse_symbols(user_message)
for symbol in symbols:
result = get_symbol_definition(symbol, self.project_files)
if result:
file_path, definition = result
user_message += f"\n\n[Definition: {symbol} from {file_path}]\n```python\n{definition}\n```"
```
- [x] Task 3.1: Inject definition as context (7ea833e)
## Phase 4: Click Navigation
## Phase 4: Click Navigation [checkpoint: 7ea833e]
Focus: Allow clicking definition to open file
- [ ] Task 4.1: Store file/line metadata with definition
- WHERE: Discussion entry structure
- WHAT: Track source location
- HOW: Add to discussion entry dict
- [x] Task 4.1: Store file/line metadata with definition (7ea833e)
- [x] Task 4.2: Add click handler (7ea833e)
- [ ] Task 4.2: Add click handler
- WHERE: `src/gui_2.py` discussion rendering
- WHAT: On click, scroll to definition
- HOW: Use selectable text with callback
## Phase 5: Testing
- [ ] Task 5.1: Write unit tests for parsing
- [ ] Task 5.2: Conductor - Phase Verification
## Phase 5: Testing [checkpoint: 7ea833e]
- [x] Task 5.1: Write unit tests for parsing (7ea833e)
- [x] Task 5.2: Conductor - Phase Verification (7ea833e)

View File

@@ -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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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.

View 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

View File

@@ -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)

View 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"