diff --git a/features/steps/tdd_tui_prompt_input_live_refresh_steps.py b/features/steps/tdd_tui_prompt_input_live_refresh_steps.py new file mode 100644 index 000000000..9bf7d3915 --- /dev/null +++ b/features/steps/tdd_tui_prompt_input_live_refresh_steps.py @@ -0,0 +1,125 @@ +"""Step definitions for tdd_tui_prompt_input_live_refresh.feature. + +Regression guard for issue #11249: _PromptTextInput must override +virtual_size with layout=False to prevent full layout recalculations +on every keystroke, which blocked Textual's WriterThread from flushing +stdout and made typed characters invisible until Enter was pressed. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from behave import given, then + + +@given("the _PromptTextInput class is available") +def step_prompt_text_input_available(context: Any) -> None: + """Import _PromptTextInput from the prompt module.""" + from cleveragents.tui.widgets.prompt import _PromptInputBase + + context._prompt_input_cls = _PromptInputBase + + +@then("its virtual_size reactive should have layout=False") +def step_virtual_size_layout_false(context: Any) -> None: + """Assert that _PromptTextInput.virtual_size has layout=False. + + Bug #11249: if layout=True, every keystroke triggers a full layout + recalculation that keeps the WriterThread queue non-empty and prevents + stdout from flushing, making typed characters invisible until Enter. + """ + cls = context._prompt_input_cls + + if not hasattr(cls, "virtual_size"): + # Fallback class (Textual not available) — skip, no reactive to check + return + + vs_reactive = cls.__dict__.get("virtual_size") + assert vs_reactive is not None, ( + "Bug #11249 regression: _PromptTextInput must define its own " + "virtual_size reactive (overriding Widget.virtual_size)." + ) + assert not getattr(vs_reactive, "_layout", True), ( + "Bug #11249 regression: _PromptTextInput.virtual_size must have " + "layout=False to prevent refresh(layout=True) on every keystroke. " + "Characters will be invisible while typing if layout=True." + ) + + +@given("the _TextualPromptInput class is available") +def step_textual_prompt_input_available(context: Any) -> None: + """Import _TextualPromptInput (the Horizontal wrapper) from the prompt module.""" + from cleveragents.tui.widgets import prompt as prompt_mod + + context._textual_prompt_cls = getattr(prompt_mod, "_TextualPromptInput", None) + context._prompt_input_base = prompt_mod._PromptInputBase + + +@then("its inner _input should be a _PromptTextInput instance") +def step_inner_input_is_prompt_text_input(context: Any) -> None: + """Assert the inner Input widget is our subclass, not raw textual.Input. + + Verifies that _TextualPromptInput uses _PromptInputBase (the subclass + with layout=False virtual_size) rather than the raw _InputBase. + """ + cls = context._textual_prompt_cls + if cls is None: + return # Textual not available — skip + + # Instantiate and check the _input type + instance = cls.__new__(cls) + # Access _input type via the class __init__ source check + # (avoid actually instantiating which requires a running app) + import inspect + + src = inspect.getsource(cls.__init__) + assert "_PromptInputBase" in src, ( + "Bug #11249 regression: _TextualPromptInput.__init__ must create " + "_input using _PromptInputBase (not _InputBase) so the virtual_size " + "layout=False override is active." + ) + del instance # avoid unused warning + + +@given("the TUI CSS file is loaded") +def step_css_file_loaded(context: Any) -> None: + """Load the TUI CSS file content.""" + css_path = ( + Path(__file__).resolve().parents[2] + / "src" + / "cleveragents" + / "tui" + / "cleveragents.tcss" + ) + context._css_content = css_path.read_text(encoding="utf-8") + + +@then("the prompt Input rule should use a fixed height not auto") +def step_prompt_input_fixed_height(context: Any) -> None: + """Assert the #prompt > Input CSS rule uses a fixed height, not height:auto. + + Bug #11249: with ``height: auto``, ``Input.styles.auto_dimensions`` + returns True, causing ``_watch_value`` to call ``refresh(layout=True)`` + on every keystroke — a second source of layout recalculations that + adds to the WriterThread queue pressure. + """ + css = context._css_content + + # Find the #prompt > Input block + import re + + match = re.search(r"#prompt\s*>\s*Input\s*\{([^}]+)\}", css, re.DOTALL) + assert match, "CSS must contain a '#prompt > Input' rule block." + + block = match.group(1) + assert "height: auto" not in block, ( + "Bug #11249 regression: '#prompt > Input' must not use 'height: auto'. " + "Auto height sets auto_dimensions=True which triggers refresh(layout=True) " + "on every keystroke. Use a fixed height (e.g. 'height: 1')." + ) + assert re.search(r"height:\s*\d", block), ( + "Bug #11249 regression: '#prompt > Input' must have a fixed numeric " + "height (e.g. 'height: 1') to prevent auto_dimensions layout refreshes." + ) diff --git a/features/tdd_tui_prompt_input_live_refresh.feature b/features/tdd_tui_prompt_input_live_refresh.feature new file mode 100644 index 000000000..bc59ec007 --- /dev/null +++ b/features/tdd_tui_prompt_input_live_refresh.feature @@ -0,0 +1,30 @@ +@tdd_issue @tdd_issue_11249 +Feature: TDD Issue #11249 — TUI prompt input characters not visible until Enter + + Textual's ``Input._watch_value`` sets ``self.virtual_size`` on every + keystroke. ``virtual_size`` is defined as ``Reactive(layout=True)`` on + ``Widget``, so each change calls ``refresh(layout=True)`` which enqueues + ANSI escape sequences in Textual's ``WriterThread``. The thread only + flushes stdout when its queue is empty; with continuous layout writes the + queue never drains between keystrokes, so typed characters are invisible + until Enter produces a burst that drains the queue. + + The fix overrides ``virtual_size`` on ``_PromptTextInput`` with + ``Reactive(layout=False)`` so changes to it no longer trigger full + layout recalculations. The CSS also fixes ``height: auto`` → ``height: 1`` + on the Input to remove the ``auto_dimensions`` guard that adds a second + ``refresh(layout=True)`` per keystroke. + + See CONTRIBUTING.md > Bug Fix Workflow > TDD Issue Test Tags. + + Scenario: _PromptTextInput.virtual_size reactive has layout=False + Given the _PromptTextInput class is available + Then its virtual_size reactive should have layout=False + + Scenario: _PromptTextInput is used as the inner input in _TextualPromptInput + Given the _TextualPromptInput class is available + Then its inner _input should be a _PromptTextInput instance + + Scenario: CSS prompt Input height is fixed not auto + Given the TUI CSS file is loaded + Then the prompt Input rule should use a fixed height not auto diff --git a/src/cleveragents/tui/cleveragents.tcss b/src/cleveragents/tui/cleveragents.tcss index 1e60f61ff..0d0344357 100644 --- a/src/cleveragents/tui/cleveragents.tcss +++ b/src/cleveragents/tui/cleveragents.tcss @@ -40,7 +40,7 @@ Screen { #prompt { layout: horizontal; - height: auto; + height: 3; border: round $primary; margin: 1 0 0 0; align-horizontal: left; @@ -48,6 +48,7 @@ Screen { #prompt > .prompt-symbol { padding: 0 1; + height: 1; content-align: center middle; color: $text-primary; } @@ -55,7 +56,7 @@ Screen { #prompt > Input { border: none; width: 1fr; - height: auto; + height: 1; } #persona-bar { diff --git a/src/cleveragents/tui/widgets/prompt.py b/src/cleveragents/tui/widgets/prompt.py index 065ce811d..3f31272b3 100644 --- a/src/cleveragents/tui/widgets/prompt.py +++ b/src/cleveragents/tui/widgets/prompt.py @@ -102,6 +102,40 @@ _StaticBase = cast(type[_StaticWidget], _load_static_base()) _HorizontalBase = cast(type[_HorizontalWidget], _load_horizontal_base()) _TEXTUAL_AVAILABLE = _InputBase.__module__.startswith("textual.") + +if _TEXTUAL_AVAILABLE: + from textual.geometry import Size as _TextualSize # type: ignore[import] + from textual.reactive import reactive as _textual_reactive # type: ignore[import] + from textual.widgets import Input as _TextualInput # type: ignore[import] + + class _PromptTextInput(_TextualInput): + """``Input`` subclass that prevents ``layout=True`` refreshes per keystroke. + + ``textual.widgets.Input._watch_value`` sets ``self.virtual_size`` on + every keystroke. ``virtual_size`` is defined as + ``Reactive(layout=True)`` on ``Widget``, so each change calls + ``refresh(layout=True)`` which enqueues ANSI escape sequences in + Textual's ``WriterThread``. The thread only flushes stdout when its + queue is empty (``qsize() == 0``); with continuous layout writes the + queue never drains between keystrokes, so characters are invisible + until Enter produces a large output burst that finally drains it. + + Overriding ``virtual_size`` with ``layout=False`` neutralises this: + the attribute can still be set by ``_watch_value`` without triggering + a full layout recalculation. The prompt is single-line and never + needs horizontal scrolling, so this is safe. + """ + + virtual_size: _textual_reactive[_TextualSize] = _textual_reactive( + _TextualSize(0, 0), + repaint=False, + layout=False, + ) + + _PromptInputBase: type[Any] = _PromptTextInput +else: + _PromptInputBase = _InputBase + if TYPE_CHECKING: # pragma: no cover - typing only _ComposeResult = Iterable[Any] else: @@ -184,7 +218,7 @@ class _TextualPromptInput(_PromptSymbolMixin, _HorizontalBase): self._symbol_widget = _StaticBase("", classes="prompt-symbol") input_id = f"{id}--input" if id else None self._input = cast( - _MutableValueInput, _InputBase(placeholder=placeholder, id=input_id) + _MutableValueInput, _PromptInputBase(placeholder=placeholder, id=input_id) ) self._current_symbol = _PROMPT_SYMBOLS[InputMode.NORMAL] self._update_symbol(self._input.value)