From 0fe333ea719a4e626003fab335b932482eb1570f Mon Sep 17 00:00:00 2001 From: CleverThis Date: Tue, 28 Apr 2026 11:29:52 +0000 Subject: [PATCH 1/5] fix(tui): extract @token text correctly in on_input_submitted suggestions query Replace text.replace("@", "").strip() with re.findall(r"@(\S+)", text) to extract only the last @token text (without the @ sign and without surrounding non-reference words) as the query passed to suggestions(). Previously, a prompt like "analyse @proj" would pass "analyse proj" as the query to suggestions(), producing garbage fuzzy matches. Now it correctly passes "proj". Add TDD regression tests (tdd_tui_suggestions_query_extraction_4741.feature) with @tdd_issue and @tdd_issue_4741 tags covering: - Single @token in multi-word prompt - @token with category prefix - Multiple @tokens (uses last token) - Standalone @token at start of prompt ISSUES CLOSED: #4741 --- ...suggestions_query_extraction_4741_steps.py | 274 ++++++++++++++++++ ..._suggestions_query_extraction_4741.feature | 42 +++ src/cleveragents/tui/app.py | 8 +- 3 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py create mode 100644 features/tdd_tui_suggestions_query_extraction_4741.feature diff --git a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py new file mode 100644 index 000000000..fb8a88b6e --- /dev/null +++ b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py @@ -0,0 +1,274 @@ +"""Step definitions for tdd_tui_suggestions_query_extraction_4741.feature. + +Regression test for issue #4741: + on_input_submitted must extract only the @token text (without the @ sign and + without surrounding non-reference words) as the query passed to suggestions(). + + Previously, text.replace("@", "").strip() was used, which corrupted the query + by including all non-reference words from the prompt. +""" + +from __future__ import annotations + +import importlib +import shutil +import sys +import tempfile +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import patch + +from behave import given, then, when + +# --------------------------------------------------------------------------- +# Mock Textual infrastructure (mirrors tui_app_coverage_steps.py) +# --------------------------------------------------------------------------- + +_MOCK_TEXTUAL_KEYS = [ + "textual", + "textual.app", + "textual.containers", + "textual.widgets", +] + + +def _build_mock_textual_4741() -> dict[str, ModuleType]: + """Build mock textual modules that satisfy the app import gate.""" + mock_textual = ModuleType("textual") + mock_textual_app = ModuleType("textual.app") + mock_textual_containers = ModuleType("textual.containers") + mock_textual_widgets = ModuleType("textual.widgets") + + class MockApp: + """Minimal App stand-in for the Textual base class.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + self._widgets: dict[str, object] = {} + + def query_one( + self, selector: str, widget_type: type | None = None + ) -> object: + if selector in self._widgets: + return self._widgets[selector] + if widget_type is not None: + widget = widget_type(id=selector.lstrip("#")) + self._widgets[selector] = widget + return widget + from unittest.mock import MagicMock + + return MagicMock() + + class MockVertical: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def __enter__(self) -> MockVertical: + return self + + def __exit__(self, *args: object) -> None: + pass + + class MockHeader: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + class MockFooter: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + class MockStatic: + def __init__(self, *args: object, **kwargs: object) -> None: + self._text = "" + + def update(self, text: str) -> None: + self._text = text + + class MockTextArea: + """Minimal TextArea stand-in for the Textual base class.""" + + text: str = "" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.text = "" + + mock_textual_app.App = MockApp # type: ignore[attr-defined] + mock_textual_containers.Vertical = MockVertical # type: ignore[attr-defined] + mock_textual_widgets.Header = MockHeader # type: ignore[attr-defined] + mock_textual_widgets.Footer = MockFooter # type: ignore[attr-defined] + mock_textual_widgets.Static = MockStatic # type: ignore[attr-defined] + mock_textual_widgets.TextArea = MockTextArea # type: ignore[attr-defined] + + return { + "textual": mock_textual, + "textual.app": mock_textual_app, + "textual.containers": mock_textual_containers, + "textual.widgets": mock_textual_widgets, + } + + +def _install_mock_textual_4741(context: object) -> None: + """Inject mock textual into sys.modules and reload the app module.""" + mocks = _build_mock_textual_4741() + context._tui4741_saved_modules: dict[str, ModuleType | None] = {} + for key in _MOCK_TEXTUAL_KEYS: + context._tui4741_saved_modules[key] = sys.modules.pop(key, None) + for key, mod in mocks.items(): + sys.modules[key] = mod + + # Reload widget modules so they pick up the mock Static/TextArea base class + import cleveragents.tui.widgets.help_panel_overlay as hp_mod + import cleveragents.tui.widgets.persona_bar as pb_mod + import cleveragents.tui.widgets.prompt as prompt_mod + import cleveragents.tui.widgets.reference_picker as rp_mod + import cleveragents.tui.widgets.slash_command_overlay as sco_mod + + importlib.reload(hp_mod) + importlib.reload(pb_mod) + importlib.reload(prompt_mod) + importlib.reload(rp_mod) + importlib.reload(sco_mod) + + import cleveragents.tui.app as app_mod + + importlib.reload(app_mod) + context._tui4741_app_mod = app_mod + context._tui4741_mock_static = mocks["textual.widgets"].Static + + +def _restore_modules_4741(context: object) -> None: + """Restore original sys.modules and reload the app module.""" + for key, val in getattr(context, "_tui4741_saved_modules", {}).items(): + if val is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = val + + import cleveragents.tui.widgets.help_panel_overlay as hp_mod + import cleveragents.tui.widgets.persona_bar as pb_mod + import cleveragents.tui.widgets.prompt as prompt_mod + import cleveragents.tui.widgets.reference_picker as rp_mod + import cleveragents.tui.widgets.slash_command_overlay as sco_mod + + importlib.reload(hp_mod) + importlib.reload(pb_mod) + importlib.reload(prompt_mod) + importlib.reload(rp_mod) + importlib.reload(sco_mod) + + import cleveragents.tui.app as app_mod + + importlib.reload(app_mod) + + +def _make_persona_state_4741(context: object) -> object: + """Create a real PersonaState backed by a temp directory.""" + from cleveragents.tui.persona.registry import PersonaRegistry + from cleveragents.tui.persona.state import PersonaState + + tmp = tempfile.mkdtemp() + context._tui4741_tmpdir = tmp + registry = PersonaRegistry(config_dir=Path(tmp)) + registry.ensure_default() + return PersonaState(registry=registry) + + +def _cleanup_tmpdir_4741(context: object) -> None: + tmp = getattr(context, "_tui4741_tmpdir", None) + if tmp: + shutil.rmtree(tmp, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("the TUI app module is imported with mocked Textual for issue 4741") +def step_import_with_mock_textual_4741(context: object) -> None: + """Install mock Textual, reload app module, register cleanup.""" + _install_mock_textual_4741(context) + context.add_cleanup(lambda: _restore_modules_4741(context)) + context.add_cleanup(lambda: _cleanup_tmpdir_4741(context)) + + +# --------------------------------------------------------------------------- +# App setup +# --------------------------------------------------------------------------- + + +class _FakeCommandRouter4741: + """Test command router that returns predictable responses.""" + + def handle(self, raw: str, *, session_id: str) -> str: + return f"handled:{raw}" + + +@given("a mock command router and persona state for issue 4741") +def step_create_mock_deps_4741(context: object) -> None: + context._tui4741_cmd_router = _FakeCommandRouter4741() + context._tui4741_persona_state = _make_persona_state_4741(context) + + +@when("I instantiate the TUI app for issue 4741") +def step_instantiate_app_4741(context: object) -> None: + AppClass = context._tui4741_app_mod._ResolvedTuiApp + context._tui4741_app = AppClass( + command_router=context._tui4741_cmd_router, + persona_state=context._tui4741_persona_state, + ) + + +@when("I call on_mount on the app for issue 4741") +def step_call_on_mount_4741(context: object) -> None: + context._tui4741_app.on_mount() + + +# --------------------------------------------------------------------------- +# Core regression steps: verify correct query extraction +# --------------------------------------------------------------------------- + + +@when( + 'I submit "{text}" to the app for issue 4741 and capture the suggestions query' +) +def step_submit_and_capture_query_4741(context: object, text: str) -> None: + """Submit text and capture the query argument passed to suggestions().""" + from cleveragents.tui.widgets.prompt import PromptInput + + captured: list[str] = [] + + def _fake_suggestions( + query: str, *, category: str | None = None, limit: int = 8 + ) -> list[str]: + captured.append(query) + return [] + + prompt = context._tui4741_app.query_one("#prompt", PromptInput) + prompt.text = text + event = SimpleNamespace() + + with patch( + "cleveragents.tui.app.suggestions", + side_effect=_fake_suggestions, + ): + context._tui4741_app.on_input_submitted(event) + + context._tui4741_captured_query = captured[0] if captured else None + + +@then('the suggestions query should be "{expected}" not "{wrong}"') +def step_assert_suggestions_query_4741( + context: object, expected: str, wrong: str +) -> None: + """Assert the captured query matches expected and not the wrong (buggy) value.""" + actual = context._tui4741_captured_query + assert actual is not None, ( + f"suggestions() was not called — no @token found in submitted text. " + f"Expected query: '{expected}'" + ) + assert actual == expected, ( + f"suggestions() received wrong query.\n" + f" Expected: '{expected}'\n" + f" Got: '{actual}'\n" + f" (The buggy value would have been: '{wrong}')" + ) diff --git a/features/tdd_tui_suggestions_query_extraction_4741.feature b/features/tdd_tui_suggestions_query_extraction_4741.feature new file mode 100644 index 000000000..bc45ee08a --- /dev/null +++ b/features/tdd_tui_suggestions_query_extraction_4741.feature @@ -0,0 +1,42 @@ +@tdd_issue @tdd_issue_4741 +Feature: TDD: suggestions() query extraction in on_input_submitted uses correct @token text + Regression test for issue #4741. + The on_input_submitted handler must extract only the @token text (without the @ + sign and without surrounding non-reference words) as the query passed to + suggestions(). Previously, text.replace("@", "").strip() was used, which + corrupted the query by including all non-reference words from the prompt. + + Background: + Given the TUI app module is imported with mocked Textual for issue 4741 + + @tdd_issue @tdd_issue_4741 + Scenario: Single @token in multi-word prompt uses only the token text as query + Given a mock command router and persona state for issue 4741 + When I instantiate the TUI app for issue 4741 + And I call on_mount on the app for issue 4741 + And I submit "analyse @proj" to the app for issue 4741 and capture the suggestions query + Then the suggestions query should be "proj" not "analyse proj" + + @tdd_issue @tdd_issue_4741 + Scenario: @token with category prefix passes only the suffix as query + Given a mock command router and persona state for issue 4741 + When I instantiate the TUI app for issue 4741 + And I call on_mount on the app for issue 4741 + And I submit "show @resource:src/main.py" to the app for issue 4741 and capture the suggestions query + Then the suggestions query should be "resource:src/main.py" not "show resource:src/main.py" + + @tdd_issue @tdd_issue_4741 + Scenario: Multiple @tokens in prompt uses the last token as query + Given a mock command router and persona state for issue 4741 + When I instantiate the TUI app for issue 4741 + And I call on_mount on the app for issue 4741 + And I submit "compare @plan1 with @plan2" to the app for issue 4741 and capture the suggestions query + Then the suggestions query should be "plan2" not "compare plan1 with plan2" + + @tdd_issue @tdd_issue_4741 + Scenario: Standalone @token at start of prompt uses only the token text as query + Given a mock command router and persona state for issue 4741 + When I instantiate the TUI app for issue 4741 + And I call on_mount on the app for issue 4741 + And I submit "@actor:local/dev" to the app for issue 4741 and capture the suggestions query + Then the suggestions query should be "actor:local/dev" not "actor:local/dev" diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index c4316b216..bf771d54b 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -4,6 +4,7 @@ from __future__ import annotations import importlib import os +import re from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, Protocol @@ -475,9 +476,10 @@ if _TEXTUAL_AVAILABLE: expanded = result.expanded_text if "@" in text: ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay) - ref_picker.set_suggestions( - text, suggestions(text.replace("@", "").strip()) - ) + at_tokens = re.findall(r"@(\S+)", text) + if at_tokens: + query = at_tokens[-1] + ref_picker.set_suggestions(query, suggestions(query)) if self._facade is None: # Facade not wired yet — preview only (graceful degradation) -- 2.52.0 From e5fb17bf88c5b7bce29fd98bf7e12c1ad2f066ed Mon Sep 17 00:00:00 2001 From: CleverThis Date: Wed, 29 Apr 2026 18:55:40 +0000 Subject: [PATCH 2/5] fix(tui): replace # type: ignore with setattr in TDD regression test steps --- ...suggestions_query_extraction_4741_steps.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py index fb8a88b6e..368eb9360 100644 --- a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py +++ b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py @@ -1,11 +1,10 @@ """Step definitions for tdd_tui_suggestions_query_extraction_4741.feature. Regression test for issue #4741: - on_input_submitted must extract only the @token text (without the @ sign and - without surrounding non-reference words) as the query passed to suggestions(). - - Previously, text.replace("@", "").strip() was used, which corrupted the query - by including all non-reference words from the prompt. + on_input_submitted must extract only the @token text (without the @ + sign and without surrounding non-reference words) as the query passed to + suggestions(). Previously, text.replace("@", "").strip() was used, which + corrupted the query by including all non-reference words from the prompt. """ from __future__ import annotations @@ -91,12 +90,12 @@ def _build_mock_textual_4741() -> dict[str, ModuleType]: def __init__(self, *args: object, **kwargs: object) -> None: self.text = "" - mock_textual_app.App = MockApp # type: ignore[attr-defined] - mock_textual_containers.Vertical = MockVertical # type: ignore[attr-defined] - mock_textual_widgets.Header = MockHeader # type: ignore[attr-defined] - mock_textual_widgets.Footer = MockFooter # type: ignore[attr-defined] - mock_textual_widgets.Static = MockStatic # type: ignore[attr-defined] - mock_textual_widgets.TextArea = MockTextArea # type: ignore[attr-defined] + setattr(mock_textual_app, "App", MockApp) + setattr(mock_textual_containers, "Vertical", MockVertical) + setattr(mock_textual_widgets, "Header", MockHeader) + setattr(mock_textual_widgets, "Footer", MockFooter) + setattr(mock_textual_widgets, "Static", MockStatic) + setattr(mock_textual_widgets, "TextArea", MockTextArea) return { "textual": mock_textual, -- 2.52.0 From 5656ff489297b795472ac5f9e34a8fa0ed5e5e2e Mon Sep 17 00:00:00 2001 From: CleverThis Date: Mon, 4 May 2026 20:14:32 +0000 Subject: [PATCH 3/5] fix(tui): fix ruff format violations and clarify standalone @token test scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply ruff format to tdd_tui_suggestions_query_extraction_4741_steps.py (two method signatures reformatted to fit within 88-char line limit) - Fix redundant wrong value in last TDD scenario: change not actor:local/dev → not @actor:local/dev to make the assertion error message meaningful and distinguish from the correct expected value --- .../tdd_tui_suggestions_query_extraction_4741_steps.py | 8 ++------ .../tdd_tui_suggestions_query_extraction_4741.feature | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py index 368eb9360..da21b648a 100644 --- a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py +++ b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py @@ -44,9 +44,7 @@ def _build_mock_textual_4741() -> dict[str, ModuleType]: def __init__(self, *args: object, **kwargs: object) -> None: self._widgets: dict[str, object] = {} - def query_one( - self, selector: str, widget_type: type | None = None - ) -> object: + def query_one(self, selector: str, widget_type: type | None = None) -> object: if selector in self._widgets: return self._widgets[selector] if widget_type is not None: @@ -227,9 +225,7 @@ def step_call_on_mount_4741(context: object) -> None: # --------------------------------------------------------------------------- -@when( - 'I submit "{text}" to the app for issue 4741 and capture the suggestions query' -) +@when('I submit "{text}" to the app for issue 4741 and capture the suggestions query') def step_submit_and_capture_query_4741(context: object, text: str) -> None: """Submit text and capture the query argument passed to suggestions().""" from cleveragents.tui.widgets.prompt import PromptInput diff --git a/features/tdd_tui_suggestions_query_extraction_4741.feature b/features/tdd_tui_suggestions_query_extraction_4741.feature index bc45ee08a..b1dd6d852 100644 --- a/features/tdd_tui_suggestions_query_extraction_4741.feature +++ b/features/tdd_tui_suggestions_query_extraction_4741.feature @@ -39,4 +39,4 @@ Feature: TDD: suggestions() query extraction in on_input_submitted uses correct When I instantiate the TUI app for issue 4741 And I call on_mount on the app for issue 4741 And I submit "@actor:local/dev" to the app for issue 4741 and capture the suggestions query - Then the suggestions query should be "actor:local/dev" not "actor:local/dev" + Then the suggestions query should be "actor:local/dev" not "@actor:local/dev" -- 2.52.0 From a73fc092f8a0bbfef0461aaca7d216d4b99043bd Mon Sep 17 00:00:00 2001 From: CleverThis Date: Tue, 5 May 2026 15:48:16 +0000 Subject: [PATCH 4/5] fix(tui): add no-@token edge case scenario to TDD regression suite Adds a 5th scenario to tdd_tui_suggestions_query_extraction_4741.feature that verifies suggestions() is NOT called when the prompt contains no @token. This covers the guard condition in on_input_submitted and completes the regression test suite for issue #4741. --- .../tdd_tui_suggestions_query_extraction_4741_steps.py | 10 ++++++++++ .../tdd_tui_suggestions_query_extraction_4741.feature | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py index da21b648a..29246ba71 100644 --- a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py +++ b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py @@ -267,3 +267,13 @@ def step_assert_suggestions_query_4741( f" Got: '{actual}'\n" f" (The buggy value would have been: '{wrong}')" ) + + +@then("suggestions was not called for issue 4741") +def step_assert_suggestions_not_called_4741(context: object) -> None: + """Assert that suggestions() was not called (no @token in prompt).""" + actual = context._tui4741_captured_query + assert actual is None, ( + f"suggestions() was unexpectedly called with query: '{actual}'. " + f"Expected it not to be called when no @token is present in the prompt." + ) diff --git a/features/tdd_tui_suggestions_query_extraction_4741.feature b/features/tdd_tui_suggestions_query_extraction_4741.feature index b1dd6d852..9d6c6893a 100644 --- a/features/tdd_tui_suggestions_query_extraction_4741.feature +++ b/features/tdd_tui_suggestions_query_extraction_4741.feature @@ -40,3 +40,11 @@ Feature: TDD: suggestions() query extraction in on_input_submitted uses correct And I call on_mount on the app for issue 4741 And I submit "@actor:local/dev" to the app for issue 4741 and capture the suggestions query Then the suggestions query should be "actor:local/dev" not "@actor:local/dev" + + @tdd_issue @tdd_issue_4741 + Scenario: Prompt with no @token does not call suggestions + Given a mock command router and persona state for issue 4741 + When I instantiate the TUI app for issue 4741 + And I call on_mount on the app for issue 4741 + And I submit "analyse the codebase" to the app for issue 4741 and capture the suggestions query + Then suggestions was not called for issue 4741 -- 2.52.0 From 1eca6b1da7b7f0f7e2987b5cb81c6b9484c01120 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Wed, 10 Jun 2026 05:38:37 -0400 Subject: [PATCH 5/5] fix(tui): set prompt.value not prompt.text in suggestions query extraction test consume_text() reads self.value (not self.text) on PromptInput. The step definition was setting prompt.text = text which left self.value empty, causing on_input_submitted to return early before the @-token extraction block was reached. ISSUES CLOSED: #4741 --- .../steps/tdd_tui_suggestions_query_extraction_4741_steps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py index 29246ba71..b23cf7705 100644 --- a/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py +++ b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py @@ -239,7 +239,7 @@ def step_submit_and_capture_query_4741(context: object, text: str) -> None: return [] prompt = context._tui4741_app.query_one("#prompt", PromptInput) - prompt.text = text + prompt.value = text event = SimpleNamespace() with patch( -- 2.52.0