fix(code): hide chat input action buttons in same frame as empty draft (#4178)

The floating `[ X ]`/`[ COPY ]` buttons on the chat input border only
toggled on the deferred `TextArea.Changed` event. History navigation and
clear set the draft text programmatically and suppress that event, so
when a draft emptied (notably tabbing through input history), the
buttons lingered for an extra frame. This syncs button visibility
synchronously when text is set programmatically so they hide/show in
lockstep with the draft.

Made by [Open
SWE](https://openswe.vercel.app/agents/89f7c717-fc07-c517-89f1-df142c799d2f)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-23 20:14:52 -04:00
committed by GitHub
parent 8ca0a185a1
commit f94d417d5a
2 changed files with 191 additions and 1 deletions
@@ -15,7 +15,7 @@ from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.content import Content
from textual.css.query import NoMatches
from textual.geometry import Offset
from textual.geometry import Offset, Size
from textual.message import Message
from textual.reactive import reactive
from textual.strip import Strip
@@ -766,6 +766,66 @@ class ChatTextArea(TextArea):
# refresh so it stays in view.
self.call_after_refresh(self.scroll_cursor_visible)
def _refresh_scrollbars(self) -> None:
"""Refresh scrollbars without flashing a transient vertical bar.
`TextArea` grows its `virtual_size` height the moment a row is inserted,
a frame before this `height: auto` widget's container reflows to match.
The base `_refresh_scrollbars` decides vertical visibility by comparing
`virtual_size.height` against the stale `self._container_size.height`,
so for that one frame the freshly inserted row looks like overflow and
the scrollbar flashes on, then off once the container catches up.
The widget only ever truly overflows once its content exceeds the height
it settles at — its resolved `max-height` (the layout chain above it is
all `height: auto`, so it always grows to `min(content, max-height)`).
Feed the base method that settled height instead of the mid-reflow one,
so the bar appears only on genuine overflow and never flashes. All other
base behavior (horizontal bar, anti-oscillation, scroll updates) is left
untouched.
Deliberately overrides Textual's private `_refresh_scrollbars` and
swaps the private `_container_size`; verified against Textual 8.2.7.
Re-verify on major Textual upgrades.
"""
bound = self._settled_content_height()
if bound is None:
super()._refresh_scrollbars()
return
original = self._container_size
# Never report a viewport smaller than the settled height; `max(...)`
# also guards the unlikely case where the real container is already
# larger than the bound, so we only ever raise the comparison height.
corrected_height = max(original.height, min(self.virtual_size.height, bound))
if corrected_height == original.height:
super()._refresh_scrollbars()
return
self._container_size = Size(original.width, corrected_height)
try:
super()._refresh_scrollbars()
finally:
self._container_size = original
def _settled_content_height(self) -> int | None:
"""Return the content-row height this widget settles at, if knowable.
Returns `None` (so the caller defers to the base behavior) unless the
vertical overflow is `auto` and `max-height` resolves to a fixed cell
count, the only case where the flash-suppression bound is well-defined.
"""
styles = self.styles
if styles.overflow_y != "auto" or not styles.has_rule("max_height"):
return None
max_height = styles.max_height
cells = max_height.cells if max_height is not None else None
if cells is None:
return None
# box-sizing is border-box by default, so subtract border/padding to get
# the content-row count the base method compares `virtual_size` against.
return max(1, cells - self.gutter.height)
def _cursor_at_visual_top(self) -> bool:
"""Return whether the cursor cannot move up further."""
try:
@@ -1232,6 +1292,10 @@ class ChatTextArea(TextArea):
self._reset_paste_burst_state()
self._skip_history_change_events += 1
self.text = text
# The suppressed Changed event (see above) is what would normally toggle
# the clear/copy buttons, so sync them now to hide/show in the same frame
# the text swaps — otherwise an emptied draft keeps the buttons for a frame.
self._sync_owner_action_buttons(text)
if cursor_at_end:
self.move_cursor_to_end()
else:
@@ -1251,8 +1315,22 @@ class ChatTextArea(TextArea):
self._skip_history_change_events += 1
self._reset_paste_burst_state()
self.text = ""
# Hide the clear/copy buttons in the same frame the draft empties; the
# suppressed Changed event would otherwise leave them for an extra frame.
self._sync_owner_action_buttons("")
self.move_cursor((0, 0))
def _sync_owner_action_buttons(self, text: str) -> None:
"""Match the owner's clear/copy buttons to programmatically set text.
History/clear text swaps suppress the `Changed` event that normally
drives button visibility, so the owner is updated directly to keep the
buttons in lockstep with the draft (matching the `Changed`-path gate).
"""
owner = self._chat_input_owner
if owner is not None:
owner._set_action_buttons_visible(visible=bool(text.strip()))
def discard_text(self) -> bool:
"""Clear the draft via an undoable edit (restorable with ctrl+z).
@@ -227,6 +227,93 @@ class _RecordingApp(App[None]):
self.submitted.append(event)
class TestChatInputScrollbar:
"""Regression tests for the chat input's vertical scrollbar behavior.
`ChatTextArea` is `height: auto; max-height: 8; overflow-y: auto`. The base
`TextArea` grows its `virtual_size` height the moment a row is inserted, a
frame before this auto-height widget's container reflows to match. Left to
the base `_refresh_scrollbars`, that one-frame mismatch makes a short draft
look like it overflows and flashes the vertical scrollbar on, then off. The
`ChatTextArea._refresh_scrollbars` override corrects the comparison height
so the bar appears only on genuine overflow.
"""
async def test_newline_into_short_draft_never_flashes_scrollbar(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A newline below `max-height` must never show the vertical scrollbar.
Records the scrollbar decision on every refresh triggered by the insert
(not just the settled state) so the one-frame flash is caught. Fails
against the unpatched base behavior, which shows the bar mid-reflow.
"""
app = _ChatInputTestApp()
async with app.run_test() as pilot:
chat = app.query_one(ChatInput)
text_area = chat.input_widget
assert text_area is not None
text_area.focus()
await pilot.pause()
decisions: list[bool] = []
original = ChatTextArea._refresh_scrollbars
def _record(self: ChatTextArea) -> None:
original(self)
decisions.append(self.show_vertical_scrollbar)
monkeypatch.setattr(ChatTextArea, "_refresh_scrollbars", _record)
await pilot.press("shift+enter")
for _ in range(4):
await pilot.pause()
assert text_area.text == "\n"
assert text_area.max_scroll_y == 0
assert decisions, "expected a scrollbar refresh during the insert"
assert not any(decisions), (
f"vertical scrollbar flashed during newline insert: {decisions}"
)
assert text_area.show_vertical_scrollbar is False
async def test_overflowing_draft_keeps_visible_scrollbar(self) -> None:
"""A draft taller than `max-height` keeps a real, scrollable bar."""
app = _ChatInputTestApp()
async with app.run_test() as pilot:
chat = app.query_one(ChatInput)
text_area = chat.input_widget
assert text_area is not None
text_area.focus()
await pilot.pause()
for _ in range(15):
await pilot.press("shift+enter")
for _ in range(3):
await pilot.pause()
assert text_area.max_scroll_y > 0
assert text_area.show_vertical_scrollbar is True
assert text_area.scrollbar_size_vertical > 0
# The cursor stays in view at the bottom of the overflowing draft.
rel_y = text_area.cursor_location[0] - text_area.scroll_offset.y
assert 0 <= rel_y < text_area.size.height
async def test_settled_content_height_resolves_max_height(self) -> None:
"""The flash-suppression bound resolves to `max-height` in content rows.
Guards the override silently disabling itself: if `max-height` stops
resolving to a fixed cell count, `_settled_content_height` returns
`None` and the flash returns.
"""
app = _ChatInputTestApp()
async with app.run_test() as pilot:
text_area = app.query_one(ChatInput).input_widget
assert text_area is not None
await pilot.pause()
# max-height: 8 with no border/padding -> 8 content rows.
assert text_area._settled_content_height() == 8
class TestChatTextAreaKeybindings:
"""Regression tests for terminal key aliases in the chat input."""
@@ -418,6 +505,31 @@ class TestInputActionButtons:
await pilot.pause()
assert actions.display is False
async def test_history_navigation_hides_buttons_in_same_frame(self) -> None:
"""Emptying the draft via history/clear hides the buttons synchronously."""
app = _ChatInputTestApp()
async with app.run_test() as pilot:
await pilot.pause()
chat_input = app.query_one(ChatInput)
text_area = chat_input.input_widget
assert text_area is not None
actions = chat_input.query_one("#input-actions")
# Recalling content shows the buttons in the same frame (no pause).
text_area.set_text_from_history("recalled", cursor_at_end=True)
assert actions.display is True
# Tabbing forward to an empty draft hides them in the same frame,
# before the suppressed Changed event would otherwise process.
text_area.set_text_from_history("", cursor_at_end=True)
assert actions.display is False
# clear_text empties the draft and hides them synchronously too.
text_area.set_text_from_history("recalled", cursor_at_end=True)
assert actions.display is True
text_area.clear_text()
assert actions.display is False
async def test_copy_button_double_click_does_not_select_label(self) -> None:
"""Double-clicking `[ COPY ]` should not trigger Textual word selection."""
app = _ChatInputTestApp()