diff --git a/src/toad/cli.py b/src/toad/cli.py index b05049d11..b643195b1 100644 --- a/src/toad/cli.py +++ b/src/toad/cli.py @@ -56,7 +56,7 @@ def check_directory(path: str) -> None: async def get_agent_binding(launch_agent: str) -> AgentBinding: if not launch_agent: return NoAgent() - launch_agent = launch_agent.lower() + requested = launch_agent.lower() from toad.agents import read_agents @@ -64,12 +64,19 @@ async def get_agent_binding(launch_agent: str) -> AgentBinding: for agent_data in agents.values(): if ( - agent_data.short_name.lower() == launch_agent - or agent_data.identity.lower() == launch_agent + agent_data.short_name.lower() == requested + or agent_data.identity.lower() == requested ): return AgentAttached(agent_data) - return NoAgent() + known = ", ".join(sorted( + f"{agent.short_name} ({agent.identity})" if agent.short_name else agent.identity + for agent in agents.values() + )) + raise click.BadParameter( + f"Unknown agent {launch_agent!r}. Known agents: {known}", + param_hint="-a / --agent", + ) class DefaultCommandGroup(click.Group): diff --git a/src/toad/widgets/committed_presentation.py b/src/toad/widgets/committed_presentation.py index be5e75bdd..85b7616c0 100644 --- a/src/toad/widgets/committed_presentation.py +++ b/src/toad/widgets/committed_presentation.py @@ -4,27 +4,31 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Iterable -from functools import partial -from dataclasses import dataclass from contextlib import asynccontextmanager +from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING +from agent_comms.acp_extension import InputStartedUpdate +from agent_comms.sent_message import OutgoingMessageReference, PublishedSentMessage from agent_comms.transcripts import TranscriptCursor, TranscriptPage from textual.widget import Widget + +from toad.core.source_events import TranscriptCoverage from toad.transcript_state import CommitTranscript, PreparedReplacementViewportRequest from toad.widgets.history_anchor import LatestPosition -from agent_comms.sent_message import PublishedSentMessage, OutgoingMessageReference -from toad.core.source_events import TranscriptCoverage -from agent_comms.acp_extension import InputStartedUpdate - from toad.widgets.presentation_window import protected_presentations if TYPE_CHECKING: - from toad.widgets.transcript_history import CommittedHistory, TranscriptHistory + from toad.transcript_preparation import PreparedTranscriptPage from toad.widgets.conversation import Conversation from toad.widgets.history_anchor import HistoryWindow - from toad.transcript_preparation import PreparedTranscriptPage - from toad.widgets.transcript_fragments import BusMessageFragment, SentMessageFragment, TranscriptFragment + from toad.widgets.transcript_fragments import ( + BusMessageFragment, + SentMessageFragment, + TranscriptFragment, + ) + from toad.widgets.transcript_history import CommittedHistory, TranscriptHistory @dataclass(frozen=True) class CommitEvidence: @@ -190,7 +194,7 @@ class PreparedSourceCommit(PreparedCommit, ABC): page: PreparedTranscriptPage @classmethod - async def capture(cls, view, history, page, sequences): + async def capture(cls, view: Conversation, history: CommittedHistory, page: TranscriptPage, sequences: frozenset[int]): from toad.transcript_preparation import TranscriptPageRenderTask from toad.work_preparation import RenderPreparation @@ -201,7 +205,7 @@ async def capture(cls, view, history, page, sequences): def outgoing(self) -> frozenset[OutgoingMessageReference]: return self.history.outgoing_coverage | self.page.outgoing_coverage - async def draw(self, cursor, is_current, live_blocks) -> bool: + async def draw(self, cursor, is_current: Callable[[], bool], live_blocks) -> bool: history = self.history async with history.window.history_lock: if not history.source_ready or not is_current(): @@ -214,7 +218,7 @@ async def publish(self, cursor: TranscriptCursor, is_current: Callable[[], bool] class PreparedAdvanceCommit(PreparedSourceCommit): - async def publish(self, cursor, is_current): + async def publish(self, cursor, is_current: Callable[[], bool]): return await self.history.advance_committed(cursor, is_current) @@ -222,7 +226,7 @@ async def publish(self, cursor, is_current): class PreparedPageCommit(PreparedSourceCommit): history: TranscriptHistory - async def publish(self, cursor, is_current): + async def publish(self, cursor, is_current: Callable[[], bool]): return await self.history.show_newest(PreparedReplacementViewportRequest( LatestPosition.hold(self.history.window), self.page), publication_current=is_current) @@ -271,15 +275,15 @@ def __init__(self, window: HistoryWindow) -> None: def current(self, window: HistoryWindow) -> bool: return window.follows_tail and window.scroll_revision == self.revision - async def extend(self, view, history, page, captured, is_current): + async def extend(self, view, history, page, captured, is_current: Callable[[], bool]): return await PreparedAdvanceCommit.capture(view, history, page, TranscriptCoverage(page.events).sequences | history.covered_sequences(required_sequences(captured))) - def finish(self, view, cursor): + def finish(self, view, cursor) -> None: view.transcript.binding.painted(view.transcript, cursor) view.call_after_refresh(self.restore_reader, view.window) - def restore_reader(self, window): + def restore_reader(self, window) -> None: # The publication's captured intent owns its deferred layout effect as # well. A later scroll or source return cannot inherit an older anchor. if self.current(window): @@ -291,7 +295,7 @@ def current(self, window: HistoryWindow) -> bool: # Retirement checks the current viewport again when applying the change. return not window.follows_tail - def permits(self, view, candidates): + def permits(self, view, candidates) -> bool: candidates = tuple(candidates) if not super().permits(view, candidates): return False @@ -306,14 +310,14 @@ def permits(self, view, candidates): return not any(widget in visible and visible[widget][0].overlaps(viewport) for widget in candidates) - async def extend(self, view, history, page, captured, is_current): + async def extend(self, view, history, page, captured, is_current: Callable[[], bool]): required = required_sequences(captured) known = TranscriptCoverage(page.events).sequences | history.covered_sequences(required) covered = frozenset(sequence for sequence in required if page.after.covers_incoming(sequence)) return PreparedCommit(history, known | covered) - def commit(self, prepared, cursor): + def commit(self, prepared, cursor) -> None: prepared.history.retain_committed(cursor) def commit_plan(window: HistoryWindow) -> CommitPlan: diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index fe5efcc76..c82ae81e1 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -11,8 +11,8 @@ from typing import TYPE_CHECKING, Callable from acp import schema as acp_protocol -from agent_comms.goal_presentation import GoalExecution, IdleGoalExecution from agent_comms.channel_targets import CommandTarget +from agent_comms.goal_presentation import GoalExecution, IdleGoalExecution from agent_comms.goals import NoGoal from agent_comms.mro_dispatch import MroDispatch, handles from agent_comms.thread_presentation import NoThreadPresentation, ThreadPresentation diff --git a/src/toad/widgets/history_anchor.py b/src/toad/widgets/history_anchor.py index 3e87137de..a0cc7846e 100644 --- a/src/toad/widgets/history_anchor.py +++ b/src/toad/widgets/history_anchor.py @@ -2,20 +2,28 @@ from __future__ import annotations +import asyncio from abc import ABC, abstractmethod +from collections.abc import Iterable +from contextlib import AsyncExitStack, asynccontextmanager, contextmanager from dataclasses import dataclass, field, replace from functools import cached_property -from contextlib import AsyncExitStack, asynccontextmanager, contextmanager -import asyncio from typing import TYPE_CHECKING from weakref import WeakSet -from textual.widget import Widget -from toad.widget_view import WorkspaceHosted from textual import events from textual.containers import VerticalScroll -from toad.widgets.presentation_window import DirectionalPreparation, PresentationBudget, OLDER_END, NEWER_END +from textual.geometry import Size +from textual.widget import Widget + from toad.transcript_state import LatestViewportRequest +from toad.widget_view import WorkspaceHosted +from toad.widgets.presentation_window import ( + NEWER_END, + OLDER_END, + DirectionalPreparation, + PresentationBudget, +) if TYPE_CHECKING: from toad.transcript_preparation import PageFragmentRange @@ -106,7 +114,7 @@ def preparation_start(self, history) -> tuple[int, int]: """Where preparation begins when no message supplies a more exact place.""" return 0, history.pages[0].prepared.start - async def prepare(self, history, current, target, selected, rows_needed, limit): + async def prepare(self, history, current, target, selected, rows_needed, limit: int): """Fill the reader's earlier rows, then the later rows in the remaining budget.""" start = self.preparation_start(history) anchor = history.pages[start[0]].prepared @@ -134,7 +142,7 @@ class TailPosition(ReaderPosition): def preparation_start(self, history) -> tuple[int, int]: return len(history.pages) - 1, len(history.pages[-1].prepared.fragments) - async def prepare(self, history, current, target, selected, rows_needed, limit): + async def prepare(self, history, current, target, selected, rows_needed, limit: int): return await OLDER_END.walk(history, current, target, selected, self.preparation_start(history), rows_needed, limit) @@ -262,7 +270,7 @@ class UnreachedPosition(ReaderPosition): position: ReaderPosition - async def prepare(self, history, current, target, selected, rows_needed, limit): + async def prepare(self, history, current, target, selected, rows_needed, limit: int): return await self.position.prepare(history, current, target, selected, rows_needed, limit) def bodies(self, window: "HistoryWindow") -> tuple[Widget, ...] | None: @@ -765,7 +773,7 @@ def watch_scroll_y(self, old_value: float, new_value: float) -> None: self.anchor(True) self.tail_follow_changed() - def _size_updated(self, size, virtual_size, container_size, layout=True) -> bool: + def _size_updated(self, size: Size, virtual_size: Size, container_size: Size, layout: bool=True) -> bool: # Native size commit owns scrollbar clamping. Compensate that movement # here, without treating unrelated Screen layouts as reader restoration. with self.layout_scroll(): @@ -794,7 +802,7 @@ def history_mutation_root(self) -> Widget | None: return None return self if self._history_mutation_root is None else self._history_mutation_root - def visible_history_items(self, items): + def visible_history_items(self, items: Iterable[Widget]): """Borrow this window's clipped cohort from the original native scene.""" compositor = self.screen._compositor visible = compositor.published_widgets diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index aef64c02d..97984ecc2 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -1,31 +1,49 @@ """Render-sized transcript fragments; wire records and cursors remain model-owned.""" from abc import ABC, abstractmethod +from collections.abc import Callable, Iterable, Iterator from dataclasses import dataclass, field, replace from functools import cached_property, partial -from collections.abc import Callable, Iterable, Iterator from typing import TYPE_CHECKING, ClassVar -from agent_comms.mro_dispatch import handles -from toad.core.projection import MroProjection from agent_comms.message_reference import MessageReference +from agent_comms.mro_dispatch import handles from agent_comms.sent_message import PublishedSentMessage -from agent_comms.transcript_sources import AssignedTranscriptSource from agent_comms.transcript_events import ( - AgentTextTranscript, ContextTranscript, IncomingTranscript, MarkdownTranscript, SentTranscript, - TextTranscript, ThinkingTranscript, ToolTranscript, TranscriptEvent, UserTranscript, + AgentTextTranscript, + ContextTranscript, + IncomingTranscript, + MarkdownTranscript, + SentTranscript, + TextTranscript, + ThinkingTranscript, + ToolTranscript, + TranscriptEvent, + UserTranscript, ) +from agent_comms.transcript_sources import AssignedTranscriptSource +from agent_comms.ui_model.message_category import AgentActivityBoundary, MessageCategory +from toad.acp.status import ToolCallStatus +from toad.core.projection import MroProjection from toad.line_blocks import ( - AgentRole, Body, MarkdownBody, Divider, Gap, LineBlock, LineRole, MutedRole, Summary, ToggleDetails, UserRole, + AgentRole, + Body, + Divider, + Gap, + LineBlock, + LineRole, + MarkdownBody, + MutedRole, + Summary, + ToggleDetails, + UserRole, ) +from toad.markdown_preparation import PreparedMarkdownPart +from toad.menus import MenuItem from toad.message_rows import MarkdownMessageStyle, MessageRow, WireMessageStyle from toad.notification_status import NotificationStatus, UncheckedNotifications -from toad.menus import MenuItem -from toad.markdown_preparation import PreparedMarkdownPart -from toad.acp.status import ToolCallStatus from toad.widgets.agent_activity import AgentActivityPresentation -from agent_comms.ui_model.message_category import AgentActivityBoundary, MessageCategory from toad.widgets.transcript_lines import AcquiredRows, NoRows, RowAcquisition if TYPE_CHECKING: @@ -100,7 +118,7 @@ def split(self, source: PreparedMarkdownPart) -> Iterator[PreparedMarkdownPart]: pending_token_stop = 0 pending_characters = 0 pending_lines = 0 - def select(first, stop, token_first, token_stop): + def select(first: int, stop: int, token_first: int, token_stop: int): return source.select_blocks(text[offsets[first]:offsets[stop]], first, slice(token_first, token_stop)) @@ -141,7 +159,7 @@ def select(first, stop, token_first, token_stop): class FragmentLineConsumer(MroProjection): """Turn a fragment's events into line blocks, one handler per event family.""" - def __init__(self, *, show_divider: bool): + def __init__(self, *, show_divider: bool) -> None: self.show_divider = show_divider self.blocks: list[LineBlock] = [] @@ -207,7 +225,7 @@ class PartPosition(ABC): def has_details(self) -> bool: ... @abstractmethod - def notification_references(self, event) -> tuple[MessageReference, ...]: ... + def notification_references(self, event: TranscriptEvent) -> tuple[MessageReference, ...]: ... @abstractmethod def status_blocks(self, shown: MessageShown) -> tuple[LineBlock, ...]: ... @@ -273,7 +291,7 @@ def notification_references(self) -> tuple[MessageReference, ...]: return () @classmethod - def text_parts(cls, source, budget): + def text_parts(cls, source: PreparedMarkdownPart, budget: RenderBudget): return tuple(budget.split(source)) @property @@ -281,7 +299,7 @@ def outgoing_sources(self): return () @classmethod - def capture_part(cls, event, text, **kwargs): + def capture_part(cls, event, text: str, **kwargs): return cls((replace(event, text=text),), **kwargs) def text_rows(self, lines) -> range: @@ -355,7 +373,7 @@ def retains_lines(self, lines, theme) -> bool: """ return self._lines.retains(lines, theme) or self._plain.retains(lines, theme) - def invalidate_lines(self): + def invalidate_lines(self) -> None: self._lines = NoRows() def hold_lines(self, request, lines) -> bool: @@ -408,7 +426,7 @@ class BusMessageFragment(TranscriptFragment): row: MessageRow @classmethod - def capture_part(cls, event, text, **kwargs): + def capture_part(cls, event, text: str, **kwargs): return cls((event,), text_part=text, **kwargs) @classmethod @@ -478,7 +496,7 @@ def blocks(self, shown): class TranscriptFragmentConsumer(MroProjection): - def __init__(self): + def __init__(self) -> None: self.fragments: list[TranscriptFragment | None] = [] self.tools: dict[str, tuple[int, list[ToolTranscript], bool]] = {} self.reported_messages: dict[int, SentTranscript] = {} @@ -486,27 +504,27 @@ def __init__(self): self.boundary = AgentActivityBoundary() @handles(ContextTranscript) - def context(self, event: ContextTranscript): + def context(self, event: ContextTranscript) -> None: # One lazy disclosure owns the full metadata source. self.fragments.append(ContextTranscriptFragment((event,))) @handles(IncomingTranscript) - def incoming(self, event: IncomingTranscript): + def incoming(self, event: IncomingTranscript) -> None: self.text(event, BusMessageFragment, row=MessageRow(event.message, event.reader)) @handles(SentTranscript) - def sent(self, event: SentTranscript): + def sent(self, event: SentTranscript) -> None: self.reported_messages[len(self.fragments)] = event self.fragments.append(TranscriptFragment((event,))) @handles(PublishedSentMessage) - def published(self, message: PublishedSentMessage): + def published(self, message: PublishedSentMessage) -> None: original = message.message.core_message(AssignedTranscriptSource.open(message.scope)) self.text(message.message, SentMessageFragment, publication=message, row=MessageRow(original, message.scope.recipient.name)) @handles(UserTranscript, MarkdownTranscript) - def text(self, event: TextTranscript, kind: type[TranscriptFragment] = TranscriptFragment, **kwargs): + def text(self, event: TextTranscript, kind: type[TranscriptFragment] = TranscriptFragment, **kwargs) -> None: starts_activity = self.boundary.observe(event.category) if event.starts_activity else False source = PreparedMarkdownPart.capture(event.text) parts = kind.text_parts(source, self.budget) @@ -518,7 +536,7 @@ def text(self, event: TextTranscript, kind: type[TranscriptFragment] = Transcrip ) @handles(ToolTranscript) - def tool(self, event: ToolTranscript): + def tool(self, event: ToolTranscript) -> None: if event.tool_call_id not in self.tools: self.tools[event.tool_call_id] = ( len(self.fragments), [], self.boundary.observe(event.category), diff --git a/src/toad/widgets/transcript_history.py b/src/toad/widgets/transcript_history.py index d759934ce..146ab9092 100644 --- a/src/toad/widgets/transcript_history.py +++ b/src/toad/widgets/transcript_history.py @@ -6,7 +6,7 @@ from bisect import bisect_right from collections import deque from collections.abc import AsyncIterator, Callable, Iterator -from contextlib import AsyncExitStack, ExitStack, asynccontextmanager +from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass, field, replace from functools import cached_property, partial from itertools import chain, islice @@ -20,19 +20,17 @@ from agent_comms.transcripts import TranscriptCursor, TranscriptPage from agent_comms.ui_model.message_category import ( MessageCategory, - OtherCategory, all_categories, ) from rich.style import Style as RichStyle -from textual import events, on +from textual import on from textual._activation import PrimaryActivation from textual._measurement import INDEPENDENT_HEIGHT, height_dependency from textual._paint_state import PaintState from textual.app import ComposeResult from textual.containers import VerticalGroup -from textual.geometry import Region +from textual.geometry import Region, Size from textual.message import Message -from textual.selection import Selection from textual.strip import Strip from textual.widget import Widget from textual.widgets import Static @@ -42,7 +40,6 @@ from toad.core.source_events import TranscriptCoverage from toad.line_blocks import ToggleDetails from toad.message_rows import MessageClock, MessageRow -from toad.response_delivery import ResponseDelivery from toad.session_scope import SessionScope from toad.transcript_filter import FilterState from toad.transcript_preparation import ( @@ -73,7 +70,6 @@ SnapshotPresentation, ) from toad.widgets.history_anchor import ( - HeldReaderDestination, HistoryWindow, LatestPosition, ) @@ -94,14 +90,12 @@ from toad.widgets.transcript_fragments import ( Shown, TranscriptFragment, - transcript_fragments, ) from toad.widgets.transcript_lines import ( BlockLines, BlockSpan, LinePreparation, Lines, - PreparedLines, TranscriptLinesBatchRenderTask, TranscriptLinesRenderTask, ) @@ -161,7 +155,7 @@ class TranscriptPageView(ReferenceRendering, HorizontalRows, Widget): RENDER_BATCH = 8 def __init__(self, prepared: PreparedTranscriptPage, *, - batch_size: int = PresentationBudget.batch_items): + batch_size: int = PresentationBudget.batch_items) -> None: self.arrivals: set[int] = set() self.prepared = prepared self.visible_categories = all_categories() @@ -372,7 +366,7 @@ def fragment_at(self, row: int) -> FragmentSpan | None: span = lines.block_span(lambda span: span.contains(row - first)) return None if span is None else FragmentSpan.in_page(fragment, first, span) - def visible_rows(self, window) -> Iterator[tuple[TranscriptFragment, range, Lines]]: + def visible_rows(self, window: HistoryWindow) -> Iterator[tuple[TranscriptFragment, range, Lines]]: """Each loaded fragment in the window's viewport, with its rows that are in view.""" # A page the viewport layout did not place is off screen; computing # its offset would rebuild the compositor's whole map. @@ -389,7 +383,7 @@ def visible_rows(self, window) -> Iterator[tuple[TranscriptFragment, range, Line if first + lines.height > top: yield fragment, range(max(0, top - first), min(lines.height, bottom - first)), lines - def visible_fragments(self, window) -> tuple[TranscriptFragment, ...]: + def visible_fragments(self, window: HistoryWindow) -> tuple[TranscriptFragment, ...]: """The fragments this page draws inside the window's viewport.""" return tuple(fragment for fragment, _rows, _lines in self.visible_rows(window)) @@ -497,7 +491,7 @@ def block_cursor(self): def __init__(self, prepared: PreparedTranscriptPage, page_source: PreparedPageSource, *, notification_view: HistoryNotifications, budget: PresentationBudget = DEFAULT_BUDGET, - source_state: TranscriptState | None = None): + source_state: TranscriptState | None = None) -> None: self.notification_view = notification_view super().__init__(source_state=source_state or LiveTranscript(), page_source=page_source) self.budget = budget @@ -862,7 +856,7 @@ def drawn_extent(self) -> tuple[int, int]: pages = [page for page in self.pages if page.line_count] return sum(page.line_count for page in pages), sum(page.prepared.stop - page.prepared.start for page in pages) - def _size_updated(self, size, virtual_size, container_size, layout=True) -> bool: + def _size_updated(self, size: Size, virtual_size: Size, container_size: Size, layout: bool=True) -> bool: # Edges are read from this history's laid-out box. A check made # before layout gave it one (a filter just chosen, a first page) finds # none, and a history shorter than its window changes no size but its own. @@ -1143,7 +1137,7 @@ class ConversationHistory[ViewT: "Conversation"](LineHistory): page_source: ReplaceablePageSource - def __init__(self, *args, view: ViewT, **kwargs): + def __init__(self, *args, view: ViewT, **kwargs) -> None: self.view = view super().__init__(*args, **kwargs) diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index 965d9b664..59a3f9f51 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -6,42 +6,53 @@ from __future__ import annotations +import io from abc import ABC, abstractmethod from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping from dataclasses import dataclass, field, replace from functools import cached_property -import io from itertools import chain, count, zip_longest from typing import TYPE_CHECKING +from toad.patch import PatchCell + if TYPE_CHECKING: from toad.textual_colours import PaletteState, TextualColours +from agent_comms.mro_dispatch import MroDispatch, handles +from rich._wrap import divide_line from rich.cells import cell_len, set_cell_size from rich.columns import Columns from rich.console import Console from rich.containers import Renderables -from rich.syntax import Syntax -from rich.text import Text -from rich._wrap import divide_line +from rich.padding import Padding from rich.segment import Segment from rich.style import Style -from rich.padding import Padding +from rich.syntax import Syntax +from rich.text import Text from textual.strip import Strip -from toad.rich_preparation import RichTextBody - -from agent_comms.mro_dispatch import MroDispatch, handles from ui_theme.scheme import ForegroundColour, MutedColour, ThemeColour + from toad.line_blocks import ( - Body, Divider, Gap, LineBlock, LineLink, LineRole, Span, Spans, SpanColumns, - Summary, MutedRole, TextRole, + Body, + Divider, + Gap, + LineBlock, + LineLink, + LineRole, + MutedRole, + Span, + SpanColumns, + Spans, + Summary, + TextRole, ) -from toad.message_rows import MessageClock, WireBody from toad.markdown_links import LinkContext from toad.markdown_preparation import MarkdownStyleConsole -from toad.render_backend import ReusableRenderTask +from toad.message_rows import MessageClock, WireBody from toad.patch import DiffSettings, Patch -from toad.rich_preparation import SyntaxPalette +from toad.render_backend import ReusableRenderTask +from toad.rich_preparation import RichTextBody, SyntaxPalette Row = tuple[Segment, ...] @@ -161,7 +172,7 @@ def _plain_spans(line: str, width: int) -> range: class PatchLayout: """Diff geometry used by the native row painter.""" - def __init__(self, patch: Patch, width: int, settings: DiffSettings): + def __init__(self, patch: Patch, width: int, settings: DiffSettings) -> None: self.patch, self.settings = patch, settings self.number_width = patch.number_width longest = max((cell_len(line.expandtabs(4)) for line in (*patch.before.values(), *patch.after.values())), default=0) @@ -284,7 +295,7 @@ class SpanColumnLayout(Columns, PreparedBlock): links remain clickable even before styled worker output arrives. """ - def __init__(self, block: SpanColumns, theme: LineTheme | None = None): + def __init__(self, block: SpanColumns, theme: LineTheme | None = None) -> None: self.indent = block.indent cells = [Text.assemble(*((span.text, (theme.style(span.role) if theme is not None else Style.null()) + link_style(span.link)) for span in cell)) for cell in block.cells] @@ -314,7 +325,7 @@ class StyledPatch(Patch, PreparedBlock): class RichLineRenderer(MroDispatch): """Paint each primitive through its most-specific declared renderer.""" - def __init__(self, theme: LineTheme, width: int = 1): + def __init__(self, theme: LineTheme, width: int = 1) -> None: self.theme, self.width = theme, max(1, width) self.output: list[Row] = [] @@ -344,7 +355,7 @@ def patch(self, patch: Patch) -> None: number_width, split = layout.number_width, layout.split pane_width, prefix_width, code_width = layout.pane_width, layout.prefix_width, layout.code_width console = self.console - def paint(cell, source, old, new): + def paint(cell: PatchCell | None, source: dict[int, Text], old: str, new: str): if cell is None: return [Text(" " * pane_width)] style = self.theme.style(cell.role) @@ -507,7 +518,7 @@ def contains(self, row: int) -> bool: class BlockLines(Lines): """A viewport retains immutable references to block-owned row snapshots.""" - def __init__(self, width: int, preparation: LinePreparation, renderer: RichLineRenderer): + def __init__(self, width: int, preparation: LinePreparation, renderer: RichLineRenderer) -> None: self.width, self.preparation, self.renderer = width, preparation, renderer def rewrap(self, width: int) -> BlockLines: @@ -539,7 +550,7 @@ def __iter__(self) -> Iterator[Row]: class PlainLines(BlockLines): """Unstyled source measurement before its first worker result arrives.""" - def __init__(self, width: int, renderer: RichLineRenderer, blocks: tuple[LineBlock, ...]): + def __init__(self, width: int, renderer: RichLineRenderer, blocks: tuple[LineBlock, ...]) -> None: super().__init__(width, LinePreparation(blocks, renderer.theme), renderer) class RowAcquisition(ABC): @@ -567,10 +578,10 @@ class NoRows(RowAcquisition): def preparation(self) -> NoRows: return self - def for_view(self, width, theme): + def for_view(self, width: int, theme): return None - def retains(self, lines, theme): + def retains(self, lines, theme) -> bool: return False @@ -583,16 +594,16 @@ class AcquiredRows(RowAcquisition): def preparation(self) -> LinePreparation: return self.lines.preparation - def for_view(self, width, theme): + def for_view(self, width: int, theme): if self.source.theme is not theme: return None self.lines = self.lines.rewrap(width) return self.lines - def retains(self, lines, theme): + def retains(self, lines, theme) -> bool: return self.source.theme is theme and self.lines.preparation is lines.preparation - def plain(self, width, renderer, blocks): + def plain(self, width: int, renderer, blocks): if self.source.theme is renderer.theme: self.lines = self.lines.rewrap(width) return self @@ -600,7 +611,7 @@ def plain(self, width, renderer, blocks): class PlainRows(AcquiredRows): - def retains(self, lines, theme): + def retains(self, lines, theme) -> bool: return self.source.theme is theme and self.lines.preparation.blocks is lines.preparation.blocks @@ -672,7 +683,7 @@ def spans(self, block: Spans) -> StyledBody: @handles(Patch) def patch(self, block: Patch) -> StyledPatch: - def highlight(lines, path): + def highlight(lines: dict[int, str], path: str): code = "\n".join(lines.values()).expandtabs(4) syntax = Syntax(code, Syntax.guess_lexer(path, code), theme=self.preparation.theme.syntax.pygments_theme, background_color="default") diff --git a/tests/textual/test_agent_binding.py b/tests/textual/test_agent_binding.py index db2612d1a..9d300beec 100644 --- a/tests/textual/test_agent_binding.py +++ b/tests/textual/test_agent_binding.py @@ -1,5 +1,8 @@ """Each launch binding keeps matching with its existing native view.""" +import click +import pytest + from support.agents import peer from toad.agents import read_agents @@ -14,11 +17,12 @@ async def test_startup_binding_is_decoded_once_and_retained_by_the_app(workspace selected = AgentAttached(definition) for spelling, expected in ( ("", NoAgent()), - ("no-such-startup-agent", NoAgent()), (definition.identity.upper(), selected), (definition.short_name.upper(), selected), ): assert await get_agent_binding(spelling) == expected + with pytest.raises(click.BadParameter, match="no-such-startup-agent"): + await get_agent_binding("no-such-startup-agent") async with workspace.app(binding=selected, session=False) as ui: app, _ = ui.textual assert app.agent_binding is selected