From 438ac1f30c3cff9569ed82a233fce84a7822a8ea Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:11:14 -0500 Subject: [PATCH 01/17] StyledRows owns the receipt that its rows arrived A styled block's rows come from the render processes, which no message queue owes, so a pilot that settles on owed work presses before the rows exist: test_hover_during_drag's press landed on AboveThrobber because the response body was still zero rows tall. On Textual main the pilot's 20 ms sleeps hid it. StyledRows.rows_ready answers it once: the render worker is finished and the rows are not awaiting styling. Seven test waits restated that fact by reading _render_worker and _styled from outside; they now ask the widget. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/toad/widgets/styled_rows.py | 8 ++++++++ tests/native/test_native_tool_output.py | 5 ++--- tests/support/textual_steps.py | 3 +-- tests/textual/test_follow_stream.py | 3 +-- tests/textual/test_hover_during_drag.py | 2 +- tests/textual/test_tool_output_cases.py | 3 +-- tests/textual/test_window_top_edge.py | 7 +++---- 7 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/toad/widgets/styled_rows.py b/src/toad/widgets/styled_rows.py index aaf000736..9eb5be6c8 100644 --- a/src/toad/widgets/styled_rows.py +++ b/src/toad/widgets/styled_rows.py @@ -48,6 +48,14 @@ class StyledRows(RestylesOnTheme, LinkedRows, ToadNode, Widget): def styled_blocks(self) -> tuple[LineBlock, ...]: """The semantic rows the next render styles.""" + @property + def rows_ready(self) -> bool: + """Whether the drawn rows are the render worker's for the current blocks. + + The rows come from the render processes, which no message queue owes, so a caller that + needs them (a click aimed at text, a copy of what is drawn) waits on this receipt.""" + return self._render_worker.is_finished and not self._styled.unstyled(self) + def invalidate_lines(self) -> None: """Restyle for a new theme or link context; the drawn rows stand until then.""" self._request_render() diff --git a/tests/native/test_native_tool_output.py b/tests/native/test_native_tool_output.py index e69c2b2fb..9be86aba8 100644 --- a/tests/native/test_native_tool_output.py +++ b/tests/native/test_native_tool_output.py @@ -9,7 +9,6 @@ from textual.selection import SELECT_ALL from toad.widgets.tool_call import ToolCall from toad.widgets.tool_content import ToolContent -from support.styling import awaits_styling SOURCE = "# Native tool output\nvalue = 1\n" CALLS = ( @@ -56,7 +55,7 @@ def drawn(tool, text): read.scroll_visible(animate=False) await ui.until(lambda: bool(read.query(ToolContent)), why="the read output") code = read.query_one(ToolContent) - await ui.until(lambda: not awaits_styling(code._styled), why="prepared read rows") + await ui.until(lambda: code.rows_ready, why="prepared read rows") assert "value = 1" in code.get_selection(SELECT_ALL)[0] await ui.until(lambda: drawn(read, "value = 1"), why="the read output on screen") read.set_expanded(False) @@ -80,7 +79,7 @@ def drawn(tool, text): bash.scroll_visible(animate=False) await ui.until(lambda: bool(bash.query(ToolContent)), why="the bash output") text = bash.query_one(ToolContent) - await ui.until(lambda: not awaits_styling(text._styled), why="prepared ANSI rows") + await ui.until(lambda: text.rows_ready, why="prepared ANSI rows") assert "NATIVE_TOOL_TEXT" in text.get_selection(SELECT_ALL)[0] assert "\x1b" not in text.get_selection(SELECT_ALL)[0] await ui.until(lambda: drawn(bash, "NATIVE_TOOL_TEXT"), why="the bash output on screen") diff --git a/tests/support/textual_steps.py b/tests/support/textual_steps.py index 2f51bd2b1..80b1fabbf 100644 --- a/tests/support/textual_steps.py +++ b/tests/support/textual_steps.py @@ -7,7 +7,6 @@ from toad.widgets.side_bar import ThreadSidebar import asyncio -from support.styling import awaits_styling async def reveal_project_tree(ui): @@ -135,7 +134,7 @@ async def wait_for_tool_diff(ui, tool): content = tool.query_one(ToolContent) # Replaced blocks keep their old rows until the render of the new ones lands, so wait for it. - await ui.until(lambda: content._render_worker.is_finished and not awaits_styling(content._styled), + await ui.until(lambda: content.rows_ready, seconds=10, why="the styled tool rows") await ui.step() return content diff --git a/tests/textual/test_follow_stream.py b/tests/textual/test_follow_stream.py index 2c781f9e3..965bae631 100644 --- a/tests/textual/test_follow_stream.py +++ b/tests/textual/test_follow_stream.py @@ -1,7 +1,6 @@ """Textual-facing: the history window follows live output at the tail, releases while reading, resumes at the tail.""" from toad.widgets.agent_response import AgentResponse -from support.styling import awaits_styling async def test_window_follows_tail_releases_on_scroll_up_and_resumes(workspace): @@ -9,7 +8,7 @@ async def test_window_follows_tail_releases_on_scroll_up_and_resumes(workspace): conversation = ui.conversation response = await conversation.post(AgentResponse("\n\n".join(f"Paragraph {i}" for i in range(40)))) # A block's rows are its styled rows: scrolling needs them to exist. - await ui.until(lambda: not awaits_styling(response.body._styled), why="the response's first styled rows") + await ui.until(lambda: response.body.rows_ready, why="the response's first styled rows") window = conversation.window window.anchor(False) conversation.refresh_block_cursor(lambda cursor: cursor.show_moved_to(conversation.window)) diff --git a/tests/textual/test_hover_during_drag.py b/tests/textual/test_hover_during_drag.py index 57fbc8f07..88644f8cc 100644 --- a/tests/textual/test_hover_during_drag.py +++ b/tests/textual/test_hover_during_drag.py @@ -12,8 +12,8 @@ async def test_pointer_steps_inside_one_widget_cause_no_restyle(workspace): async with workspace.app(size=(120, 40)) as ui: app, pilot = ui.textual response = await ui.conversation.post(AgentResponse("Select this phrase " * 25)) - await ui.step() text = response.body + await ui.until(lambda: text.rows_ready, why="the response's styled rows") await pilot.mouse_down(text, offset=(1, 0)) screen = app.screen x, y = text.region.x + 3, text.region.y diff --git a/tests/textual/test_tool_output_cases.py b/tests/textual/test_tool_output_cases.py index 6b9a8b424..560186a50 100644 --- a/tests/textual/test_tool_output_cases.py +++ b/tests/textual/test_tool_output_cases.py @@ -9,7 +9,6 @@ from toad.tool_output import SpecificTextToolOutputPart, ToolOutputPart from toad.widgets.tool_call import ToolCall from toad.widgets.tool_content import ToolContent -from support.styling import awaits_styling class InspectionToolOutputPart(SpecificTextToolOutputPart): @@ -38,7 +37,7 @@ async def test_a_declared_part_is_adopted_and_painted_without_catalog_edits(work assert InspectionToolOutputPart in ToolOutputPart.members_with(SpecificTextToolOutputPart) assert type(tool.output.parts[0]) is InspectionToolOutputPart assert tool.query_one(ToolContent).get_clipboard_text() == "case owns: DECLARED_NEW_OUTPUT" - await ui.until(lambda: not awaits_styling(tool.query_one(ToolContent)._styled), why="prepared output") + await ui.until(lambda: tool.query_one(ToolContent).rows_ready, why="prepared output") await ui.until_shows("case owns: DECLARED_NEW_OUTPUT") diff --git a/tests/textual/test_window_top_edge.py b/tests/textual/test_window_top_edge.py index c98747b56..38200bc17 100644 --- a/tests/textual/test_window_top_edge.py +++ b/tests/textual/test_window_top_edge.py @@ -16,7 +16,6 @@ from support.workspace import Workspace from toad.widgets.conversation import Conversation from toad.widgets.presentation_window import OLDER_END, PresentationBudget -from support.styling import awaits_styling THOUGHTS = ("Preparing branch notice tests", "Parametrizing rename routing tests", @@ -292,7 +291,7 @@ async def test_returning_to_tab_keeps_the_reader_below_a_live_thought( await view.post(thought) later = AgentResponse("\n\n".join(f"RETURN_ROW_{i:03d}" for i in range(40))) await view.post(later) - await ui.until(lambda: not awaits_styling(thought._styled) and not awaits_styling(later.body._styled), + await ui.until(lambda: thought.rows_ready and later.body.rows_ready, why="both live blocks to have their actual styled rows") window = view.window window.anchor() @@ -346,7 +345,7 @@ async def test_a_live_thought_crossing_the_top_edge_keeps_its_rows_at_phone_widt thought = AgentThought(THINKING) await view.post(thought) await view.post(AgentResponse("\n\n".join(f"Later paragraph {i}" for i in range(20)))) - await ui.until(lambda: not awaits_styling(thought._styled), why="the thought's styled rows") + await ui.until(lambda: thought.rows_ready, why="the thought's styled rows") window = view.window window.anchor() await ui.until(lambda: window.max_scroll_y > 0 and window.follows_tail, why="a scrollable tail") @@ -362,7 +361,7 @@ async def test_a_live_thought_crossing_the_top_edge_keeps_its_rows_at_phone_widt for chunk in (" and", " then a few more", " words arrive", " in the last paragraph"): thought.append(chunk) await ui.step() - await ui.until(lambda: thought._render_worker.is_finished, + await ui.until(lambda: thought.rows_ready, why="the streamed text to be styled") for _ in range(4): await ui.step() From 7d50a10b7d21a0550959a422e902fef2d663b903 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:12:23 -0500 Subject: [PATCH 02/17] welcome_view waits for the opened thread's tab to be selected, not only mounted The tab label mounts before the workspace selects its source, so the test read the welcome's selected session. test_goal_owner_views already waits on the selection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/textual/test_welcome_view.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/textual/test_welcome_view.py b/tests/textual/test_welcome_view.py index 75f8e13db..4cb087d1b 100644 --- a/tests/textual/test_welcome_view.py +++ b/tests/textual/test_welcome_view.py @@ -96,9 +96,10 @@ async def test_opening_a_thread_run_elsewhere_opens_its_history_read_as_the_user entries = await welcome_entries(ui, "scribe") await focus_row(ui, entries.query_one(WelcomeThreads).rows["scribe"]) await ui.press("enter") - await ui.until(lambda: len(ui.tabs()) == 1, why="the thread's tab") app, _ = ui.textual - assert app.selected_session.thread_identity.thread_name() == "scribe" + await ui.until(lambda: app.selected_session.thread_identity.thread_name() == "scribe", + why="the thread's tab selected") + assert len(ui.tabs()) == 1 await ui.write_draft("hello scribe") await ui.press("enter") From f676a66e79f1f35ede70bf7bf730420b721dbde3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:13:56 -0500 Subject: [PATCH 03/17] channel_any_mode waits for the bus to record the toggle The menu's verb runs in Core, off the UI loop, so no settled queue owes it; the test's claim is the bus state, so it waits on that state in both directions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/textual/test_channel_any_mode.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/textual/test_channel_any_mode.py b/tests/textual/test_channel_any_mode.py index 0506ec596..2c642cf2a 100644 --- a/tests/textual/test_channel_any_mode.py +++ b/tests/textual/test_channel_any_mode.py @@ -39,8 +39,7 @@ async def test_channel_menu_toggles_member_activity_and_keeps_unpainted_traffic_ sidebar = await channel_roster(ui, "#team") assert ACTION in await open_menu(ui, sidebar, "#team") await ui.choose_menu_action(ACTION) - await ui.step() - assert comms.bus.require_channel("#team").any_mode + await ui.until(lambda: comms.bus.require_channel("#team").any_mode, why="member activity on") assert bodies(comms) == ["exact route", "outbound from member", "inbound to member"] assert comms.views.viewer_snapshot(Checkout(workspace.project)).channel_unread["#team"] == ExactUnread(count=3) @@ -53,8 +52,7 @@ async def test_channel_menu_toggles_member_activity_and_keeps_unpainted_traffic_ assert ACTION in await open_menu(ui, sidebar, "#team") await ui.choose_menu_action(ACTION) - await ui.step() - assert not comms.bus.require_channel("#team").any_mode + await ui.until(lambda: not comms.bus.require_channel("#team").any_mode, why="member activity off") assert bodies(comms) == ["exact route"] From f99b41d364dfc55cc30f15f22e4b3acef78f8deb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:15:49 -0500 Subject: [PATCH 04/17] ACP log controls are pressed when they accept a press Textual's Button refuses a press during the previous press's 0.2 s effect; the test pressed Wrap and Latest twice inside it once the pilot stopped sleeping. One press helper waits on Button.accepts_press (OpenHCSDev/textual#257) and replaces the hand-written -active wait. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/textual/test_acp_log_view.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/textual/test_acp_log_view.py b/tests/textual/test_acp_log_view.py index 810177b2a..dbc44cf1e 100644 --- a/tests/textual/test_acp_log_view.py +++ b/tests/textual/test_acp_log_view.py @@ -50,10 +50,17 @@ def view_button(view, name): return next(button for button in view.query(LogViewButton) if button.kind is LogView.member_named(name)) -async def reread(ui, view, button): +async def press(ui, button): + """Click a log control as a user does: a button refuses a press during the last one's effect.""" _app, pilot = ui.textual - assert await pilot.click(button) + widget = button if not isinstance(button, type) else ui.app.screen.query_one(button) + await ui.until(lambda: widget.accepts_press, why=f"{type(widget).__name__} to accept a press") + assert await pilot.click(widget) await ui.step() + + +async def reread(ui, view, button): + await press(ui, button) await asyncio.wait_for(view.wait_ready(), 10) await ui.step() @@ -67,14 +74,11 @@ async def test_log_views_wrap_keyboard_resize_and_latest_read_the_physical_log(w assert "Unsettled owner input; compaction not dispatched" in area.text assert "The usage limit has been reached" in area.text assert area.soft_wrap and area.read_only - assert await pilot.click(view_button(view, "events")) - await ui.step() + await press(ui, view_button(view, "events")) assert "session/update" in area.text and "response" in area.text - assert await pilot.click(view_button(view, "raw")) - await ui.step() + await press(ui, view_button(view, "raw")) assert area.text == log.read_bytes().decode("utf-8", errors="replace") - assert await pilot.click(LogWrapButton) - await ui.step() + await press(ui, LogWrapButton) assert not area.soft_wrap # Keyboard reaches the final column rather than clipping it. longest = max(range(len(area.document.lines)), key=lambda index: len(area.document.lines[index])) @@ -89,11 +93,9 @@ async def test_log_views_wrap_keyboard_resize_and_latest_read_the_physical_log(w await ui.resize(50, 24) for button in view.query(LogButton): assert button.region.right <= app.screen.size.width, (button, button.region) - assert await pilot.click(LogWrapButton) - await ui.step() + await press(ui, LogWrapButton) assert area.soft_wrap - assert await pilot.click(view_button(view, "errors")) - await ui.step() + await press(ui, view_button(view, "errors")) assert "Provider usage limit reached" in area.text assert area.region.height >= 5 with log.open("a") as stream: @@ -128,9 +130,6 @@ async def test_earlier_and_latest_page_through_a_log_larger_than_one_page_and_re assert "Unable to read log" in area.text assert view.page is None and view.query_one(LogEarlierButton).disabled log.write_bytes(original) - # Textual ignores another click during its active effect; wait for the control to accept it. - await ui.until(lambda: not view.query_one(LogLatestButton).has_class("-active"), - why="the Latest button to accept a click") await reread(ui, view, LogLatestButton) assert "The usage limit has been reached" in area.text assert ui.failure() is None From 8f85e0b02c3a13ff632de620a60492b79a9cd22e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:17:54 -0500 Subject: [PATCH 05/17] A taller status waits for its page to measure again Opening a status's details measures the page once the taller rows return from the render processes, which no queue owes; the test waits for the measurement it claims. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/textual/test_message_notifications.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/textual/test_message_notifications.py b/tests/textual/test_message_notifications.py index bf86854ba..55f887c00 100644 --- a/tests/textual/test_message_notifications.py +++ b/tests/textual/test_message_notifications.py @@ -225,8 +225,8 @@ async def test_a_status_of_the_same_height_repaints_its_rows_without_measuring_t history.show_details(page, newest, newest.shown.toggled()) await ui.until(lambda: newest.shown.details_open, why="the details opened") - await ui.step() - assert page._fragment_slots is not measured, "a taller status kept the old box" + # The taller rows come from the render processes; the page measures again when they land. + await ui.until(lambda: page._fragment_slots is not measured, why="a taller status measured again") async def test_a_reread_or_resubscribed_message_starts_from_its_known_answer(workspace): From 364d4cd4d3c39447c0ac3005f08b5b55f97c1804 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:20:34 -0500 Subject: [PATCH 06/17] A sender link is aimed at once the message's status row has lifted the page The delivery status arrives from Core after the message rows and lifts the bottom-anchored page two rows; the click aimed before it pressed the sender and released on the status's details link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/textual/test_existing_thread_link.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/textual/test_existing_thread_link.py b/tests/textual/test_existing_thread_link.py index 45e038d77..4f3dbecac 100644 --- a/tests/textual/test_existing_thread_link.py +++ b/tests/textual/test_existing_thread_link.py @@ -32,6 +32,9 @@ async def test_name_of_an_open_thread_selects_its_tab_and_creates_nothing(worksp await ui.select_tab(owner) await ui.open_channel("#team", actor="owner", owner=owner) await ui.until_shows(header.label) + # The message's delivery status arrives from Core after its rows and lifts the + # bottom-anchored page; aim at the sender once the status row is drawn. + await ui.until_shows("Sent · Observed") await ui.until(lambda: app.screen.get_style_at(*ui.link_cell(header, sender)).meta.get("@click") == OpenLineLink(sender), why="the sender drawn as a link") # The channel tab's unread count clears as its message is read; the tabs themselves must not change. From 17516c1970abb8f7d6d75d4c4d646dffde206c65 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:22:25 -0500 Subject: [PATCH 07/17] Tab strip tests wait for the underline's slide on Toad's own clock The slide is a Toad effect, not a Textual animation, so wait_for_scheduled_animations never waited for it; early in a 0.18 s slide from off-strip the highlight is not drawn. strip_settled waits on effect_phase(TAB_SLIDE).in_motion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/textual/test_tab_scrollbar_top.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/textual/test_tab_scrollbar_top.py b/tests/textual/test_tab_scrollbar_top.py index 545140f38..2e2d7bc8c 100644 --- a/tests/textual/test_tab_scrollbar_top.py +++ b/tests/textual/test_tab_scrollbar_top.py @@ -1,6 +1,6 @@ """Textual-facing: the tab strip's horizontal scrollbar is drawn above the labels and underline, with matching pointer geometry, as overflow comes and goes.""" -from toad.widgets.session_tabs import SessionLabel, SessionsTabs, SessionTabClose, Underline +from toad.widgets.session_tabs import TAB_SLIDE, SessionLabel, SessionsTabs, SessionTabClose, Underline HEIGHT = 35 @@ -13,9 +13,17 @@ async def open_peers(ui, count: int) -> list[str]: ui.app.session_tracker.name_session( ui.app.session_navigation.get(identity).identity, f"thread-{index}-long-title") peers.append(identity) + await strip_settled(ui) + return peers + + +async def strip_settled(ui) -> None: + """Wait for the strip's scroll animations and its underline's slide, which runs on Toad's own clock.""" await ui.step() await ui.pilot.wait_for_scheduled_animations() - return peers + underline = ui.app.screen.query_one(SessionsTabs).query_one(Underline) + await ui.until(lambda: not underline.effect_phase(TAB_SLIDE).in_motion, why="the underline's slide") + await ui.step() def underline_is_painted(ui, tabs, underline, x_from=None, x_to=None): @@ -142,8 +150,7 @@ async def test_overflow_appearing_on_a_mounted_strip_keeps_the_underline_painted title = (f"pr17-standby-liveness-owner-{index} working on active goal" if long_titles else str(index)) app.session_tracker.name_session(session.identity, title) - await ui.step() - await ui.pilot.wait_for_scheduled_animations() + await strip_settled(ui) assert tabs.show_horizontal_scrollbar == long_titles label = tabs.query_one(f"#{owner}", SessionLabel) underline = tabs.query_one(Underline) From 5172c1456e45a8844a030aa29dad342f206e806e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:22:25 -0500 Subject: [PATCH 08/17] Saved view test waits for its chat to mount and its read-only decision Enter opens the view's tab; its chat mounts after, and the composer is disabled by the same read-only decision that sets the status, so both are read after it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/textual/test_saved_view_read_only.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/textual/test_saved_view_read_only.py b/tests/textual/test_saved_view_read_only.py index 31746717c..0c0563f70 100644 --- a/tests/textual/test_saved_view_read_only.py +++ b/tests/textual/test_saved_view_read_only.py @@ -34,12 +34,14 @@ async def test_saved_view_shows_union_history_keeps_its_pin_and_refuses_to_send( sidebar = await channel_roster(ui, "#engineering") next(group for group in sidebar.query(ChannelGroup) if group.row.target_name == "#engineering").row.focus() await ui.press("enter") - chat = ui.textual[0].screen.query_one(CommsChatView) + screen = ui.textual[0].screen + await ui.until(lambda: screen.query(CommsChatView), why="the saved view's chat") + chat = screen.query_one(CommsChatView) await ui.until(lambda: [fragment.message.body for fragment in chat.message_history.fragments_in_range] == [m.body for m in rows], why="the saved view's history") - assert chat.prompt.prompt_text_area.disabled await ui.until(lambda: "Read-only" in chat.status, why="the read-only status") + assert chat.prompt.prompt_text_area.disabled await (await chat.submissions.submit(input_events.UserInputSubmitted("must not publish"))).wait() assert chat.prompt.text == "must not publish" assert comms.views.latest_sequence() == before From 53ff90d1e1e334cad8539d90868cd573ce755688 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:22:25 -0500 Subject: [PATCH 09/17] Clicks on a sent message wait for its delivery status Two tests aimed at a message's link before the status row arrived from Core and lifted the page, so the release toggled the status details instead. One helper, until_delivery_shown, owns that wait. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/support/ui.py | 5 +++++ tests/textual/test_block_menu_and_copy.py | 1 + tests/textual/test_existing_thread_link.py | 4 +--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/support/ui.py b/tests/support/ui.py index 4eaafd7ad..acfc04e37 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -254,6 +254,11 @@ async def until_shows(self, text: str, *, seconds: float = 10) -> None: lambda: self.shows(text), seconds=seconds, why=f"{text!r} on screen" ) + async def until_delivery_shown(self) -> None: + """A sent message's delivery status arrives from Core after its rows and lifts the + bottom-anchored page; a click aimed at the message waits for it.""" + await self.until_shows("Sent · Observed") + class TextualUi(AppAgents, Ui): """Toad drawn by Textual, driven headless through Textual's pilot; its agents attach to that app.""" diff --git a/tests/textual/test_block_menu_and_copy.py b/tests/textual/test_block_menu_and_copy.py index 66ee30225..f28265d9e 100644 --- a/tests/textual/test_block_menu_and_copy.py +++ b/tests/textual/test_block_menu_and_copy.py @@ -113,6 +113,7 @@ async def send_and_preview(tab, target): why="the original image reference delivered to the recipient", ) await ui.until_shows("clipboard-") + await ui.until_delivery_shown() expected = DirectProjectFileLink(path, original_root=workspace.project) await ui.until(lambda: ui.link_at("clipboard-") == OpenLineLink(expected), why="the complete quoted attachment rendered as a file link") diff --git a/tests/textual/test_existing_thread_link.py b/tests/textual/test_existing_thread_link.py index 4f3dbecac..b24893a73 100644 --- a/tests/textual/test_existing_thread_link.py +++ b/tests/textual/test_existing_thread_link.py @@ -32,9 +32,7 @@ async def test_name_of_an_open_thread_selects_its_tab_and_creates_nothing(worksp await ui.select_tab(owner) await ui.open_channel("#team", actor="owner", owner=owner) await ui.until_shows(header.label) - # The message's delivery status arrives from Core after its rows and lifts the - # bottom-anchored page; aim at the sender once the status row is drawn. - await ui.until_shows("Sent · Observed") + await ui.until_delivery_shown() await ui.until(lambda: app.screen.get_style_at(*ui.link_cell(header, sender)).meta.get("@click") == OpenLineLink(sender), why="the sender drawn as a link") # The channel tab's unread count clears as its message is read; the tabs themselves must not change. From e15cf1e2afa6c51075368068f1fc92ea08f3ef29 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:24:45 -0500 Subject: [PATCH 10/17] One owner for 'drawn where it will be clicked' A click lands on what the published frame shows; the palette's input was clicked before its pushed screen had a frame. TextualUi.clickable owns the check that one test wrote out by hand; textual_steps.command_dialog, a third copy, had no caller and goes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/support/textual_steps.py | 21 ------------------- tests/support/ui.py | 20 ++++++++++++++++++ ..._application_actions_in_the_running_app.py | 10 ++++----- 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/tests/support/textual_steps.py b/tests/support/textual_steps.py index 80b1fabbf..07eb82343 100644 --- a/tests/support/textual_steps.py +++ b/tests/support/textual_steps.py @@ -53,27 +53,6 @@ async def reveal_session_details(ui, target=None): return details -async def command_dialog(ui, *, seconds: float = 20): - """Wait for the command dialog to be mounted, focused on its name field and clickable.""" - from textual.widgets import Input - from toad.widgets.comms_command_dialog import CommandDialog - - def usable(): - dialog = ui.app.screen - if not (isinstance(dialog, CommandDialog) and dialog.is_mounted and dialog.is_attached): - return False - field = dialog.form.values.get("name") - entry = field.editor if field is not None else None - return (isinstance(entry, Input) and entry.is_mounted and entry.is_attached - and dialog.focused is entry and ui.app.focused is entry - and entry.region.width > 0 and entry.region.height > 0 - and entry.region.offset in dialog.size.region - and dialog.get_widget_at(*entry.region.offset)[0] is entry) - - await ui.until(usable, seconds=seconds, why="the command dialog") - return ui.app.screen - - async def refresh_chat(view) -> None: """Run the chat view's history read now and wait for it; a view that is not shown reads nothing.""" history = view.message_history diff --git a/tests/support/ui.py b/tests/support/ui.py index acfc04e37..3a1b17fba 100644 --- a/tests/support/ui.py +++ b/tests/support/ui.py @@ -20,6 +20,8 @@ from typing import TYPE_CHECKING from textual.actions import StyleAction +from textual.errors import NoWidget +from textual.geometry import Offset from rich.cells import cell_len from support.agents import AppAgents, command @@ -276,6 +278,24 @@ def textual(self) -> tuple[ToadApp, Pilot]: def visible_lines(self) -> list[str]: return [strip.text for strip in self.app.screen._compositor.render_strips()] + def clickable(self, widget, offset: tuple[int, int] = (0, 0)) -> bool: + """Whether a click at `offset` in `widget` reaches it: a click lands on what the + published frame shows there, which a newly pushed or moving widget may not be yet.""" + if not widget.is_attached: + return False + region, screen = widget.region, widget.screen + point = Offset(region.x + offset[0], region.y + offset[1]) + if point not in region or point not in screen.size.region: + return False + try: + return screen.get_widget_at(*point)[0] is widget + except NoWidget: + # A screen pushed since the last frame has none of its widgets placed yet. + return False + + async def until_clickable(self, widget, offset: tuple[int, int] = (0, 0), *, why: str) -> None: + await self.until(lambda: self.clickable(widget, offset), why=why) + async def menu(self, why: str) -> dict: """The open context menu's actions and their labels, once it shows.""" from toad.widgets.comms_menu import ContextMenu diff --git a/tests/textual/test_application_actions_in_the_running_app.py b/tests/textual/test_application_actions_in_the_running_app.py index 3eac95364..5726939ab 100644 --- a/tests/textual/test_application_actions_in_the_running_app.py +++ b/tests/textual/test_application_actions_in_the_running_app.py @@ -61,7 +61,9 @@ async def open_column_width_editor(ui, app): async def palette(ui, app, query, completed): await ui.press('ctrl+p') await ui.until(lambda: isinstance(app.screen, CommandPalette), why="the command palette") - assert await ui.textual[1].click(app.screen.query_one(Input)) + field = app.screen.query_one(Input) + await ui.until_clickable(field, why="the palette's input drawn") + assert await ui.textual[1].click(field) await ui.type(query) await ui.until(lambda: app.screen.query_one(OptionList).option_count > 0, why="palette matches") await ui.press('enter') @@ -112,11 +114,7 @@ async def test_new_session_button_opens_creation_and_tab_close_keeps_the_origina await ui.step() new = app.screen.query_one(CommsSidebar).query_one(NewSessionButton) new.scroll_visible(animate=False, immediate=True) - # A click reaches what the published frame shows (compositor - # published_widgets), not a newer layout that has not been written yet. - await ui.until(lambda: new.region.width > 3 and new.region.height > 0 - and app.screen.get_widget_at(new.region.x + 3, new.region.y)[0] is new, - why="the new-session control to be shown where it is clicked") + await ui.until_clickable(new, (3, 0), why="the new-session control to be shown where it is clicked") assert await ui.textual[1].click(new, offset=(3, 0)) await ui.until(lambda: isinstance(app.screen, CommandDialog), why="the declared new-thread creation form") From a1a013881ab334f6fbbccfec8782ea0ef4ce9a68 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:29:02 -0500 Subject: [PATCH 11/17] A read acknowledgement revoked in flight leaves the read to the work that replaced it mark_read runs in the screen's painted-read worker, which suspend() does not join. An ack that crossed a suspend (retire, close, detach) and met a replaced file retried, called require_commit() on the suspended source and warned the user. It now captures generation, the owner of 'in-flight view work revoked', and returns when the work was revoked while it ran. Traced: an ack started at generation 3, the source suspended, the ack ended at generation 4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/toad/transcript_publication.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index 9e3fdd60d..cd16669e8 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -1020,10 +1020,17 @@ def require_commit(self) -> None: async def mark_read( self, through: TranscriptCursor, acknowledge: Callable[[TranscriptCursor], None], selected: Callable[[], bool] ) -> None: - """A replaced source gets one fresh painted cursor, never a fatal read worker.""" + """A replaced source gets one fresh painted cursor, never a fatal read worker. + + An acknowledgement is in-flight view work: if this source's work was revoked + while it ran (suspended, retired, closed or changed), the work that replaced it + paints and acknowledges its own cursor, so a stale one asks for nothing.""" + generation = self.generation try: await asyncio.to_thread(acknowledge, through) except TranscriptChanged: + if self.generation != generation: + return try: fresh = await self.refresh_cursor() if selected(): From a3206f2e27db2316de16877b952c6c62ef196964 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:35:30 -0500 Subject: [PATCH 12/17] A page read on a view that lost its binding reads again instead of failing the app Uncaptured answers every other question as 'never current, nothing to do', but its read_page raised ValueError('Transcript paging requires a bound thread'), which the source worker reported as an app error whenever a latest-page request queued before an unbind ran after it. ReadAgain is the owner of 'this answer no longer stands; a new request owns the advance', and execute() already drops it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/toad/transcript_publication.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/toad/transcript_publication.py b/src/toad/transcript_publication.py index cd16669e8..df347eaf0 100644 --- a/src/toad/transcript_publication.py +++ b/src/toad/transcript_publication.py @@ -19,6 +19,7 @@ from agent_comms.child_process import join_retirement from agent_comms.coordination.errors import ( CoordinationReadUnavailable, + ReadAgain, ReloadRequired, StaleRevision, TranscriptChanged, @@ -345,7 +346,9 @@ def ready_to_commit(self) -> bool: return False async def read_page(self, scope, *, position: PagePosition, limit: PageLimit) -> TranscriptPage: - raise ValueError("Transcript paging requires a bound thread") + # A page request queued before the view lost its binding: its answer no longer + # stands, and the request a new binding makes reads again. + raise ReadAgain() async def publish(self) -> bool: """Nothing to publish: the request is done.""" From 23e4d2c488b8d37d69ea20d150c7445b1de259e3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:51:26 -0500 Subject: [PATCH 13/17] A history step moves the visit cursor when it is taken, not when its switch finishes The workspace selects the new tab early in a switch and records the visit only after the switch's later frames, so H then L inside that window counted from the old cursor and landed on the wrong tab (test_navigation_keys failed under the settled pilot; a quick H L does the same to a person). TabOrder.navigate now moves the cursor itself. The app's selection and history paths become select_session (records the visit) and show_history_step (the step is already recorded), so the history_index threaded through the switch to re-record it is deleted, and record_visit only appends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/toad/app.py | 19 +++++++++++++----- src/toad/tab_order.py | 29 ++++++++++++--------------- tests/model/test_session_name.py | 2 +- tests/model/test_tab_visit_history.py | 9 ++++----- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/toad/app.py b/src/toad/app.py index 41e5eae9c..7b3272d4a 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -59,6 +59,7 @@ from toad.terminal_origin import TerminalOrigin from toad.clipboard import Clipboard from toad.constants import WELCOME +from toad.tab_identity import TabIdentity from toad.tab_order import TabOrder from toad.thread_navigation import ThreadNavigator from toad.session_navigation import SessionNavigation @@ -189,7 +190,7 @@ def __init__( self.sidebar_state = SidebarState() self._mode_switch_lock = asyncio.Lock() self.tab_order = TabOrder( - lambda identity, index: self.select_session(identity.mode_name, history_index=index), + lambda identity: self.show_history_step(identity.mode_name), home=WELCOME, db=DB(), scopes=connection.tab_scopes(), @@ -386,12 +387,20 @@ async def prepare_session(self, mode: str) -> SessionScope: async with self._mode_switch_lock: return await self.workspace_sessions.prepare(mode) - def select_session(self, mode: str, *, history_index: int | None = None) -> AwaitComplete: + def select_session(self, mode: str) -> AwaitComplete: + """A person's selection: its visit is recorded once the tab is shown.""" + return self._switch(mode, self.tab_order.record_visit) + + def show_history_step(self, mode: str) -> AwaitComplete: + """A history step: TabOrder recorded its visit when the step was taken.""" + return self._switch(mode, lambda identity: None) + + def _switch(self, mode: str, visited: Callable[[TabIdentity], object]) -> AwaitComplete: if mode != self.selected_mode: self.navigation_reader.invalidate() - return AwaitComplete(self._switch_mode_ready(mode, history_index=history_index)) + return AwaitComplete(self._switch_mode_ready(mode, visited)) - async def _switch_mode_ready(self, mode: str, *, history_index: int | None = None) -> None: + async def _switch_mode_ready(self, mode: str, visited: Callable[[TabIdentity], object]) -> None: async with self._mode_switch_lock: previous = self.selected_mode try: @@ -413,7 +422,7 @@ async def _switch_mode_ready(self, mode: str, *, history_index: int | None = Non await self.workspace_screen.layout_navigation() if mode != previous: if entry := self.session_navigation.get(mode): - self.tab_order.record_visit(entry.identity, history_index) + visited(entry.identity) self.events.publish(core_events.SessionSelected(mode, previous)) finally: self.screen.refresh() diff --git a/src/toad/tab_order.py b/src/toad/tab_order.py index e941dc1a2..56cc60a70 100644 --- a/src/toad/tab_order.py +++ b/src/toad/tab_order.py @@ -13,7 +13,7 @@ class TabOrder: def __init__( - self, focus: Callable[[TabIdentity, int | None], object], home: str, db: DB, + self, focus: Callable[[TabIdentity], object], home: str, db: DB, scopes: tuple[TabScope, ...], ) -> None: # What is shown while no tab is open. @@ -105,12 +105,15 @@ def history_target(self, direction: int) -> tuple[int, TabIdentity] | None: return None def navigate(self, direction: int) -> None: + """The cursor moves when the step is taken: a switch runs over several frames, and + a second step taken meanwhile counts from this one, not from where it started.""" if target := self.history_target(direction): index, mode = target - if mode == self._visits[self._cursor]: - self.record_visit(mode, index) - else: - self._focus(mode, index) + switching = mode != self._visits[self._cursor] + self._cursor = index + self.events.publish(TabHistoryChanged()) + if switching: + self._focus(mode) def came_from(self, current: TabIdentity) -> str: """The tab selected before ``current``: the newest other visit, then the open tabs; @@ -120,19 +123,13 @@ def came_from(self, current: TabIdentity) -> str: current.mode_name if current in self else self.home, ) - def record_visit(self, mode: TabIdentity, history_index: int | None = None) -> None: + def record_visit(self, mode: TabIdentity) -> None: + """A new visit: it drops the steps ahead of the cursor and becomes the newest.""" if mode not in self: return - if ( - history_index is not None - and 0 <= history_index < len(self._visits) - and self._visits[history_index] == mode - ): - self._cursor = history_index - else: - del self._visits[self._cursor + 1 :] - self._visits.append(mode) - self._cursor = len(self._visits) - 1 + del self._visits[self._cursor + 1 :] + self._visits.append(mode) + self._cursor = len(self._visits) - 1 self.events.publish(TabHistoryChanged()) async def close(self, modes: Collection[TabIdentity]) -> None: diff --git a/tests/model/test_session_name.py b/tests/model/test_session_name.py index ad941c725..1cde55306 100644 --- a/tests/model/test_session_name.py +++ b/tests/model/test_session_name.py @@ -50,7 +50,7 @@ def record(self, event: SessionChangedEvent, _subscription) -> None: def test_session_tracker_publishes_only_changed_labels(tmp_path): - order = TabOrder(lambda identity, index: None, home="home", db=DB(tmp_path / "toad.db"), + order = TabOrder(lambda identity: None, home="home", db=DB(tmp_path / "toad.db"), scopes=(TabScope("test-core"),)) tracker = SessionTracker(order) details = tracker.new_session(AcpTab(1), title="old") diff --git a/tests/model/test_tab_visit_history.py b/tests/model/test_tab_visit_history.py index 1f981f505..462288c37 100644 --- a/tests/model/test_tab_visit_history.py +++ b/tests/model/test_tab_visit_history.py @@ -13,7 +13,7 @@ async def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no database = DB(tmp_path / "toad.db") await database.create() order = TabOrder( - lambda mode, index: focused.append((mode, index)), + focused.append, home="home", db=database, scopes=(TabScope("test-core"),), ) await order.load() @@ -21,8 +21,7 @@ async def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no await order.open(mode) order.record_visit(mode) order.navigate(-1) - mode, index = focused[-1] - order.record_visit(mode, index) + mode = focused[-1] await order.open(new_kind, after=mode) order.record_visit(new_kind) assert order.names == (first, second, new_kind, third) @@ -30,7 +29,7 @@ async def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no assert order.previous(new_kind) == second.mode_name await order.close({second, third}) order.navigate(-1) - assert focused[-1][0] == first + assert focused[-1] == first assert order.previous(new_kind) == first.mode_name assert order.project({first: 1, new_kind: 2, closed: 3}) == (1, 2) @@ -39,7 +38,7 @@ async def test_home_is_shown_only_while_no_tab_is_open(tmp_path): only, closed = PreviewTab(tmp_path / "only"), PreviewTab(tmp_path / "closed") database = DB(tmp_path / "toad.db") await database.create() - order = TabOrder(lambda mode, index: None, home="home", db=database, scopes=(TabScope("test-core"),)) + order = TabOrder(lambda mode: None, home="home", db=database, scopes=(TabScope("test-core"),)) await order.load() assert order.previous(closed) == "home" await order.open(only) From 8cae87215c981f52ae24b819b5fe923890ee6d6c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:57:43 -0500 Subject: [PATCH 14/17] A commit's prepared page publishes only with its commit When a commit's draw could not publish, its PreparedReplacementViewportRequest was deferred like a reader's request and later drew the commit's rows on its own, with no commit to record the painted cursor. The screen showed the rewritten file while reads acknowledged the old one: TranscriptChanged twice, then 'Saved history reloading'. The commit keeps its pending demand and retries itself, so its page no longer defers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/toad/transcript_state.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/toad/transcript_state.py b/src/toad/transcript_state.py index 7a9c4cf19..43ab129b6 100644 --- a/src/toad/transcript_state.py +++ b/src/toad/transcript_state.py @@ -319,6 +319,11 @@ def source(self, owner: ConversationHistory): async def page(self, owner: TranscriptSourcePreparation, source: PreparedPageSource): return self.initial.for_view() + def defer(self, owner: TranscriptSourcePreparation) -> LatestViewportState: + """A commit's page publishes only with its commit, which records the painted cursor and + retries itself; drawn later on its own, its rows would show a source the cursor never reached.""" + return NoLatestRequest() + @dataclass(frozen=True, eq=False) class WaitingSourceWork[Result]: From dfc9cc557515b83ca5105040c70262be0ab91b7d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 20:03:41 -0500 Subject: [PATCH 15/17] A history that has left its window holds no current source snapshot HistorySourceSnapshot.current rebuilt the owner's snapshot through query_ancestor(HistoryWindow), so a worker that outlived its history's mount (comms_chat _refresh_labels -> update_mentions -> source_is_current) raised NoMatches and failed the app instead of answering 'not current'. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/toad/transcript_source_preparation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/toad/transcript_source_preparation.py b/src/toad/transcript_source_preparation.py index 33d2121fe..c4637ee34 100644 --- a/src/toad/transcript_source_preparation.py +++ b/src/toad/transcript_source_preparation.py @@ -94,8 +94,9 @@ def current(self, owner: TranscriptSourcePreparation) -> bool: Being on screen decides whether rows can be drawn, not whether the read may finish: requiring it here discarded every read a tab started while it was not visible, so the whole missed run loaded in one burst on return. + A history that has left its window holds no source, so no snapshot is current. """ - return self == owner.source_snapshot() + return owner.is_attached and self == owner.source_snapshot() class TranscriptSourcePreparation(CoreEventReceiver): From f3bb131ee9531438645192bf851b4f88acabfd53 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 20:03:41 -0500 Subject: [PATCH 16/17] Sidebar direction test reads the arrows once both bars recompose them Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/textual/test_sidebar_direction.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/textual/test_sidebar_direction.py b/tests/textual/test_sidebar_direction.py index 143e43827..945482838 100644 --- a/tests/textual/test_sidebar_direction.py +++ b/tests/textual/test_sidebar_direction.py @@ -23,7 +23,9 @@ async def test_arrow_clicks_follow_spatial_neighbours(workspace, side): app.sidebar_layout.move(channels.placement_identity, side) app.sidebar_layout.move(thread.placement_identity, side) app.workspace_chrome.refresh_layout() - await ui.step() + # Moving a bar recomposes its controls; read them once both bars have them again. + await ui.until(lambda: all(bar.query(SidebarAction) for bar in (channels, thread)), + why="both sidebars' arrow controls") async def click(bar: SideBar, edge) -> None: button = bar.query_one(f"#sidebar-{edge.declared_name}", SidebarAction) From cb2fe5e3ea88546d5bc923723359168df50a9a97 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 20:05:30 -0500 Subject: [PATCH 17/17] Relock Textual to e54d6c4 (OpenHCSDev/textual#257: the pilot settles on owed work) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index e429ae583..f244eb283 100644 --- a/uv.lock +++ b/uv.lock @@ -1339,7 +1339,7 @@ wheels = [ [[package]] name = "textual" version = "8.2.8" -source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#74e1f0c442851e9532d22bb758c9849ff85ef37e" } +source = { git = "https://github.com/OpenHCSDev/textual.git?branch=main#e54d6c4c6baef8568cde613e71c14c11507b9f0a" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, { name = "mdit-py-plugins" },