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/forms/editors.py b/src/toad/forms/editors.py index 6cd067253..76c3b1c63 100644 --- a/src/toad/forms/editors.py +++ b/src/toad/forms/editors.py @@ -15,6 +15,8 @@ from python_introspect.validation import validate_annotation_value 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 @@ -38,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)`. """ @@ -47,6 +49,14 @@ 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.""" + + def __init__(self, text: str) -> None: + super().__init__() + self.text = text def __init__(self, field: DeclaredField) -> None: self.field = field @@ -81,11 +91,16 @@ def send(self, value: object) -> None: self.field.edit(value) except (TypeError, ValueError) as error: self.refuse(error) + else: + self.feedback = "" def refuse(self, error: Exception) -> None: - self.app.show_error(error, self.field.label) + 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,) 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 773e99181..5bf4dab27 100644 --- a/src/toad/paths.py +++ b/src/toad/paths.py @@ -60,14 +60,14 @@ 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, 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") as stream: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: traceback.print_exception(error, file=stream) return path diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index c7bbfb287..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,15 +125,33 @@ 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: - """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,) + 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) @@ -146,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 @@ -160,12 +181,27 @@ 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 close(self, screen: "MainScreen") -> None: - self.finish(screen, ReadyContent()) + def detaching(self, screen: "MainScreen") -> None: + replacement = OpeningContent(self.completion) + screen.content = replacement + replacement.render(screen) + + +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 +213,54 @@ 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()) + + 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 + 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() @@ -205,7 +289,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() @@ -254,43 +338,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( @@ -324,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): @@ -410,8 +456,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(self) await self.presentation.close(self) @handles(core_events.CommsUpdated) @@ -587,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") @@ -606,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/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..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 @@ -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: diff --git a/src/toad/session_presentation.py b/src/toad/session_presentation.py index e13b2259c..e137a3a7e 100644 --- a/src/toad/session_presentation.py +++ b/src/toad/session_presentation.py @@ -396,21 +396,22 @@ 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: + async def remove_native_tree(self, screen: MainScreen) -> None: if (conversation := self.widget) is None: return + screen.content.detaching(screen) self.widget = None async with AsyncResourceRelease() as release: release.push_async_callback(conversation.remove) @@ -475,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, @@ -504,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 be9083e62..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,13 @@ 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) + """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) 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/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 47c98594d..e091d0c12 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 @@ -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: