Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions src/toad/session_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions src/toad/widget_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions src/toad/workspace_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion tests/support/core_adapters/mcp_permission_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions tests/textual/test_hidden_session_retirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:]:
Expand Down
7 changes: 4 additions & 3 deletions tests/textual/test_initial_prompt_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand All @@ -60,7 +61,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
Expand All @@ -84,7 +85,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()
Expand Down
8 changes: 4 additions & 4 deletions tests/textual/test_keymap_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions tests/textual/test_keymap_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")

Expand Down
2 changes: 1 addition & 1 deletion tests/textual/test_plan_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions tests/textual/test_session_start_failure.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions tests/textual/test_sidebar_retirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion tests/textual/test_sidebar_state_across_views.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
)
Expand Down
2 changes: 1 addition & 1 deletion tests/textual/test_tab_return_keeps_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading