diff --git a/CHANGELOG.md b/CHANGELOG.md
index a77559e73..f857d6eab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ Changed `wf10_batch.robot` to be less likely to create files, and
## [Unreleased]
+- **fix(tui): extract @token text correctly in on_input_submitted
+ suggestions query (PR #11004 / issue #4741)** — The TUI handler passed
+ ``text.replace("@", "").strip()`` to ``suggestions()``, producing garbage
+ fuzzy matches (e.g. "analyse \@proj" → "analyse proj"). Replaced with
+ ``re.findall(r"@(\S+)", text)`` that extracts only the last ``@token`` as
+ the query, returning just the token value without surrounding words.
+
- **SubplanExecutionService lazy wiring in CLI** (#10268): Wired `subplan_service`
from the DI container into `_get_plan_executor()` so `PlanExecutor` can spawn child
plans during the Execute phase. When `SubplanService` is available but
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index c33eb0f9b..f7217f12f 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -48,3 +48,4 @@ Below are some of the specific details of various contributions.
* HAL 9000 has contributed the DecisionService wiring for PlanExecutor strategize persistence fix (#10813): added decision_service to the PlanExecutor constructor and wired it from the CLI dependency-injection container in `_get_plan_executor()`, plus implemented `_persist_strategy_decisions()` to persist strategy decisions as domain `Decision` objects.
* HAL 9000 has contributed the A2A module rename standardization BDD tests (PR #10583 / issue #8615): comprehensive Behave test suite validating that all 22 A2A symbols are properly exported from `cleveragents.a2a`, no legacy ACP references remain in the module source, and documentation uses correct A2A naming conventions — fixing inline imports, unused behave symbols, cross-scenario context dependencies, and missing type annotations.
* HAL 9000 has contributed the `ActorSelectionOverlay._render` → `_refresh_display` rename fix (PR #11176 / issue #11039, Epic #8174): renamed `_render()` method to `_refresh_display()` to avoid shadowing Textual's `Widget._render()`, fixing a crash in textual >=1.0 where `get_content_height()` would receive `None` and raise `AttributeError: 'NoneType' object has no attribute 'get_height'`.
+* HAL 9000 has contributed the TUI @token text extraction fix for suggestions query (PR #11004 / issue #4741): replaced ``text.replace("@", "").strip()`` with ``re.findall(r"@(\S+)", text)[-1]`` in ``on_input_submitted`` to correctly extract only the last @token as the query, preventing garbage fuzzy matches. Includes TDD BDD regression test suite covering single-token, category-prefixed, multi-token, and standalone-token scenarios (PR #11004).
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..9a4473789
--- /dev/null
+++ b/features/steps/tdd_tui_suggestions_query_extraction_4741_steps.py
@@ -0,0 +1,252 @@
+"""Step definitions for TDD issue #4741: @token text extraction for suggestions().
+
+Verifies that ``on_input_submitted`` in ``src/cleveragents/tui/app.py`` extracts
+only the last ``@token`` from input using regex (``re.findall(r"@(\\S+)", text)``),
+instead of the buggy ``text.replace("@", "").strip()``.
+
+Uses a Scenario Outline so each / pair is tested identically.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import importlib
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+from types import ModuleType, SimpleNamespace
+from typing import Any
+from unittest.mock import MagicMock
+
+from behave import given, then
+
+
+# ---------------------------------------------------------------------------
+# Mock Textual
+# ---------------------------------------------------------------------------
+
+_MOCK_TEXTUAL_KEYS = [
+ "textual",
+ "textual.app",
+ "textual.containers",
+ "textual.widgets",
+]
+
+
+def _build_mock_textual() -> dict[str, ModuleType]:
+ mock_textual = ModuleType("textual")
+ mock_app_mod = ModuleType("textual.app")
+ mock_containers = ModuleType("textual.containers")
+ mock_widgets = ModuleType("textual.widgets")
+
+ class MockApp:
+ def __init__(self, *a, **kw):
+ pass
+
+ class MockVertical:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a, **kw):
+ pass
+
+ class MockHeader:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ class MockFooter:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ class MockStatic:
+ def __init__(self, *a, **kw):
+ self._text = ""
+
+ def update(self, text):
+ self._text = str(text)
+
+ class MockInput:
+ value: str = ""
+
+ def __init__(self, *a, **kw):
+ pass
+
+ def consume_text(self):
+ return SimpleNamespace(text=self.value)
+
+ mock_app_mod.App = MockApp
+ mock_containers.Vertical = MockVertical
+ mock_widgets.Header = MockHeader
+ mock_widgets.Footer = MockFooter
+ mock_widgets.Static = MockStatic
+ mock_widgets.Input = MockInput
+
+ return {
+ "textual": mock_textual,
+ "textual.app": mock_app_mod,
+ "textual.containers": mock_containers,
+ "textual.widgets": mock_widgets,
+ }
+
+
+def _restore_modules(context):
+ for key, val in getattr(context, "_tui_saved_modules", {}).items():
+ if val is None:
+ sys.modules.pop(key, None)
+ else:
+ sys.modules[key] = val
+
+ importlib.reload(
+ importlib.import_module("cleveragents.tui.widgets.help_panel_overlay")
+ )
+ importlib.reload(importlib.import_module("cleveragents.tui.widgets.persona_bar"))
+ importlib.reload(importlib.import_module("cleveragents.tui.widgets.prompt"))
+ importlib.reload(
+ importlib.import_module("cleveragents.tui.widgets.reference_picker")
+ )
+ importlib.reload(
+ importlib.import_module("cleveragents.tui.widgets.slash_command_overlay")
+ )
+ importlib.reload(importlib.import_module("cleveragents.tui.app"))
+
+
+# ---------------------------------------------------------------------------
+# Given: set up mock + run on_input_submitted with the captured input text
+# ---------------------------------------------------------------------------
+
+
+@given('the TUI on_input_submitted handler processes "{input_text}"')
+def step_run_on_input_submitted(context, input_text):
+ """Install mock Textual, create app, simulate submission, capture query.
+
+ We patch both ``ReferencePickerOverlay.set_suggestions`` and the module-level
+ ``suggestions()`` so we can inspect the exact argument passed to it.
+ """
+ # Remove saved modules from any previous scenario (Scenario Outline)
+ _restore_modules(context) if getattr(context, "_tui_saved_modules", None) else None
+
+ mocks = _build_mock_textual()
+
+ context._tui_saved_modules = {}
+ for key in _MOCK_TEXTUAL_KEYS:
+ context._tui_saved_modules[key] = sys.modules.pop(key, None)
+ for key, mod in mocks.items():
+ sys.modules[key] = mod
+
+ # Reload widget modules with mocked base classes
+ from cleveragents.tui.widgets import (
+ help_panel_overlay as hp_mod,
+ persona_bar as pb_mod,
+ prompt as prompt_mod,
+ reference_picker as rp_mod,
+ 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)
+
+ # Install mock persona state and command router before app module reloads
+ tmp_dir = tempfile.mkdtemp()
+ context._tui_tmpdir = Path(tmp_dir)
+
+ from cleveragents.tui.persona.registry import PersonaRegistry
+ from cleveragents.tui.persona.state import PersonaState
+
+ registry = PersonaRegistry(config_dir=Path(tmp_dir))
+ registry.ensure_default()
+ persona_state = PersonaState(registry=registry)
+
+ class MockCommandRouter:
+ def handle(self, raw, *, session_id):
+ return f"handled:{raw}"
+
+ mock_router = MockCommandRouter()
+
+ context._tui_persona_state = persona_state
+ context._tui_cmd_router = mock_router
+
+ app_mod = importlib.import_module("cleveragents.tui.app")
+ importlib.reload(app_mod)
+
+ # Patch the suggestions function BEFORE instantiating the app, so that when
+ # on_input_submitted calls it internally, our spy intercepts it.
+ captured_queries: list[str] = []
+
+ def _spy_suggestions(query, *, category=None, limit=8):
+ captured_queries.append(query)
+ return list(
+ app_mod.suggestions.__wrapped__.__code__.co_varnames[:limit]
+ ) # dummy
+
+ # The suggestions function is imported at module level in app.py. We need to
+ # patch it there, not on reference_parser itself.
+ def spy(query, **kw):
+ captured_queries.append(query)
+ return []
+
+ app_mod.suggestions = spy
+
+ cls = app_mod._ResolvedTuiApp
+
+ # Build mock widgets dict so query_one works
+ app_inst = cls(command_router=mock_router, persona_state=persona_state)
+
+ # Manually populate _widgets from the MockApp instance if it exists
+ if hasattr(app_inst, "_widgets"):
+ wdict: dict[str, Any] = app_inst._widgets
+ else:
+ wdict = {}
+
+ # Create mock widgets that behave correctly
+ mock_prompt = MagicMock()
+ mock_prompt.consume_text.return_value = SimpleNamespace(text=input_text)
+
+ mock_picker = MagicMock()
+ mock_picker.set_suggestions = lambda q, s: None # dummy, we spy suggestions instead
+ wdict["#reference-picker"] = mock_picker
+
+ mock_prompt_widget = MagicMock()
+ mock_prompt_widget.consume_text.return_value = SimpleNamespace(text=input_text)
+ wdict["#prompt"] = mock_prompt_widget
+
+ wdict["#conversation"] = MagicMock()
+ wdict["#help-panel"] = MagicMock()
+ wdict["#persona-bar"] = MagicMock()
+
+ # Patch query_one to use our widget dict
+ app_inst.query_one = lambda sel, *args, **kw: wdict.get(sel, MagicMock())
+
+ with contextlib.suppress(Exception):
+ app_inst.on_input_submitted(event=MagicMock())
+
+ context._tui_captured_query = captured_queries[0] if captured_queries else None
+
+ def _cleanup():
+ if hasattr(app_inst, "_widgets"):
+ app_inst._widgets.clear()
+
+ def _cleanup_tmpdir(d=context._tui_tmpdir):
+ if d is not None and d.exists():
+ shutil.rmtree(str(d), ignore_errors=True)
+
+ context.add_cleanup(_cleanup)
+ context.add_cleanup(lambda: _restore_modules(context))
+ context.add_cleanup(_cleanup_tmpdir)
+
+
+# ---------------------------------------------------------------------------
+# Then: assertion on the captured query
+# ---------------------------------------------------------------------------
+
+
+@then('the suggestion query should be "{expected}"')
+def step_suggestion_query_is_correct(context, expected):
+ assert context._tui_captured_query == expected, (
+ f"Bug #4741: extracted suggestion query was "
+ f"{context._tui_captured_query!r}, expected {expected!r}. "
+ f"The old buggy .replace(@).strip() approach produces garbage matches."
+ )
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..ac8d0f88a
--- /dev/null
+++ b/features/tdd_tui_suggestions_query_extraction_4741.feature
@@ -0,0 +1,23 @@
+@tdd_issue @tdd_issue_4741
+Feature: TDD Issue #4741 — Extract only the @token text as suggestions() query
+
+ Bug #4741: ``on_input_submitted`` passed ``text.replace("@", "").strip()`` to
+ ``suggestions()``, e.g. "analyse \@proj" → "analyse proj", producing garbage
+ fuzzy matches because surrounding words polluted the query.
+
+ The fix replaces it with ``re.findall(r"@(\S+)", text)`` taking the last
+ match as the query string, so "analyse \@proj" → "proj".
+
+Scenario Outline: @token extraction produces correct queries for input text
+ The suggestion query must be the last extracted @token from input text.
+ With the old buggy code these assertions fail; after the fix they pass.
+
+ Given the TUI on_input_submitted handler processes ""
+ Then the suggestion query should be ""
+
+ Examples:
+ | input | expected_query |
+ | analyse @proj | proj |
+ | read @project:main-doc | project:main-doc |
+ | look-up @actor:x @tool:y | tool:y |
+ | @skill:doc-generator | skill:doc-generator |
diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py
index 4734ff2cb..a287240f7 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
@@ -396,10 +397,13 @@ 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())
- )
+ matches = re.findall(r"@(\S+)", text)
+ if matches:
+ query_text = matches[-1]
+ ref_picker = self.query_one(
+ "#reference-picker", ReferencePickerOverlay
+ )
+ ref_picker.set_suggestions(text, suggestions(query_text))
if self._facade is None:
# Facade not wired yet — preview only (graceful degradation)