From 3b0054079c801001591fc706aa95863e90e4df63 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:32:52 -0400 Subject: [PATCH 1/4] Withdraw reader keys before tab eviction and save shown errors --- src/toad/app.py | 3 +-- src/toad/cli.py | 2 +- src/toad/paths.py | 11 +++++---- src/toad/screens/main.py | 27 ++++++++++++++++++----- src/toad/session_presentation.py | 4 ++++ src/toad/textual_colours.py | 9 +++++--- src/toad/transcript_source_preparation.py | 3 --- src/toad/widgets/conversation.py | 4 ++-- 8 files changed, 43 insertions(+), 20 deletions(-) diff --git a/src/toad/app.py b/src/toad/app.py index 022a766db..4ffb409a2 100644 --- a/src/toad/app.py +++ b/src/toad/app.py @@ -7,7 +7,6 @@ from toad.widgets.comms_transfer import Transfers import asyncio import signal -import traceback import gc import sys import json @@ -294,7 +293,7 @@ def _handle_exception(self, error: Exception) -> bool: """The mounted application owns escaped handler and worker errors.""" if not self.screen_stack or not self.screen.is_mounted: return super()._handle_exception(error) - self.show_error("".join(traceback.format_exception(error)), type(error).__name__) + self.show_error(error, type(error).__name__) return True async def on_load(self) -> None: diff --git a/src/toad/cli.py b/src/toad/cli.py index bed9c66b2..d7fb196a1 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -18,7 +18,7 @@ def persist_terminal_failure(error: Exception) -> None: from toad import paths try: - path = paths.save_traceback(error, "Terminal_crash") + path = paths.save_error(error, "Terminal_crash") except OSError as log_error: click.echo(f"Unable to save terminal traceback: {log_error}", err=True) else: diff --git a/src/toad/paths.py b/src/toad/paths.py index 773e99181..c698d2eb6 100644 --- a/src/toad/paths.py +++ b/src/toad/paths.py @@ -60,15 +60,18 @@ def get_project_data(project_path: Checkout) -> Path: return project_data_path -def save_traceback(error: BaseException, prefix: str) -> Path: - """Write ``error``'s traceback to a new file in the log directory.""" +def save_error(error: BaseException | str, prefix: str) -> Path: + """Persist an error, retaining an exception's traceback and chained causes.""" import traceback from toad.acp.agent import generate_datetime_filename path = get_log() / generate_datetime_filename(prefix, ".txt") descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - with os.fdopen(descriptor, "w") as stream: - traceback.print_exception(error, file=stream) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + if isinstance(error, BaseException): + traceback.print_exception(error, file=stream) + else: + stream.write(f"{error}\n") return path diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index c7bbfb287..bb2c02cb2 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -128,7 +128,10 @@ def finish(self, screen: "MainScreen", content: "ScreenContent") -> None: content.render(screen) def close(self, screen: "MainScreen") -> None: - """Terminal content has no unfinished waiters to release.""" + self.finish(screen, ClosedContent()) + + def detaching(self, screen: "MainScreen") -> None: + """Only mounted content has reader keys to withdraw.""" def keyboard_scopes(self, screen: MainScreen) -> tuple[DeclaredWidgetActions, ...]: return (screen,) @@ -164,8 +167,19 @@ def finish(self, screen: "MainScreen", content: ScreenContent) -> None: super().finish(screen, content) self.completion.set_result(None) - def close(self, screen: "MainScreen") -> None: - self.finish(screen, ReadyContent()) + +class ClosedContent(ScreenContent): + def line(self) -> str: + return "" + + async def wait(self, screen: "MainScreen") -> None: + """Closing has released the content's waiters.""" + + async def prepare(self, screen: "MainScreen") -> None: + """A closed tab cannot mount content again.""" + + def finish(self, screen: "MainScreen", content: ScreenContent) -> None: + """In-flight preparation cannot revive a closed tab.""" class NavigatedOpeningContent(OpeningContent): @@ -177,6 +191,9 @@ class ReadyContent(ScreenContent): def keyboard_scopes(self, screen: MainScreen) -> tuple[DeclaredWidgetActions, ...]: return (screen, *screen.conversation.keyboard_scopes()) + def detaching(self, screen: "MainScreen") -> None: + self.finish(screen, OpeningContent()) + async def prepare(self, screen: "MainScreen") -> None: await screen._prepare_content() @@ -205,7 +222,7 @@ async def prepare(self, screen: "MainScreen") -> None: self.render(screen) async def attached(self, screen: "MainScreen") -> None: - self.finish(screen, ReadyContent()) + self.finish(screen, OpeningContent()) if screen.is_current: await screen.prepare_presentation() @@ -410,8 +427,8 @@ async def wait_content_ready(self) -> None: await self.content.wait(self) async def close_presentation(self) -> None: - await self.thread_sidebar.evict() self.content.close(self) + await self.thread_sidebar.evict() await self.presentation.close(self) @handles(core_events.CommsUpdated) diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index e13b2259c..eefa2f7c3 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -409,8 +409,12 @@ def external_transcript_changed(self) -> None: self.sources.external_transcript_changed(self.widget) async def remove_native_tree(self) -> None: + from toad.screens.main import MainScreen + if (conversation := self.widget) is None: return + screen = conversation.query_ancestor(MainScreen) + screen.content.detaching(screen) self.widget = None async with AsyncResourceRelease() as release: release.push_async_callback(conversation.remove) diff --git a/src/toad/textual_colours.py b/src/toad/textual_colours.py index be9083e62..16fd2096e 100644 --- a/src/toad/textual_colours.py +++ b/src/toad/textual_colours.py @@ -546,9 +546,12 @@ def show_done(self, text: str, title: str) -> None: self.notify(text, title=title) def show_error(self, error: Exception | str, title: str, severity: SeverityLevel = "error") -> None: - """Every error Toad shows: its text verbatim, never parsed as markup.""" - text = str(error) - self.notify(text, title=title, severity=severity, markup=False) + """Save every shown error before its short, literal notification.""" + from toad import paths + + path = paths.save_error(error, title) + text = str(error).partition("\n")[0] + self.notify(f"{text}\nLog: {path}", title=title, severity=severity, markup=False) def get_color_system(self) -> str: return self.colours.depth.name diff --git a/src/toad/transcript_source_preparation.py b/src/toad/transcript_source_preparation.py index e63f05381..f6f6f9907 100644 --- a/src/toad/transcript_source_preparation.py +++ b/src/toad/transcript_source_preparation.py @@ -127,10 +127,7 @@ def schedule_source_work(self, work) -> Worker | None: return self.reserve_source_work().schedule(self, work) def source_failed(self, error) -> None: - from toad import paths - self.retire_source() - paths.save_traceback(error, "History_failure") self.app.show_error(error, "History") def observe_source(self) -> None: diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 47c98594d..04e7bb542 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -831,9 +831,9 @@ def visible_read_cursor(self): """Only a painted, unfiltered tail can acknowledge the full transcript.""" if not self.is_mounted or self.visible_categories != all_categories(): return None - window = self.query_one_optional(Window) + window = self.window through = self.transcript.displayed_cursor - if through is None or window is None or not window.follows_tail: + if through is None or not window.follows_tail: return None if any(history.blocks_visible_read for history in window.histories): return None From 776f7f928fb49babf8bb2a90d9c6716bac88e5f5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:35:03 -0400 Subject: [PATCH 2/4] Use content readiness for painted reads and retain caught error objects --- src/toad/screens/main.py | 78 ++++++++++++++++++---------------- src/toad/screens/store.py | 2 +- src/toad/session_navigation.py | 6 +-- 3 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index bb2c02cb2..f9b5690a1 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -136,6 +136,9 @@ def detaching(self, screen: "MainScreen") -> None: def keyboard_scopes(self, screen: MainScreen) -> tuple[DeclaredWidgetActions, ...]: return (screen,) + def read_visible(self, screen: MainScreen) -> PaintedRead: + return NothingToRead() + async def attached(self, screen: "MainScreen") -> None: """Repaint the current content state when its session becomes ready.""" self.render(screen) @@ -194,6 +197,43 @@ def keyboard_scopes(self, screen: MainScreen) -> tuple[DeclaredWidgetActions, .. def detaching(self, screen: "MainScreen") -> None: self.finish(screen, OpeningContent()) + def read_visible(self, screen: MainScreen) -> PaintedRead: + """Only mounted content can supply a painted reader cursor.""" + conversation = screen.conversation + through = conversation.visible_read_cursor + if through is None: + return NothingToRead() + app = screen.app + access = app.coordination_access + identity = screen.thread_identity + project = screen.project_path + source_root = identity.wire_root() + worktree = app.project_dir + + def acknowledge(cursor) -> None: + selected = RouteSelection.capture(source_root) + comms = access.require(selected).service + + def mark() -> None: + resolved = identity.resolve_thread(comms.registry, project) + comms.views.mark_thread_view_read( + resolved, worktree=worktree, through=cursor + ) + + access.write(selected, mark) + + async def mark(): + try: + await conversation.transcript.mark_read( + through, + acknowledge, + lambda: app.selected_session is screen, + ) + except DMScopeChanged as error: + app.show_error(error, "DM read not marked") + + return ReadWork(mark()) + async def prepare(self, screen: "MainScreen") -> None: await screen._prepare_content() @@ -271,43 +311,7 @@ def __init__( self.presentation = OperationalSessionPresentation() def read_visible(self) -> PaintedRead: - """Capture this session's painted cursor before its guarded backend write.""" - conversation = self.query_one_optional(Conversation) - if conversation is None: - return NothingToRead() - through = conversation.visible_read_cursor - if through is None: - return NothingToRead() - app = self.app - access = app.coordination_access - identity = self.thread_identity - project = self.project_path - source_root = identity.wire_root() - worktree = app.project_dir - - def acknowledge(cursor) -> None: - selected = RouteSelection.capture(source_root) - comms = access.require(selected).service - - def mark() -> None: - resolved = identity.resolve_thread(comms.registry, project) - comms.views.mark_thread_view_read( - resolved, worktree=worktree, through=cursor - ) - - access.write(selected, mark) - - async def mark(): - try: - await conversation.transcript.mark_read( - through, - acknowledge, - lambda: app.selected_session is self, - ) - except DMScopeChanged as error: - app.show_error(error, "DM read not marked") - - return ReadWork(mark()) + return self.content.read_visible(self) @classmethod def from_thread( diff --git a/src/toad/screens/store.py b/src/toad/screens/store.py index eddbebba1..07f087b7a 100644 --- a/src/toad/screens/store.py +++ b/src/toad/screens/store.py @@ -502,7 +502,7 @@ async def on_mount(self) -> None: try: self._agents = await read_agents() except AgentReadError as error: - self.app.show_error(f"Failed to read agents data ({error})", "Agents data") + self.app.show_error(error, "Failed to read agents data") else: await self.container.mount_compose(self.compose_agents()) with suppress(NoMatches): diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index 2c264f2bc..e48937c22 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -347,10 +347,8 @@ async def history( app = self.app try: source = app.workspace_sessions.origin(owner_mode).require() - except RelationViolationError: - app.show_error( - "The owning agent tab was closed", "Comms target unavailable" - ) + except RelationViolationError as error: + app.show_error(error, "The owning agent tab was closed") return app.selected_mode origin = ThreadOrigin.capture(app, source) try: From 961e7ca7be78ee7fec25f74e7937bea5a8eb349d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:50:37 -0400 Subject: [PATCH 3/4] Reject stale content completion and keep field refusals out of crash logs --- src/toad/forms/editors.py | 12 ++++- src/toad/forms/form_view.py | 19 ++++++- src/toad/paths.py | 9 ++-- src/toad/screens/main.py | 60 ++++++++++++++-------- src/toad/session_navigation.py | 2 +- src/toad/session_presentation.py | 16 +++--- src/toad/textual_colours.py | 15 +++--- src/toad/widgets/comms_command_dialog.py | 3 +- src/toad/widgets/conversation.py | 2 +- src/toad/widgets/session_thread_sidebar.py | 14 +++-- 10 files changed, 97 insertions(+), 55 deletions(-) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 6cd067253..037a5c19e 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -15,6 +15,7 @@ from python_introspect.validation import validate_annotation_value from textual import containers from textual.app import ComposeResult +from textual.message import Message from textual.validation import Function from textual.geometry import Spacing from textual.css.scalar import Scalar @@ -48,6 +49,13 @@ class ValueEditor(DeclaredViews, EditorWidget): field: DeclaredField + class Feedback(Message): + """Transient validation text for the field's own view, never a crash log.""" + + def __init__(self, text: str) -> None: + super().__init__() + self.text = text + def __init__(self, field: DeclaredField) -> None: self.field = field super().__init__(**self.control_arguments()) @@ -81,9 +89,11 @@ def send(self, value: object) -> None: self.field.edit(value) except (TypeError, ValueError) as error: self.refuse(error) + else: + self.post_message(self.Feedback("")) def refuse(self, error: Exception) -> None: - self.app.show_error(error, self.field.label) + self.post_message(self.Feedback(str(error))) self.paint() diff --git a/src/toad/forms/form_view.py b/src/toad/forms/form_view.py index 8ab4e9aef..d56175550 100644 --- a/src/toad/forms/form_view.py +++ b/src/toad/forms/form_view.py @@ -7,7 +7,7 @@ from formbridge.forms.parameter_form_service import ParameterFormService from formbridge.forms.parameter_info_types import DirectDataclassInfo, ParameterInfo from objectstate import DottedFieldPath, ObjectState -from textual import containers, events, lazy +from textual import containers, events, lazy, on from textual.app import ComposeResult from textual.content import Content from textual.widgets import Static @@ -15,7 +15,7 @@ from textual.geometry import Spacing from textual.layouts.stream import StreamLayout from rich.style import Style -from ui_theme.scheme import BackgroundColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour +from ui_theme.scheme import BackgroundColour, ErrorColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour from toad.forms.declared_views import DeclaredField, DeclaredViews from toad.forms.editors import ValueEditor @@ -37,6 +37,12 @@ def paint_foreground(self): return self.app.colours.colour(MutedColour) +class FieldRefusal(FieldHelp): + @property + def paint_foreground(self): + return self.app.colours.colour(ErrorColour) + + class GroupTitle(WidgetView, Static): padding = Spacing(0, 0, 0, 1) @@ -93,6 +99,8 @@ def __init__(self, field: DeclaredField, super().__init__(field, included) self.editor = ValueEditor.declared_for(field) self.title = FieldTitle(field.label) + self.refusal = FieldRefusal("", markup=False) + self.refusal.display = False self.search_text = " ".join( DeclaredField(field.state, step).label for step in field.state.parameter_structure.ancestors(field.path) @@ -102,6 +110,13 @@ def compose(self) -> ComposeResult: yield self.title yield FieldHelp(self.description()) yield self.editor + yield self.refusal + + @on(ValueEditor.Feedback) + def show_feedback(self, message: ValueEditor.Feedback) -> None: + message.stop() + self.refusal.update(message.text) + self.refusal.display = bool(message.text) def description(self) -> Content: help = Content.from_markup(self.field.help) diff --git a/src/toad/paths.py b/src/toad/paths.py index c698d2eb6..5bf4dab27 100644 --- a/src/toad/paths.py +++ b/src/toad/paths.py @@ -60,18 +60,15 @@ def get_project_data(project_path: Checkout) -> Path: return project_data_path -def save_error(error: BaseException | str, prefix: str) -> Path: - """Persist an error, retaining an exception's traceback and chained causes.""" +def save_error(error: BaseException, prefix: str) -> Path: + """Persist the exception's traceback and chained causes.""" import traceback from toad.acp.agent import generate_datetime_filename path = get_log() / generate_datetime_filename(prefix, ".txt") descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - if isinstance(error, BaseException): - traceback.print_exception(error, file=stream) - else: - stream.write(f"{error}\n") + traceback.print_exception(error, file=stream) return path diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index f9b5690a1..398bb77fb 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -3,6 +3,7 @@ import asyncio from abc import abstractmethod from collections.abc import Iterable +from dataclasses import dataclass, field from functools import partial from pathlib import Path from typing import Literal @@ -124,8 +125,14 @@ async def wait(self, screen: "MainScreen") -> None: ... async def prepare(self, screen: "MainScreen") -> None: ... def finish(self, screen: "MainScreen", content: "ScreenContent") -> None: + if screen.content is not self: + return screen.content = content content.render(screen) + self.finished() + + def finished(self) -> None: + """Only opening content has completion waiters to release.""" def close(self, screen: "MainScreen") -> None: self.finish(screen, ClosedContent()) @@ -139,6 +146,12 @@ def keyboard_scopes(self, screen: MainScreen) -> tuple[DeclaredWidgetActions, .. def read_visible(self, screen: MainScreen) -> PaintedRead: return NothingToRead() + def focus_prompt(self, screen: MainScreen, *, scroll_end: bool = True) -> None: + """Content without a mounted composer has no focus target.""" + + def focus_sidebar_return(self, screen: MainScreen) -> None: + """Content without a mounted composer has no return target.""" + async def attached(self, screen: "MainScreen") -> None: """Repaint the current content state when its session becomes ready.""" self.render(screen) @@ -152,9 +165,11 @@ def label(self, screen: "MainScreen", text: str, *, visible: bool) -> Static: return label +@dataclass(eq=False) class OpeningContent(ScreenContent): - def __init__(self) -> None: - self.completion = asyncio.get_running_loop().create_future() + completion: asyncio.Future[None] = field( + default_factory=lambda: asyncio.get_running_loop().create_future() + ) def line(self) -> str: return ThreadLoading.LABEL @@ -166,10 +181,14 @@ async def wait(self, screen: "MainScreen") -> None: async def prepare(self, screen: "MainScreen") -> None: await screen._prepare_content() - def finish(self, screen: "MainScreen", content: ScreenContent) -> None: - super().finish(screen, content) + def finished(self) -> None: self.completion.set_result(None) + def detaching(self, screen: "MainScreen") -> None: + replacement = OpeningContent(self.completion) + screen.content = replacement + replacement.render(screen) + class ClosedContent(ScreenContent): def line(self) -> str: @@ -197,6 +216,14 @@ def keyboard_scopes(self, screen: MainScreen) -> tuple[DeclaredWidgetActions, .. def detaching(self, screen: "MainScreen") -> None: self.finish(screen, OpeningContent()) + def focus_prompt(self, screen: MainScreen, *, scroll_end: bool = True) -> None: + screen.conversation.focus_prompt(scroll_end=scroll_end) + + def focus_sidebar_return(self, screen: MainScreen) -> None: + target = screen.conversation.prompt.prompt_text_area + if target.focusable: + screen.screen.set_focus(target, scroll_visible=False) + def read_visible(self, screen: MainScreen) -> PaintedRead: """Only mounted content can supply a painted reader cursor.""" conversation = screen.conversation @@ -345,26 +372,24 @@ async def prepare_presentation(self) -> None: await self.content.prepare(self) async def _prepare_content(self) -> None: + content = self.content try: await self.presentation.prepare(self) await super().prepare_presentation() await self.thread_sidebar.prepare_presentation() except asyncio.CancelledError as error: - self.opening_failed(error) + content.finish(self, FailedContent(error)) raise except Refused as error: - self.opening_failed(error) + content.finish(self, FailedContent(error)) else: - self.content.finish(self, ReadyContent()) - - def opening_failed(self, error: BaseException) -> None: - self.content.finish(self, FailedContent(error)) + content.finish(self, ReadyContent()) async def retire_presentation(self) -> None: # The departing scene is still committed here. Awaiting source/sidebar # retirement can publish a new layout which no longer contains it. await super().retire_presentation() - await self.thread_sidebar.retire_presentation() + await self.thread_sidebar.retire_presentation(self) await self.presentation.retire(self) def retained_native_presentations(self): @@ -432,7 +457,7 @@ async def wait_content_ready(self) -> None: async def close_presentation(self) -> None: self.content.close(self) - await self.thread_sidebar.evict() + await self.thread_sidebar.evict(self) await self.presentation.close(self) @handles(core_events.CommsUpdated) @@ -608,14 +633,10 @@ def on_option_list_option_highlighted( self.conversation.prompt.suggest(event.option.id) def action_focus_prompt(self) -> None: - if conversation := self.query_one_optional(Conversation): - conversation.focus_prompt() + self.content.focus_prompt(self) def focus_sidebar_return(self) -> None: - if conversation := self.query_one_optional(Conversation): - target = conversation.prompt.prompt_text_area - if target.focusable: - self.screen.set_focus(target, scroll_visible=False) + self.content.focus_sidebar_return(self) async def go_home(self) -> None: await self.app.select_session("store") @@ -627,5 +648,4 @@ async def go_home(self) -> None: @on(SideBar.Dismiss) def on_side_bar_dismiss(self, message: SideBar.Dismiss): message.stop() - if conversation := self.query_one_optional(Conversation): - conversation.focus_prompt(scroll_end=False) + self.content.focus_prompt(self, scroll_end=False) diff --git a/src/toad/session_navigation.py b/src/toad/session_navigation.py index e48937c22..0eccbc0d4 100644 --- a/src/toad/session_navigation.py +++ b/src/toad/session_navigation.py @@ -316,7 +316,7 @@ async def launch( try: agent = agents[agent_identity] except KeyError: - app.show_error("Agent not found", "Launch agent") + app.notify("Agent not found", title="Launch agent", severity="warning", markup=False) return tab = ( AcpTab(session_pk) if session_pk is not None diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index eefa2f7c3..e137a3a7e 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -396,24 +396,21 @@ async def close(self, screen: MainScreen) -> None: release.push_async_callback(self.transcript.close) await screen.app.workspace_chrome.native.dispose(screen) - async def evict(self) -> None: + async def evict(self, screen: MainScreen) -> None: """Release the owned native tree while retaining its actual editor resource.""" if (conversation := self.widget) is None: return self.state = SessionViewState.capture(conversation) - await self.remove_native_tree() + await self.remove_native_tree(screen) def external_transcript_changed(self) -> None: """Refresh the owned CLI view; an unbuilt view reads its source on attach.""" if self.widget is not None: self.sources.external_transcript_changed(self.widget) - async def remove_native_tree(self) -> None: - from toad.screens.main import MainScreen - + async def remove_native_tree(self, screen: MainScreen) -> None: if (conversation := self.widget) is None: return - screen = conversation.query_ancestor(MainScreen) screen.content.detaching(screen) self.widget = None async with AsyncResourceRelease() as release: @@ -479,8 +476,9 @@ async def _trim_retained(self, selected: Conversation) -> None: """ app = self._app() budget = selected.window.presentation_budget + screens = {owner: screen for screen, owner in self._presentations()} candidates = {} - for screen, owner in self._presentations(): + for owner, screen in screens.items(): candidates.setdefault(screen.id, []).append(owner) required = ( self.view.presentation, @@ -508,14 +506,14 @@ async def _trim_retained(self, selected: Conversation) -> None: ) for owner in ordered: if owner not in kept: - await owner.evict() + await owner.evict(screens[owner]) async def dispose(self, screen: MainScreen) -> None: """Finalize a live tree before pruning; a closed tab retains no editor.""" async with self._lock: owner = screen.presentation async with AsyncResourceRelease() as release: - release.push_async_callback(owner.remove_native_tree) + release.push_async_callback(owner.remove_native_tree, screen) if self.view is screen: self.view = None await owner.release_binding(owner.widget, screen) diff --git a/src/toad/textual_colours.py b/src/toad/textual_colours.py index 16fd2096e..e40af1866 100644 --- a/src/toad/textual_colours.py +++ b/src/toad/textual_colours.py @@ -67,6 +67,7 @@ from watchdog.observers import Observer from watchdog.observers.api import BaseObserver +from toad import paths from toad.line_blocks import LineRole from toad.preferences import SavedSettings, ToadSettings from toad.render_tasks import TextEffectRenderTask @@ -328,9 +329,10 @@ async def mount(self) -> None: def choose(self, name: str) -> None: if name not in self.schemes: # `wal` exists once pywal has written its palette. - self.app.show_error(f"Theme {name!r} is not available" - + (f": pywal has not written {self.wal_directory / WalFile.NAME}" - if name == WalFile.SCHEME else ""), "Theme") + self.app.notify(f"Theme {name!r} is not available" + + (f": pywal has not written {self.wal_directory / WalFile.NAME}" + if name == WalFile.SCHEME else ""), + title="Theme", severity="warning", markup=False) return self.app.theme = name @@ -546,9 +548,10 @@ def show_done(self, text: str, title: str) -> None: self.notify(text, title=title) def show_error(self, error: Exception | str, title: str, severity: SeverityLevel = "error") -> None: - """Save every shown error before its short, literal notification.""" - from toad import paths - + """Persist real exceptions; text from ResultShower is a transient refusal.""" + if isinstance(error, str): + self.notify(error, title=title, severity=severity, markup=False) + return path = paths.save_error(error, title) text = str(error).partition("\n")[0] self.notify(f"{text}\nLog: {path}", title=title, severity=severity, markup=False) diff --git a/src/toad/widgets/comms_command_dialog.py b/src/toad/widgets/comms_command_dialog.py index 6be0dc43c..8f72beca7 100644 --- a/src/toad/widgets/comms_command_dialog.py +++ b/src/toad/widgets/comms_command_dialog.py @@ -102,7 +102,8 @@ async def connect_provider(self): await command.refresh_catalogs(command.arguments()) self.configuration_changed() elif self.is_attached: - self.app.show_error(f'Provider login exited with status {result}', 'Provider login') + self.app.notify(f'Provider login exited with status {result}', + title='Provider login', severity='warning', markup=False) if self.is_attached: self.search_input.focus() diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 04e7bb542..e091d0c12 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -1108,7 +1108,7 @@ async def on_agent_fail(self, event: AgentFail) -> None: self.prompt.sync_session() if (details := self.query_one_optional(SessionDetails)) is not None: details.sync_turn() - self.app.show_error(event.message, "Agent failure") + self.app.notify(event.message, title="Agent failure", severity="error", markup=False) if self._agent_data is not None: self.app.application.usage.publish( diff --git a/src/toad/widgets/session_thread_sidebar.py b/src/toad/widgets/session_thread_sidebar.py index 558ddb44a..f8e9ee9ba 100644 --- a/src/toad/widgets/session_thread_sidebar.py +++ b/src/toad/widgets/session_thread_sidebar.py @@ -52,20 +52,20 @@ async def _cancel_panel_workers(self) -> None: except WorkerCancelled: pass - async def retire_presentation(self) -> None: + async def retire_presentation(self, screen: "MainScreen") -> None: if not self.panels_loaded: # A partial mount is discarded rather than parked as a complete view. - await self.evict() + await self.evict(screen) return await self._cancel_panel_workers() for panel in self.panels: panel.widget.sidebar_visibility_changed() - async def evict(self) -> None: + async def evict(self, screen: "MainScreen") -> None: """Release panels on eviction or close, preserving their reader position.""" await self._cancel_panel_workers() self.navigation.panel_scroll_y = self.panels_viewport.scroll_y - self.capture_panels() + self.capture_panels(screen) await self.panels_viewport.remove_children() if controls := self.query_one_optional("#sidebar-controls"): await controls.remove_children() @@ -86,12 +86,10 @@ def update_plan(self, entries: list[PlanItem]) -> None: """Absent panels consume the latest source value, never a replay buffer.""" self.plan.update(self, entries) - def capture_panels(self) -> None: + def capture_panels(self, screen: "MainScreen") -> None: for owner, panel in zip(self._panel_owners, self.panels): owner.capture(panel.widget) - screen = self._owner() - if screen is not None: - screen._project_panel = None + screen._project_panel = None def _compose_panels(self) -> ComposeResult: if not self.panels: From f07d35f941651259f62903eb88c0c14f834fe9b1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 29 Oct 2026 03:59:33 -0400 Subject: [PATCH 4/4] Only publish field feedback when its text changes --- src/toad/forms/editors.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/toad/forms/editors.py b/src/toad/forms/editors.py index 037a5c19e..76c3b1c63 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -16,6 +16,7 @@ from textual import containers from textual.app import ComposeResult from textual.message import Message +from textual.reactive import var from textual.validation import Function from textual.geometry import Spacing from textual.css.scalar import Scalar @@ -39,8 +40,8 @@ class ValueEditor(DeclaredViews, EditorWidget): """A control that shows one field's value and sends the user's edits to the state. - The value lives in the ObjectState: the control is painted from it and - keeps nothing else. The editor capability binds the field before the native + The value lives in the ObjectState: the control is painted from it. Only + transient validation feedback lives here. The editor binds the field before the native control is initialized: `class BoolEditor(ValueEditor, Checkbox)`. """ @@ -48,6 +49,7 @@ class ValueEditor(DeclaredViews, EditorWidget): """Whether the field's help should end with its default value.""" field: DeclaredField + feedback: var[str] = var("", init=False) class Feedback(Message): """Transient validation text for the field's own view, never a crash log.""" @@ -90,12 +92,15 @@ def send(self, value: object) -> None: except (TypeError, ValueError) as error: self.refuse(error) else: - self.post_message(self.Feedback("")) + self.feedback = "" def refuse(self, error: Exception) -> None: - self.post_message(self.Feedback(str(error))) + self.feedback = str(error) self.paint() + def watch_feedback(self, text: str) -> None: + self.post_message(self.Feedback(text)) + class BoolEditor(ValueEditor, FormCheckbox): shows = (bool,)