diff --git a/frontends/tuiapp_v2.py b/frontends/tuiapp_v2.py index 5afd5ef2..977a5008 100644 --- a/frontends/tuiapp_v2.py +++ b/frontends/tuiapp_v2.py @@ -583,6 +583,16 @@ def _sidebar_last_user(sess: AgentSession) -> str: return "" +# Textual 8.2.6 renamed _select_start to _select_state (SelectState.start.container). +def _select_origin_widget(screen: Any) -> Any: + state = getattr(screen, "_select_state", None) + if state is not None: + start = getattr(state, "start", None) + return start.container if start is not None else None + legacy = getattr(screen, "_select_start", None) + return legacy[0] if legacy is not None else None + + def _sidebar_last_summary(sess: AgentSession) -> str: try: history = sess.agent.llmclient.backend.history @@ -870,13 +880,7 @@ def patched(select_widget, mouse_coord, delta_y): scroll_lines = app.SELECT_AUTO_SCROLL_LINES candidates = [select_widget] - # Textual 8.2.6 renamed _select_start to _select_state (SelectState.start.container). - select_state = getattr(screen, "_select_state", None) - if select_state is not None: - sw = select_state.start.container - else: - ss = getattr(screen, "_select_start", None) - sw = ss[0] if ss is not None else None + sw = _select_origin_widget(screen) if sw is not None and sw is not select_widget: candidates.append(sw) diff --git a/tests/test_tuiapp_v2_select_origin.py b/tests/test_tuiapp_v2_select_origin.py new file mode 100644 index 00000000..e69b7858 --- /dev/null +++ b/tests/test_tuiapp_v2_select_origin.py @@ -0,0 +1,169 @@ +import importlib +import sys +import types +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +FRONTENDS = REPO_ROOT / "frontends" +for path in (str(REPO_ROOT), str(FRONTENDS)): + if path not in sys.path: + sys.path.insert(0, path) + + +def _install_import_stubs(): + rich = types.ModuleType("rich") + rich_markdown = types.ModuleType("rich.markdown") + + class Markdown: + def __init__(self, markup, **kwargs): + self.parsed = [] + + rich_markdown.Markdown = Markdown + + rich_table = types.ModuleType("rich.table") + rich_table.Table = type("Table", (), {}) + + rich_text = types.ModuleType("rich.text") + rich_text.Text = type("Text", (), {}) + + textual = types.ModuleType("textual") + textual.events = types.ModuleType("textual.events") + + class _Base: + BINDINGS: list = [] + def __init__(self, *a, **kw): + pass + def __init_subclass__(cls, **kw): + super().__init_subclass__() + def __class_getitem__(cls, item): + return cls + + textual_app = types.ModuleType("textual.app") + textual_app.App = type("App", (_Base,), {}) + textual_app.ComposeResult = object + + textual_binding = types.ModuleType("textual.binding") + + class Binding: + def __init__(self, *a, **kw): + self.args = a + self.kwargs = kw + + textual_binding.Binding = Binding + + textual_containers = types.ModuleType("textual.containers") + textual_containers.Horizontal = type("Horizontal", (_Base,), {}) + textual_containers.Vertical = type("Vertical", (_Base,), {}) + textual_containers.VerticalScroll = type("VerticalScroll", (_Base,), {}) + + textual_message = types.ModuleType("textual.message") + textual_message.Message = type("Message", (_Base,), {}) + + textual_screen = types.ModuleType("textual.screen") + textual_screen.ModalScreen = type("ModalScreen", (_Base,), {}) + + textual_widgets = types.ModuleType("textual.widgets") + textual_widgets.OptionList = type("OptionList", (_Base,), {}) + textual_widgets.Static = type("Static", (_Base,), {}) + textual_widgets.TextArea = type("TextArea", (_Base,), {}) + + textual_widgets_option_list = types.ModuleType("textual.widgets.option_list") + textual_widgets_option_list.Option = type("Option", (_Base,), {}) + + chatapp_common = types.ModuleType("chatapp_common") + chatapp_common.format_restore = lambda: (([], "", 0), None) + + btw_cmd = types.ModuleType("btw_cmd") + btw_cmd.handle_frontend_command = lambda *a, **k: "" + + continue_cmd = types.ModuleType("continue_cmd") + continue_cmd.handle_frontend_command = lambda *a, **k: "" + continue_cmd.list_sessions = lambda *a, **k: [] + continue_cmd.extract_ui_messages = lambda *a, **k: [] + + export_cmd = types.ModuleType("export_cmd") + export_cmd.last_assistant_text = lambda *a, **k: "" + export_cmd.export_to_temp = lambda *a, **k: "" + export_cmd.wrap_for_clipboard = lambda *a, **k: "" + + agentmain = types.ModuleType("agentmain") + + class GeneraticAgent: + def __init__(self): + self.verbose = False + + agentmain.GeneraticAgent = GeneraticAgent + + sys.modules.update({ + "rich": rich, + "rich.markdown": rich_markdown, + "rich.table": rich_table, + "rich.text": rich_text, + "textual": textual, + "textual.events": textual.events, + "textual.app": textual_app, + "textual.binding": textual_binding, + "textual.containers": textual_containers, + "textual.message": textual_message, + "textual.screen": textual_screen, + "textual.widgets": textual_widgets, + "textual.widgets.option_list": textual_widgets_option_list, + "chatapp_common": chatapp_common, + "btw_cmd": btw_cmd, + "continue_cmd": continue_cmd, + "export_cmd": export_cmd, + "agentmain": agentmain, + }) + + +_install_import_stubs() +tuiapp_v2 = importlib.import_module("tuiapp_v2") + + +class _Widget: + def __init__(self, name): + self.name = name + + +class _SelectStart: + def __init__(self, container=None): + self.container = container + + +class _SelectState: + def __init__(self, start): + self.start = start + + +class SelectOriginWidgetTests(unittest.TestCase): + def test_textual_8x_returns_select_state_start_container(self): + container = _Widget("messages") + screen = types.SimpleNamespace(_select_state=_SelectState(_SelectStart(container=container))) + self.assertIs(tuiapp_v2._select_origin_widget(screen), container) + + def test_textual_8x_select_state_none_returns_none(self): + screen = types.SimpleNamespace(_select_state=None) + self.assertIsNone(tuiapp_v2._select_origin_widget(screen)) + + def test_textual_8x_start_none_returns_none(self): + screen = types.SimpleNamespace(_select_state=_SelectState(start=None)) + self.assertIsNone(tuiapp_v2._select_origin_widget(screen)) + + def test_legacy_textual_7x_returns_first_element_of_select_start_tuple(self): + widget = _Widget("legacy") + screen = types.SimpleNamespace(_select_start=(widget, (0, 0))) + self.assertIs(tuiapp_v2._select_origin_widget(screen), widget) + + def test_legacy_textual_7x_select_start_none_returns_none(self): + screen = types.SimpleNamespace(_select_start=None) + self.assertIsNone(tuiapp_v2._select_origin_widget(screen)) + + def test_neither_attribute_returns_none(self): + screen = types.SimpleNamespace() + self.assertIsNone(tuiapp_v2._select_origin_widget(screen)) + + +if __name__ == "__main__": + unittest.main()