fix(tui): subclass Input to override _watch_value and eliminate layout=True per keystroke #11251

Merged
HAL9000 merged 6 commits from bugfix/m8-tui-input-live-refresh into master 2026-05-28 03:36:24 +00:00
4 changed files with 193 additions and 3 deletions
@@ -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
+3 -2
View File
@@ -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 {
+35 -1
View File
@@ -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)