From 658b0c48d6e873549e4e2bb3b2503e3809bf6df3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 00:05:59 -0400 Subject: [PATCH 1/6] Declare that opening a row is what enter means on a list that opens rows DeclaredTable(OpensSelected, DataTable) inherits SelectCursor on enter and takes the key with OpenSelectedAction first in its MRO. The behaviour was intended and never declared, so the keymap reported a conflict in its own defaults. The keymap tests picked f7 as a free key. TextArea owns f7 as Select all, so they now use f18, which nothing claims. Co-Authored-By: Claude Opus 5.5 --- src/toad/widget_actions.py | 6 ++++++ tests/textual/test_keymap_model.py | 8 ++++---- tests/textual/test_keymap_screen.py | 8 ++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index da7712683..f38f140fc 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -17,6 +17,7 @@ from textual._activation import PrimaryActivation from textual._callback import invoke from textual.keys import KeyDeclaration, KeyDeclarationType +from textual.navigation import SelectCursor from textual.widgets.text_area import EditingMode from textual.dom import DOMNode from toad.app_node import ToadNode @@ -331,6 +332,11 @@ class OpenSelectedAction(VisibleAction): title = "Open" keys = {EditingMode: ("enter",)} + @classmethod + def permits_key_overlap(cls, other: KeyDeclarationType) -> bool: + """Opening the row is what selecting the cursor means on a list that opens rows.""" + return issubclass(other, SelectCursor) + async def apply(self, context: OpensSelected) -> None: context.open_selected() diff --git a/tests/textual/test_keymap_model.py b/tests/textual/test_keymap_model.py index 986a58025..1166d1969 100644 --- a/tests/textual/test_keymap_model.py +++ b/tests/textual/test_keymap_model.py @@ -12,17 +12,17 @@ def test_saved_remaps_keep_modes_independent_and_reset_to_the_declaration(): catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) ordinary = KeymapRow.of(KeymapAction(), Standard) normal = KeymapRow.of(KeymapAction(), VimNormal) - keymap = Keymap().changed(normal, ("f7",)) + keymap = Keymap().changed(normal, ("f18",)) applied = [] settings = SavedSettings(applied=applied.append) settings.set(SETTINGS.ui.keymap.bindings, keymap.bindings) loaded = SavedSettings(settings.document()).current.ui.keymap assert [setting.path for setting in applied] == [SETTINGS.ui.keymap.bindings.bind(settings.state).path] - assert catalog.effective(normal, loaded) == ("f7",) + assert catalog.effective(normal, loaded) == ("f18",) assert catalog.effective(ordinary, loaded) == catalog.defaults(ordinary) assert catalog.effective(normal, loaded.reset(normal)) == catalog.defaults(normal) with pytest.raises(ValueError, match="Commander.*Keymap|Keymap.*Commander"): - loaded.changed(KeymapRow.of(CommanderAction(), VimNormal), ("f7",)) + loaded.changed(KeymapRow.of(CommanderAction(), VimNormal), ("f18",)) def test_an_empty_override_unbinds_and_survives_saving_until_reset(): @@ -44,7 +44,7 @@ def test_defaults_allow_the_declared_alternatives_in_every_mode(): def test_reset_cannot_reintroduce_a_collision_and_duplicate_rows_are_refused(): catalog = KeymapCatalog.capture(KeyMode.members_with(KeyMode)) menu = KeymapRow.of(KeymapAction(), Standard) - keymap = Keymap().changed(menu, ("f7",)) + keymap = Keymap().changed(menu, ("f18",)) keymap = keymap.changed(KeymapRow.of(CommanderAction(), Standard), catalog.defaults(menu)) with pytest.raises(ValueError, match="Key conflict"): keymap.reset(menu) diff --git a/tests/textual/test_keymap_screen.py b/tests/textual/test_keymap_screen.py index 5b9572131..8aa31a66f 100644 --- a/tests/textual/test_keymap_screen.py +++ b/tests/textual/test_keymap_screen.py @@ -51,7 +51,7 @@ async def test_f1_menu_remaps_itself_saves_and_refuses_a_conflict(workspace, mod table = app.screen.query_one(DataTable) assert table.size.height > table.header_height, "The command rows must have visible space" await choose(ui, KeymapAction(), mode) - app.screen.query_one("#keymap-keys", Input).value = "f7" + app.screen.query_one("#keymap-keys", Input).value = "f18" await ui.pilot.click("#keymap-apply") await ui.until(lambda: KeymapAction.binding_id(mode().binding_mode) in app._keymap, why="the saved menu remap") @@ -67,10 +67,10 @@ async def test_f1_menu_remaps_itself_saves_and_refuses_a_conflict(workspace, mod await ui.until(lambda: app.screen is app.workspace_screen, why="the keymap menu closed") await ui.press("f1") assert app.screen is app.workspace_screen - await ui.press("f7") + await ui.press("f18") await ui.until(lambda: isinstance(app.screen, KeymapScreen), why="the remapped menu key") await choose(ui, CommanderAction(), mode) - app.screen.query_one("#keymap-keys", Input).value = "f7" + app.screen.query_one("#keymap-keys", Input).value = "f18" await ui.pilot.click("#keymap-apply") await ui.until(lambda: "Key conflict" in str(app.screen.query_one("#keymap-status", Static).render()), why="the colliding remap refusal") @@ -80,7 +80,7 @@ async def test_f1_menu_remaps_itself_saves_and_refuses_a_conflict(workspace, mod async with workspace.app(size=size) as reopened: await reopened.press("f1") assert reopened.app.screen is reopened.app.workspace_screen - await reopened.press("f7") + await reopened.press("f18") await reopened.until(lambda: isinstance(reopened.app.screen, KeymapScreen), why="the saved menu remap after restarting the app") From 8105a4c4311fe582479335b103511d8eb74f82fc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 00:44:34 -0400 Subject: [PATCH 2/6] Pass the Checkout from_thread declares in hidden session tests owner.worktree already is the Checkout; wrapping it in Path made on_mount raise, leaving a dead screen whose sidebar query then failed with NoMatches. Co-Authored-By: Claude Opus 5.5 --- tests/textual/test_hidden_session_retirement.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/textual/test_hidden_session_retirement.py b/tests/textual/test_hidden_session_retirement.py index fdf1135ff..92ac5d6c4 100644 --- a/tests/textual/test_hidden_session_retirement.py +++ b/tests/textual/test_hidden_session_retirement.py @@ -2,7 +2,6 @@ from agent_comms.repo import Checkout from collections import Counter -from pathlib import Path import sys from agent_comms.comms import Comms @@ -34,7 +33,7 @@ async def open_hidden_sessions(workspace, ui, owners): first = ui.selected_tab def source(owner): - return MainScreen.from_thread(Path(owner.worktree), str(workspace.comms.root), owner.incarnation) + return MainScreen.from_thread(owner.worktree, str(workspace.comms.root), owner.incarnation) modes = {} for owner in owners[1:]: From 1524ce3470caba6d0ab033ad81d0c31c1436b8e9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 00:46:22 -0400 Subject: [PATCH 3/6] Pass the screen evict declares at every stale test call Co-Authored-By: Claude Opus 5.5 --- tests/support/core_adapters/mcp_permission_observer.py | 2 +- tests/textual/test_initial_prompt_handoff.py | 4 ++-- tests/textual/test_plan_display.py | 2 +- tests/textual/test_sidebar_retirement.py | 8 ++++---- tests/textual/test_tab_return_keeps_blocks.py | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/support/core_adapters/mcp_permission_observer.py b/tests/support/core_adapters/mcp_permission_observer.py index 6bcc9658d..9490b7dc8 100644 --- a/tests/support/core_adapters/mcp_permission_observer.py +++ b/tests/support/core_adapters/mcp_permission_observer.py @@ -102,7 +102,7 @@ async def request_permission(self, *, session_id, tool_call, options, **kwargs): previous = self.view presentation = self.session.presentation await presentation.retire(self.session) - await presentation.evict() + await presentation.evict(self.session) assert request.pending and not self.agent.controller.surface.owns(previous) assert not previous.is_mounted and presentation.sources.agent is self.agent await presentation.prepare(self.session) diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index 9f1151b00..e5066bd3c 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -60,7 +60,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th owner.publication.clear() await app.session_navigation.new(await saved_tab(app), lambda: MainScreen(app.project_dir)) await app.selected_session.wait_content_ready() - await owner.presentation.evict() + await owner.presentation.evict(owner) assert view._initial_prompt is None assert owner.presentation.state.initial_prompt == "NEWEST_PENDING_LOCAL_UI_EVENT" assert owner.presentation.widget is None @@ -84,7 +84,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th # The consumed value must stay absent on another eviction. await app.session_navigation.new(await saved_tab(app), lambda: MainScreen(app.project_dir)) await app.selected_session.wait_content_ready() - await owner.presentation.evict() + await owner.presentation.evict(owner) assert owner.presentation.state.initial_prompt is None await ui.select_tab(owner.id) await ui.step() diff --git a/tests/textual/test_plan_display.py b/tests/textual/test_plan_display.py index 73124e037..11876a736 100644 --- a/tests/textual/test_plan_display.py +++ b/tests/textual/test_plan_display.py @@ -108,7 +108,7 @@ async def test_an_empty_plan_resets_the_view_and_a_retired_sidebar_shows_later_p advance(workspace, 0) await ui.until(lambda: plan.all_complete, why="the completed plan") advance(workspace, 1) - await sidebar.evict() + await sidebar.evict(ui.textual[0].selected_session) assert not sidebar.panels await ui.until(lambda: not plan.entries and not plan.all_complete, why="the plan to reset") await ui.until(lambda: painted_text(plan, "No plan yet"), why="the empty plan painted") diff --git a/tests/textual/test_sidebar_retirement.py b/tests/textual/test_sidebar_retirement.py index 96b981f39..bf63a99da 100644 --- a/tests/textual/test_sidebar_retirement.py +++ b/tests/textual/test_sidebar_retirement.py @@ -140,7 +140,7 @@ async def exercise(ui, screen, root): assert tree_ref() is sidebar.query_one(ProjectDirectoryTree) await sidebar.prepare_presentation() assert tree_ref() is sidebar.query_one(ProjectDirectoryTree) - await sidebar.evict() + await sidebar.evict(screen) assert not sidebar.panels and not sidebar._panels_loaded assert screen._project_panel is None assert not tuple(viewport.children) @@ -214,7 +214,7 @@ async def test_panels_rebuild_after_eviction_while_collapsed(workspace): async with workspace.app(size=(130, 44), application=InstalledApp) as ui: screen = await started(workspace, ui) sidebar = screen.query_one(SessionThreadSidebar) - await sidebar.evict() + await sidebar.evict(screen) sidebar.reveal() sidebar.collapsed = True await ui.step(0.05) @@ -231,7 +231,7 @@ async def test_panels_show_the_current_project_after_the_source_project_changes_ async with workspace.app(size=(130, 44), application=InstalledApp) as ui: screen = await started(workspace, ui) sidebar = await reveal(ui, screen) - await sidebar.evict() + await sidebar.evict(screen) replacement = workspace.project.parent / "replacement-project" replacement.mkdir() (replacement / "new-source.txt").write_text("Current filesystem") @@ -254,7 +254,7 @@ async def test_leaving_a_view_stops_its_sidebar_hydration(workspace): sidebar = await reveal(ui, screen) # The next tab is saved first, so leaving the view does not wait on the database. tab = await saved_tab(app) - await sidebar.evict() + await sidebar.evict(screen) sidebar._start_hydration() assert sidebar._panels_loading workers = tuple( diff --git a/tests/textual/test_tab_return_keeps_blocks.py b/tests/textual/test_tab_return_keeps_blocks.py index 8d29fcc1e..d7f1c5b50 100644 --- a/tests/textual/test_tab_return_keeps_blocks.py +++ b/tests/textual/test_tab_return_keeps_blocks.py @@ -47,7 +47,7 @@ async def test_evicted_saved_tab_restores_before_its_first_published_frame(works document, undo = editor.document, editor.history old_view = ui.conversation other = await ui.new_session() - await owner.presentation.evict() + await owner.presentation.evict(owner) assert owner.presentation.widget is None assert owner.presentation.transcript.documents From 4e5988674979662572ada7e97d4659d8aa240085 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 00:47:58 -0400 Subject: [PATCH 4/6] Pass the Checkout MainScreen declares from the remaining tests Co-Authored-By: Claude Opus 5.5 --- tests/textual/test_initial_prompt_handoff.py | 3 ++- tests/textual/test_sidebar_state_across_views.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index e5066bd3c..b93029ad9 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -2,6 +2,7 @@ conversation widget being evicted and remounted, and keeps the editor's document and undo history.""" import asyncio +from agent_comms.repo import Checkout from collections import Counter @@ -41,7 +42,7 @@ def observed(self, event, subscription): async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_the_draft_and_undo_survive(workspace): async with workspace.app(size=(110, 35)) as ui: app, _pilot = ui.textual - owner = ObservedLaunchScreen(workspace.project, initial_prompt="INITIAL_LOCAL_UI_EVENT") + owner = ObservedLaunchScreen(Checkout(workspace.project), initial_prompt="INITIAL_LOCAL_UI_EVENT") await app.session_navigation.new(await saved_tab(app), lambda: owner) await owner.wait_content_ready() await asyncio.wait_for(owner.publication.wait(), 12) diff --git a/tests/textual/test_sidebar_state_across_views.py b/tests/textual/test_sidebar_state_across_views.py index dd8d10d3a..7f15805c5 100644 --- a/tests/textual/test_sidebar_state_across_views.py +++ b/tests/textual/test_sidebar_state_across_views.py @@ -1,5 +1,6 @@ """Textual-facing: the channels sidebar's expansion, scroll position and range anchor survive switching between views, painted identically from its first ready frame.""" +from agent_comms.repo import Checkout from support.stylesheets import INSTALLED from support.textual_steps import channel_roster @@ -75,7 +76,7 @@ async def test_sidebar_expansion_scroll_and_anchor_survive_view_changes(workspac worker = await app.session_navigation.new( MailboxTab(workspace.comms.registry.require("worker").incarnation, str(workspace.comms.root)), lambda: MainScreen.from_thread( - workspace.project, + Checkout(workspace.project), str(workspace.comms.root), workspace.comms.registry.require("worker").incarnation, ) From c381f291348e8b7c194fe5db42d1aa7891c2666e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 00:50:49 -0400 Subject: [PATCH 5/6] A session that fails to start leaves the workspace and says so A failed Mount was shown by ToadApp._handle_exception while Textual tore the tree down, but WorkspaceSessions.views kept it: select and close then raised NoMatches for the sidebar, naming the wrong thing. Co-Authored-By: Claude Opus 5.5 --- src/toad/session_scope.py | 7 ++++++ src/toad/workspace_sessions.py | 6 ++++++ tests/textual/test_session_start_failure.py | 24 +++++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 tests/textual/test_session_start_failure.py diff --git a/src/toad/session_scope.py b/src/toad/session_scope.py index d8d4004f1..22e5d3731 100644 --- a/src/toad/session_scope.py +++ b/src/toad/session_scope.py @@ -328,6 +328,13 @@ class SessionScope(NavigationOwner, ProjectPathOwner, ToadNode): footer_compact = False presentation: OperationalSessionPresentation + async def _pre_process(self) -> bool: + """Textual tears a failed start down; the workspace must not keep the dead tree.""" + started = await super()._pre_process() + if not started: + self.app.workspace_sessions.detach(self.id) + return started + def matches_launch(self, agent_identity: str, session_id: str) -> bool: return False diff --git a/src/toad/workspace_sessions.py b/src/toad/workspace_sessions.py index 4f589477a..d6ddababe 100644 --- a/src/toad/workspace_sessions.py +++ b/src/toad/workspace_sessions.py @@ -165,6 +165,10 @@ async def retire(self): return self +class SessionStartFailed(RuntimeError): + """A session's own startup failed; the app has already shown that error.""" + + class WorkspaceSessions: def __init__(self, app: "ToadApp") -> None: self.app = app @@ -196,6 +200,8 @@ async def prepare(self, identity: str) -> "SessionScope": self.views[identity] = view host = self.app.workspace_screen.query_one("#workspace-session-host", Container) await host.mount(view) + if identity not in self.views: + raise SessionStartFailed(identity) return view async def select(self, identity: str) -> "SessionScope": diff --git a/tests/textual/test_session_start_failure.py b/tests/textual/test_session_start_failure.py new file mode 100644 index 000000000..95f5e3ae3 --- /dev/null +++ b/tests/textual/test_session_start_failure.py @@ -0,0 +1,24 @@ +"""Textual-facing: a session whose mount fails leaves the workspace, instead of crashing later as a missing sidebar.""" + +from agent_comms.repo import Checkout +import pytest + +from support.ui import saved_tab + +from toad.screens.main import MainScreen +from toad.workspace_sessions import SessionStartFailed + + +class FailsToStart(MainScreen): + def on_mount(self) -> None: + raise ValueError("START_FAILURE") + + +async def test_a_failed_mount_names_the_session_and_leaves_no_dead_view(workspace): + async with workspace.app(size=(110, 35)) as ui: + app, _ = ui.textual + before = set(app.workspace_sessions.views) + with pytest.raises(SessionStartFailed): + await app.session_navigation.new( + await saved_tab(app), lambda: FailsToStart(Checkout(workspace.project))) + assert set(app.workspace_sessions.views) == before From c2061c5471e7ba247134968b964256bc40472fd0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 01:12:55 -0400 Subject: [PATCH 6/6] Give the suite enough time to finish 722 tests on 2 workers reached 587 in the 60 minute cap, so every run was cancelled and the required suite check could not pass on any head. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfe19439f..e8d633615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: suite: runs-on: ubuntu-latest # A hung test must turn red in minutes, not burn GitHub's 6-hour default. - timeout-minutes: 60 + timeout-minutes: 120 steps: - uses: actions/checkout@v4 with: