diff --git a/src/toad/acp/comms_updates.py b/src/toad/acp/comms_updates.py index 4b091320a..116c38905 100644 --- a/src/toad/acp/comms_updates.py +++ b/src/toad/acp/comms_updates.py @@ -2,8 +2,6 @@ from __future__ import annotations -from agent_comms.errors import RouteChangedError - from dataclasses import dataclass from typing import Any @@ -16,8 +14,8 @@ InputDeliveryChangedUpdate, InputFailedUpdate, InputStartedUpdate, - PromptCancelledUpdate, McpClientAcknowledgementUpdate, + PromptCancelledUpdate, QueueChangedUpdate, RequestFailedUpdate, SelectedWriteAcceptedUpdate, @@ -27,11 +25,12 @@ TurnChangedUpdate, ) from agent_comms.context_reading import UnmeasuredContext +from agent_comms.errors import RouteChangedError from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.thread_presentation import ThreadPresentation + from toad.acp.client_session import ClientSessionRequest from toad.acp.context_measurement import ContextMeasurement - from toad.core import events as core_events @@ -43,7 +42,7 @@ def __init__( *, cursor_token: int | None = None, queue_token: int | None = None, - ): + ) -> None: self.agent = agent self.session_id = session_id self.route = None @@ -51,7 +50,7 @@ def __init__( self.cursor_token = cursor_token self.queue_token = queue_token - async def consume_metadata(self, metadata): + async def consume_metadata(self, metadata) -> None: from .sdk_boundary import DecodeCommsMetadataTask authority = ClientSessionRequest(self.agent, self.session_id) @@ -173,11 +172,11 @@ def mcp_status(self, update: McpClientAcknowledgementUpdate) -> None: self.agent.events.publish(core_events.CommsUpdated(update, self.session_id)) @handles(SelectedWriteAcceptedUpdate) - def selected_write_accepted(self, update): + def selected_write_accepted(self, update) -> None: pass @handles(RequestFailedUpdate) - def request_failed(self, update): + def request_failed(self, update) -> None: from toad.core.events import RequestAgentFail # One request failed; the session that carried it is still open. @@ -186,14 +185,14 @@ def request_failed(self, update): ) @handles(PromptCancelledUpdate) - def prompt_cancelled(self, update): + def prompt_cancelled(self, update) -> None: self.agent.events.publish(core_events.CommsUpdated(update, self.session_id)) class OwnerSnapshotConsumer(CommsUpdateConsumer): """Canonical reads reconcile a turn without overtaking this client's request.""" - def __init__(self, agent, session_id, *, turn_token): + def __init__(self, agent, session_id, *, turn_token) -> None: super().__init__(agent, session_id) self.turn_token = turn_token @@ -202,7 +201,7 @@ def accepts_turn(self): and self.agent.controller.accepts_turn_snapshot(self.turn_token)) @handles(ThreadPresentation) - def thread_presentation(self, presentation): + def thread_presentation(self, presentation) -> None: identity = presentation.read_identity if identity is None: return # An unavailable source supplies no turn settlement. diff --git a/src/toad/acp/sdk_boundary.py b/src/toad/acp/sdk_boundary.py index 21c2f4a3d..9535bd615 100644 --- a/src/toad/acp/sdk_boundary.py +++ b/src/toad/acp/sdk_boundary.py @@ -1,14 +1,17 @@ """Decode the specification once; typed SDK models own ACP values thereafter.""" from __future__ import annotations -from typing import Any, TYPE_CHECKING + from abc import abstractmethod from dataclasses import dataclass -from agent_comms.declared_family import DeclaredFamily -from toad.render_backend import RenderTask +from typing import TYPE_CHECKING + from acp.schema import SessionNotification, ToolCall +from agent_comms.acp_extension import AgentCommsUpdate, decode_updates +from agent_comms.declared_family import DeclaredFamily from agent_comms.field_codec import FieldRepresentation + from toad.acp.notification_items import NotificationItems -from agent_comms.acp_extension import AgentCommsUpdate, decode_updates +from toad.render_backend import RenderTask if TYPE_CHECKING: from toad.acp.status import ToolCallStatus @@ -41,7 +44,7 @@ class AcceptedSessionUpdateValidation(SessionUpdateValidation): notification: SessionNotification updates: tuple[AgentCommsUpdate, ...] - def publish(self, owner, session_id, raw, metadata): + def publish(self, owner, session_id, raw, metadata) -> None: owner.publish(session_id, self.notification, self.updates) @@ -49,7 +52,7 @@ def publish(self, owner, session_id, raw, metadata): class RejectedSessionUpdateValidation(SessionUpdateValidation): error: str - def publish(self, owner, session_id, raw, metadata): + def publish(self, owner, session_id, raw, metadata) -> None: owner.reject(session_id, raw, metadata, self.error) diff --git a/src/toad/render_processes.py b/src/toad/render_processes.py index 28df1db49..d86672de3 100644 --- a/src/toad/render_processes.py +++ b/src/toad/render_processes.py @@ -7,10 +7,10 @@ """ import asyncio -from collections.abc import Callable -from concurrent.futures import ProcessPoolExecutor import multiprocessing import os +from collections.abc import Callable +from concurrent.futures import ProcessPoolExecutor from typing import TYPE_CHECKING, Any, TypeVar from toad.render_backend import Renderer @@ -33,8 +33,10 @@ def _initialize_worker() -> None: No notification or rendering job is made. """ os.environ.pop("AGENT_COMMS_THREAD", None) - from toad.acp import sdk_boundary # noqa: F401 -- declare worker ingress capabilities from toad import render_tasks # noqa: F401 -- declare worker rendering capabilities + from toad.acp import ( + sdk_boundary, # noqa: F401 -- declare worker ingress capabilities + ) class RenderProcessPool(Renderer): @@ -98,7 +100,7 @@ def start(self) -> None: ) self.start_workers(self._executor) - async def run(self, function: Callable[..., Result], *args: Any) -> Result: + async def run[*Args, Value](self, function: Callable[[*Args], Value], *args: *Args) -> Value: """Execute a data-only function in a child, propagating its result/error.""" loop = self._bind_loop() while not self._closed and len(self._pending) >= self._max_pending: diff --git a/src/toad/textual_colours.py b/src/toad/textual_colours.py index 3e50c09fb..bd9422d12 100644 --- a/src/toad/textual_colours.py +++ b/src/toad/textual_colours.py @@ -14,39 +14,53 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, replace from functools import lru_cache -from typing import Any, ClassVar, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, ClassVar from rich.color import Color as RichColor from rich.console import RenderableType from rich.segment import Segment from rich.style import Style as RichStyle from rich.terminal_theme import TerminalTheme -from textual.ansi_palette import AnsiPalette, NativeAnsiPalette, MappedAnsiPalette +from textual.ansi_palette import AnsiPalette, MappedAnsiPalette, NativeAnsiPalette from textual.color import Color -from textual.design import ColorSystem from textual.content import Content +from textual.design import ColorSystem from textual.driver import TerminalGaveNoPalette -from textual.style import Style as VisualStyle -from textual.strip import Strip -from toad.preferences import SavedSettings, ToadSettings from textual.screen import Screen +from textual.strip import Strip +from textual.style import Style as VisualStyle from textual.theme import Theme from textual.timer import Timer -from watchdog.events import FileClosedEvent, FileSystemEvent, FileSystemEventHandler -from watchdog.observers import Observer - from ui_theme.backend import Backend, TerminalBackend from ui_theme.catalog import SCHEMES from ui_theme.clock import AnimationClock -from ui_theme.colour import AnsiColour, Colour, Rgb, TerminalPalette, require_terminal_palette +from ui_theme.colour import ( + AnsiColour, + Colour, + Rgb, + TerminalPalette, + require_terminal_palette, +) from ui_theme.depth import ColourDepth from ui_theme.effects import Band, Gradient, Mix, PlayedText, TextFrame +from ui_theme.scheme import ( + BackgroundColour, + BlendColour, + ColourScheme, + ForegroundColour, + LayerColour, + TextStyle, + ThemeColour, +) from ui_theme.splash import TextEffect -from ui_theme.scheme import BackgroundColour, BlendColour, ColourScheme, ForegroundColour, LayerColour, ThemeColour, TextStyle from ui_theme.wal import WalFile -from toad.widgets.transcript_lines import LineTheme, RichLineRenderer +from watchdog.events import FileClosedEvent, FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + from toad.line_blocks import LineRole +from toad.preferences import SavedSettings, ToadSettings from toad.render_tasks import TextEffectRenderTask +from toad.widgets.transcript_lines import LineTheme, RichLineRenderer if TYPE_CHECKING: from toad.app import ToadApp @@ -186,7 +200,7 @@ class TextualScheme: palette: PaletteState -class TextualColours(Backend[Theme, Color, Any, Strip], TerminalBackend[TerminalTheme]): +class TextualColours(Backend[Theme, Color, Strip | Content, Strip], TerminalBackend[TerminalTheme]): """The app's colour depth, schemes, terminal palettes, line styles and animation clock.""" def __init__(self, app: ToadApp) -> None: @@ -402,7 +416,7 @@ def paint_rising(self, frame: Mix, target: Strip) -> Strip: """The palette state owns RGB blending or the native dim-to-normal step.""" return self.schemes[self.app.theme].palette.paint_rising(self, frame, target) - def paint_text(self, frame: TextFrame, target: None) -> Content: + def paint_text(self, frame: TextFrame, target: Strip | None) -> Content: """Text in Textual's own styled text; each frame is built once.""" return _text(frame) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 0a47dee66..82ba27c63 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -8,7 +8,7 @@ from operator import attrgetter from pathlib import Path from time import monotonic, time_ns -from typing import TYPE_CHECKING, Any, Callable, Literal +from typing import TYPE_CHECKING, Callable, Literal from acp import schema as acp_protocol from agent_comms.goal_presentation import GoalExecution, IdleGoalExecution @@ -40,14 +40,15 @@ from toad.acp.status import StopReason from toad.agent import AgentBase from toad.agent_binding import AgentBinding, NoAgentBound -from toad.agent_presentation import AgentAttachmentView from toad.agent_schema import AgentDefinition from toad.answer import Answer from toad.app import ToadApp from toad.block_navigation import ( + BlockCursor, ContentNavigation, ConversationBlock, ConversationEntry, + CursorDirection, DownCursor, UpCursor, require_blocks, @@ -463,7 +464,7 @@ def _turn_changed(self, owner: TurnOwner) -> None: visible_categories: var[frozenset[type[MessageCategory]]] = var(lambda: all_categories(), init=False) - project_path = var("") + project_path = var(Path) working_directory: var[str] = var("") @@ -516,8 +517,8 @@ def __init__( session_title, initial_prompt, transcript) - def _initialize_session(self, project_path, agent=None, agent_session_id=None, - session_pk=None, session_title=None, initial_prompt=None, + def _initialize_session(self, project_path: Path, agent: AgentDefinition | None=None, agent_session_id: str | None=None, + session_pk: int | None=None, session_title: str | None=None, initial_prompt: str | None=None, transcript: TranscriptPresentation | None = None) -> None: project_path = project_path.resolve().absolute() @@ -674,7 +675,7 @@ async def session_ready(self) -> None: self.take_initial_prompt() @handles(core_events.ConfigurationChanged) - async def on_acp_configuration_changed(self, message: CoreEventMessage): + async def on_acp_configuration_changed(self, message: CoreEventMessage) -> None: self._update_model_info() if (prompt := self.query_one_optional(Prompt)) is not None: prompt.sync_configuration() @@ -868,7 +869,7 @@ def _observed_activity_changed(self, event: ObservedThreadActivity.Changed) -> N prompt.sync_session() @on(events.Key) - async def on_key(self, event: events.Key): + async def on_key(self, event: events.Key) -> None: if self.prompt.prompt_text_area.editing_mode.modal: return if ( @@ -1101,14 +1102,16 @@ async def on_change_mode(self, event: CoreEventMessage) -> None: await self.set_mode(event.event.mode_id) @handles(input_events.ProviderLogin) - def on_provider_login(self, event: CoreEventMessage): + def on_provider_login(self, event: CoreEventMessage) -> None: event.stop() self.action_provider_login() @work(group="provider-login", exclusive=True) async def action_provider_login(self) -> None: import shlex + from textual.geometry import Offset + from toad.agent_schema import Command from toad.catalog_actions import CatalogCommandAction from toad.screens.action_modal import ActionModal @@ -1289,12 +1292,12 @@ async def watch_busy_count(self, _busy: int) -> None: self._sync_working() @on(TerminalTool.Projection) - async def on_terminal_projection(self, message: TerminalTool.Projection): + async def on_terminal_projection(self, message: TerminalTool.Projection) -> None: message.stop() await message.binding.present_terminal(message, self) @handles(core_events.UpdateStatusLine) - async def on_update_status_line(self, message: CoreEventMessage): + async def on_update_status_line(self, message: CoreEventMessage) -> None: # The shared widget can receive a queued status message after its # source changes. The selected Agent owns the measured value. self.agent_binding.refresh_context(self) @@ -1325,7 +1328,7 @@ async def on_mcp_client_stopped( @handles(core_events.Update) - async def on_acp_agent_message(self, message: CoreEventMessage): + async def on_acp_agent_message(self, message: CoreEventMessage) -> None: message.stop() if not self.agent_binding.matches_agent(message.publisher): return @@ -1357,19 +1360,19 @@ def on_worker_state_changed(self, message) -> None: self.transcript.retry() @handles(core_events.Thinking) - async def on_acp_agent_thinking(self, message: CoreEventMessage): + async def on_acp_agent_thinking(self, message: CoreEventMessage) -> None: self.turns.describe("Thinking…") activity = " ".join(message.event.text.splitlines()).strip() or "Thinking" await self.output.append(ThoughtStream(), message.event.text) @handles(core_events.RequestPermission) - async def on_acp_request_permission(self, message: CoreEventMessage): + async def on_acp_request_permission(self, message: CoreEventMessage) -> None: for request in message.publisher.permissions.pending: self.request_permissions(request) self.output.boundary() @handles(core_events.Plan) - async def on_acp_plan(self, message: CoreEventMessage): + async def on_acp_plan(self, message: CoreEventMessage) -> None: from toad.widgets.plan import Plan if self.contents.children and isinstance( @@ -1382,13 +1385,13 @@ async def on_acp_plan(self, message: CoreEventMessage): @handles(core_events.ToolCall) async def on_acp_tool_call_update( self, message: CoreEventMessage - ): + ) -> None: await message.publisher.controller.surface.present_tool(message, self) @handles(core_events.AvailableCommandsUpdate) async def on_acp_available_commands_update( self, message: CoreEventMessage - ): + ) -> None: self.update_slash_commands() async def action_interrupt(self) -> None: @@ -1435,7 +1438,7 @@ def ask( options: list[Answer], title: str = "", get_content: Callable[[], Widget] | None = None, - callback: Callable[[Answer], Any] | None = None, + callback: Callable[[Answer], None] | None = None, ) -> Ask: """Replace the prompt with a dialog to ask a question @@ -1579,7 +1582,7 @@ async def inspect_session(self, event: DetailAction.Activated) -> None: @handles(input_events.GoalControlActivated) - async def on_goal_control(self, event: CoreEventMessage): + async def on_goal_control(self, event: CoreEventMessage) -> None: event.stop() await self.goal_controls.activate(event.event.action) @@ -1819,7 +1822,7 @@ async def post_shell(self, command: str) -> None: width, height = self.get_terminal_dimensions() await self.shell.send(command, width, height) - def move_cursor(self, direction) -> None: + def move_cursor(self, direction: CursorDirection) -> None: self.navigation.move(direction) self.refresh_block_cursor(lambda cursor: cursor.show_moved_to(self.window)) @@ -1930,7 +1933,7 @@ def action_export_to_svg(self) -> None: webbrowser.open(f"file:///{svg_path}") - def refresh_block_cursor(self, show) -> None: + def refresh_block_cursor(self, show: Callable[[BlockCursor], None]) -> None: """Paint the cursor's new block; `show` scrolls for how the cursor got there.""" self.cursor.refresh() if (cursor := self.navigation.cursor) is not None: @@ -1944,7 +1947,7 @@ def refresh_block_cursor(self, show) -> None: self.prompt.focus() self.refresh_bindings() - def _present_block_cursor(self, cursor, show) -> None: + def _present_block_cursor(self, cursor, show: Callable[[BlockCursor], None]) -> None: from toad.screens.session_view import SessionView # A later prompt, tab or source event may already have moved the cursor diff --git a/src/toad/widgets/question.py b/src/toad/widgets/question.py index 66e95326b..3ce4dd54e 100644 --- a/src/toad/widgets/question.py +++ b/src/toad/widgets/question.py @@ -1,25 +1,21 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Callable +from typing import Callable +from textual import containers, events, on, widgets +from textual._activation import PrimaryActivation from textual.app import ComposeResult -from textual import events, on -from textual.binding import Binding -from textual import containers from textual.content import Content -from textual.reactive import var, reactive from textual.message import Message -from textual._activation import PrimaryActivation +from textual.reactive import reactive, var from textual.widget import Widget -from textual import widgets - from toad.answer import Answer +from toad.frame_presentation import UntintedTonePaint +from toad.question_actions import QuestionAction, SelectKindAction from toad.question_presentation import QuestionPresentation from toad.widget_actions import DeclaredWidgetActions -from toad.question_actions import QuestionAction, SelectKindAction -from toad.frame_presentation import UntintedTonePaint type Options = list[Answer] @@ -31,7 +27,7 @@ class Ask: question: str options: Options get_content: Callable[[], Widget] | None = None - callback: Callable[[Answer], Any] | None = None + callback: Callable[[Answer], None] | None = None class NonSelectableLabel(widgets.Label): @@ -178,7 +174,7 @@ def __init__( id: str | None = None, classes: str | None = None, disabled: bool = False, - ): + ) -> None: super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.set_reactive(Question.title, title) self.presentation = QuestionPresentation()