fix(tui): subclass Input to override _watch_value and eliminate layout=True per keystroke #11251
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user