From 412c338f4e774b03619771d07132b07d4b5f987d Mon Sep 17 00:00:00 2001 From: "hamza.khyari" Date: Wed, 20 May 2026 13:35:09 +0000 Subject: [PATCH 1/6] fix(tui): subclass Input to override _watch_value and eliminate layout=True per keystroke Input._watch_value sets self.virtual_size (Reactive layout=True) on every keystroke, keeping Textual's WriterThread write queue permanently non-empty. The queue never reaches qsize()==0 so flush() is never called and typed characters are invisible until Enter drains the queue via conversation.update(). _PromptTextInput subclasses Input and overrides _watch_value to skip the virtual_size update while preserving Changed event, _suggestion reset and initial cursor positioning. The prompt is single-line and never scrolls horizontally so omitting virtual_size is safe. ISSUES CLOSED: #11249 --- src/cleveragents/tui/widgets/prompt.py | 63 +++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/cleveragents/tui/widgets/prompt.py b/src/cleveragents/tui/widgets/prompt.py index 065ce811d..ff7fa3d34 100644 --- a/src/cleveragents/tui/widgets/prompt.py +++ b/src/cleveragents/tui/widgets/prompt.py @@ -102,6 +102,67 @@ _StaticBase = cast(type[_StaticWidget], _load_static_base()) _HorizontalBase = cast(type[_HorizontalWidget], _load_horizontal_base()) _TEXTUAL_AVAILABLE = _InputBase.__module__.startswith("textual.") + +def _build_prompt_input_class() -> type[Any]: + """Return an Input subclass that overrides ``_watch_value`` to skip the + ``virtual_size`` layout recalculation. + + ``textual.widgets.Input._watch_value`` sets ``self.virtual_size`` on every + keystroke. ``virtual_size`` is defined as ``Reactive(layout=True)`` which + calls ``refresh(layout=True)`` whenever the value changes. This keeps + Textual's ``WriterThread`` write queue permanently non-empty during typing + (the queue never reaches ``qsize() == 0``, so ``flush()`` is never called + and typed characters are invisible until Enter triggers a large output + burst that drains the queue). + + The ``_PromptTextInput`` subclass preserves all other ``_watch_value`` + behaviour (``_suggestion`` reset, ``Input.Changed`` event, initial cursor + positioning) but skips ``virtual_size`` and the ``auto_dimensions`` layout + refresh. Since the prompt is single-line and never needs horizontal + scrolling, omitting ``virtual_size`` is safe. + """ + if not _TEXTUAL_AVAILABLE: + return _InputBase # fallback already lacks _watch_value + + class _PromptTextInput(_InputBase): # type: ignore[valid-type,misc] + """Input subclass with layout=True refresh eliminated from _watch_value.""" + + def _watch_value(self, value: str) -> None: # type: ignore[override] + """Override to skip virtual_size update (Reactive layout=True). + + Textual's default implementation sets ``self.virtual_size`` which + triggers ``refresh(layout=True)`` on every keystroke. For a + single-line prompt widget that never scrolls horizontally this is + unnecessary and prevents the WriterThread from flushing stdout, + causing characters to appear only after Enter is pressed. + """ + # Reset suggestion (same as super) + self._suggestion = "" # type: ignore[attr-defined] + # Respect auto_dimensions without triggering full layout + # (handled by repaint=True on the value reactive itself) + + # Post Changed event so mode detection and other listeners work + from textual.widgets._input import Input # type: ignore[import-untyped] + + validation_result = ( + self.validate(value) # type: ignore[attr-defined] + if "changed" in self.validate_on # type: ignore[attr-defined] + else None + ) + self.post_message( # type: ignore[attr-defined] + Input.Changed(self, value, validation_result) + ) + + # Set initial cursor to end of value (same as super) + if self._initial_value: # type: ignore[attr-defined] + self.cursor_position = len(self.value) # type: ignore[attr-defined] + self._initial_value = False # type: ignore[attr-defined] + + return _PromptTextInput + + +_PromptInputBase = _build_prompt_input_class() + if TYPE_CHECKING: # pragma: no cover - typing only _ComposeResult = Iterable[Any] else: @@ -184,7 +245,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) -- 2.52.0 From 6bda792d2bc750d4db6dc616ece43f60fb77a29b Mon Sep 17 00:00:00 2001 From: "hamza.khyari" Date: Wed, 20 May 2026 13:42:48 +0000 Subject: [PATCH 2/6] fix(tui): subclass Input to override _watch_value and eliminate layout=True per keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input._watch_value sets self.virtual_size (Reactive layout=True) on every keystroke, keeping Textual's WriterThread write queue permanently non-empty. The queue never reaches qsize()==0 so flush() is never called and typed characters are invisible until Enter drains the queue. Two fixes applied: 1. _PromptTextInput subclasses textual.widgets.Input and overrides virtual_size with Reactive(layout=False). Setting virtual_size in _watch_value no longer triggers refresh(layout=True). Zero type:ignore suppressions — uses proper Textual reactive types. 2. CSS #prompt and #prompt > Input changed from height:auto to fixed heights (3 and 1). This prevents the auto_dimensions guard in _watch_value from adding a second refresh(layout=True) per keystroke. 3 BDD regression scenarios added covering: virtual_size layout=False, _PromptInputBase usage in _TextualPromptInput, and fixed CSS height. ISSUES CLOSED: #11249 --- ...tdd_tui_prompt_input_live_refresh_steps.py | 125 ++++++++++++++++++ .../tdd_tui_prompt_input_live_refresh.feature | 30 +++++ src/cleveragents/tui/cleveragents.tcss | 5 +- src/cleveragents/tui/widgets/prompt.py | 81 ++++-------- 4 files changed, 185 insertions(+), 56 deletions(-) create mode 100644 features/steps/tdd_tui_prompt_input_live_refresh_steps.py create mode 100644 features/tdd_tui_prompt_input_live_refresh.feature 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 ff7fa3d34..ff1264fad 100644 --- a/src/cleveragents/tui/widgets/prompt.py +++ b/src/cleveragents/tui/widgets/prompt.py @@ -103,65 +103,38 @@ _HorizontalBase = cast(type[_HorizontalWidget], _load_horizontal_base()) _TEXTUAL_AVAILABLE = _InputBase.__module__.startswith("textual.") -def _build_prompt_input_class() -> type[Any]: - """Return an Input subclass that overrides ``_watch_value`` to skip the - ``virtual_size`` layout recalculation. +if _TEXTUAL_AVAILABLE: + from textual.geometry import Size as _TextualSize + from textual.reactive import reactive as _textual_reactive + from textual.widgets import Input as _TextualInput - ``textual.widgets.Input._watch_value`` sets ``self.virtual_size`` on every - keystroke. ``virtual_size`` is defined as ``Reactive(layout=True)`` which - calls ``refresh(layout=True)`` whenever the value changes. This keeps - Textual's ``WriterThread`` write queue permanently non-empty during typing - (the queue never reaches ``qsize() == 0``, so ``flush()`` is never called - and typed characters are invisible until Enter triggers a large output - burst that drains the queue). + class _PromptTextInput(_TextualInput): + """``Input`` subclass that prevents ``layout=True`` refreshes per keystroke. - The ``_PromptTextInput`` subclass preserves all other ``_watch_value`` - behaviour (``_suggestion`` reset, ``Input.Changed`` event, initial cursor - positioning) but skips ``virtual_size`` and the ``auto_dimensions`` layout - refresh. Since the prompt is single-line and never needs horizontal - scrolling, omitting ``virtual_size`` is safe. - """ - if not _TEXTUAL_AVAILABLE: - return _InputBase # fallback already lacks _watch_value + ``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. - class _PromptTextInput(_InputBase): # type: ignore[valid-type,misc] - """Input subclass with layout=True refresh eliminated from _watch_value.""" + 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. + """ - def _watch_value(self, value: str) -> None: # type: ignore[override] - """Override to skip virtual_size update (Reactive layout=True). + virtual_size: _textual_reactive[_TextualSize] = _textual_reactive( + _TextualSize(0, 0), + repaint=False, + layout=False, + ) - Textual's default implementation sets ``self.virtual_size`` which - triggers ``refresh(layout=True)`` on every keystroke. For a - single-line prompt widget that never scrolls horizontally this is - unnecessary and prevents the WriterThread from flushing stdout, - causing characters to appear only after Enter is pressed. - """ - # Reset suggestion (same as super) - self._suggestion = "" # type: ignore[attr-defined] - # Respect auto_dimensions without triggering full layout - # (handled by repaint=True on the value reactive itself) - - # Post Changed event so mode detection and other listeners work - from textual.widgets._input import Input # type: ignore[import-untyped] - - validation_result = ( - self.validate(value) # type: ignore[attr-defined] - if "changed" in self.validate_on # type: ignore[attr-defined] - else None - ) - self.post_message( # type: ignore[attr-defined] - Input.Changed(self, value, validation_result) - ) - - # Set initial cursor to end of value (same as super) - if self._initial_value: # type: ignore[attr-defined] - self.cursor_position = len(self.value) # type: ignore[attr-defined] - self._initial_value = False # type: ignore[attr-defined] - - return _PromptTextInput - - -_PromptInputBase = _build_prompt_input_class() + _PromptInputBase: type[Any] = _PromptTextInput +else: + _PromptInputBase = _InputBase if TYPE_CHECKING: # pragma: no cover - typing only _ComposeResult = Iterable[Any] -- 2.52.0 From 19c81ac388cf0ac1dbd751d766c2e5d5b6a94418 Mon Sep 17 00:00:00 2001 From: controller-ci-rerun Date: Wed, 27 May 2026 09:32:24 -0400 Subject: [PATCH 3/6] chore: re-trigger CI [controller] -- 2.52.0 From 16baa60cb2db97be84294d55856a678f2536231c Mon Sep 17 00:00:00 2001 From: CleverThis Date: Wed, 27 May 2026 19:53:15 -0400 Subject: [PATCH 4/6] fix(typecheck): install tui optional dep in typecheck nox session The typecheck session was installing only the base package (-e .) without the tui optional dependency, so pyright could not resolve the textual.geometry, textual.reactive, and textual.widgets imports that prompt.py conditionally loads when _TEXTUAL_AVAILABLE is True. Changing to -e .[tui] ensures textual is present in the typecheck venv so pyright reports no reportMissingImports errors. --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 7f0f82934..13d344535 100644 --- a/noxfile.py +++ b/noxfile.py @@ -162,7 +162,7 @@ def format(session: nox.Session): def typecheck(session: nox.Session): """Check types with pyright.""" session.install("pyright") - session.install("-e", ".") + session.install("-e", ".[tui]") session.run("pyright", stderr=sys.stdout) -- 2.52.0 From 2537c5704f6f182e139f381e071b0ddb3ee99e1b Mon Sep 17 00:00:00 2001 From: Drew Morris Date: Wed, 27 May 2026 20:21:34 -0400 Subject: [PATCH 5/6] Revert "fix(typecheck): install tui optional dep in typecheck nox session" This reverts commit fc66708231f300a7993d7f401c66960afdde3d6b. --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 13d344535..7f0f82934 100644 --- a/noxfile.py +++ b/noxfile.py @@ -162,7 +162,7 @@ def format(session: nox.Session): def typecheck(session: nox.Session): """Check types with pyright.""" session.install("pyright") - session.install("-e", ".[tui]") + session.install("-e", ".") session.run("pyright", stderr=sys.stdout) -- 2.52.0 From 153502feca72ec3c4907b742f4b1eaa01fc7e37b Mon Sep 17 00:00:00 2001 From: CleverThis Date: Wed, 27 May 2026 22:44:09 -0400 Subject: [PATCH 6/6] fix(tui): suppress textual import errors for optional dependency in typecheck Pyright evaluates `if _TEXTUAL_AVAILABLE:` blocks statically and raises reportMissingImports for textual.geometry, textual.reactive, and textual.widgets. Add `# type: ignore[import]` to the three conditional imports so typecheck passes when textual is not installed in the check environment. --- src/cleveragents/tui/widgets/prompt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cleveragents/tui/widgets/prompt.py b/src/cleveragents/tui/widgets/prompt.py index ff1264fad..3f31272b3 100644 --- a/src/cleveragents/tui/widgets/prompt.py +++ b/src/cleveragents/tui/widgets/prompt.py @@ -104,9 +104,9 @@ _TEXTUAL_AVAILABLE = _InputBase.__module__.startswith("textual.") if _TEXTUAL_AVAILABLE: - from textual.geometry import Size as _TextualSize - from textual.reactive import reactive as _textual_reactive - from textual.widgets import Input as _TextualInput + 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. -- 2.52.0