diff --git a/src/toad/acp/session_updates.py b/src/toad/acp/session_updates.py index 9e05ea822..014eb11f2 100644 --- a/src/toad/acp/session_updates.py +++ b/src/toad/acp/session_updates.py @@ -1,118 +1,138 @@ """Process ACP notifications in order; update declarations own their effects.""" + from __future__ import annotations import asyncio from abc import abstractmethod +from typing import TYPE_CHECKING + +from acp.schema import ( + AgentMessageChunk, + AgentPlanUpdate, + AgentThoughtChunk, + AvailableCommandsUpdate, + ConfigOptionUpdate, + CurrentModeUpdate, + SessionInfoUpdate, + SessionNotification, + TextContentBlock, + ToolCallProgress, + ToolCallStart, + UsageUpdate, + UserMessageChunk, +) +from agent_comms.acp_extension import AgentCommsUpdate +from agent_comms.mro_dispatch import MroDispatch, handles -from agent_comms.declared_family import DeclaredFamily - -from toad.core import events from toad.acp.client_session import ClientRequestOwner, ClientSessionRequest from toad.acp.context_measurement import ContextMeasurement from toad.acp.sdk_boundary import ValidateSessionUpdateTask -from agent_comms.mro_dispatch import MroDispatch, handles -from acp.schema import (UserMessageChunk, AgentMessageChunk, AgentThoughtChunk, - ToolCallStart, ToolCallProgress, AgentPlanUpdate, AvailableCommandsUpdate, - CurrentModeUpdate, ConfigOptionUpdate, SessionInfoUpdate, UsageUpdate, TextContentBlock) +from toad.core import events from toad.plan import decode_plan from toad.project_link import AcpLogLink +if TYPE_CHECKING: + from toad.acp.agent import Agent + from toad.acp.comms_updates import CommsUpdateConsumer + class SessionUpdateEffect(MroDispatch): """Effect registrations consume official SDK declarations, never raw shapes.""" - def __init__(self, agent, consumer): + + def __init__(self, agent: Agent, consumer: CommsUpdateConsumer): self.agent, self.consumer = agent, consumer @property def route(self): return self.consumer.route - def require_supported(self, update): - if not any(self.handlers_for(update)): - raise ValueError(f'ACP update capability is not supported: {type(update).__name__}') + def require_supported(self, notification: SessionNotification): + if not any(self.handlers_for(notification.update)): + raise ValueError(f"ACP update capability is not supported: {type(notification.update).__name__}") @handles(UserMessageChunk) - def user(self, update): + def user(self, update: UserMessageChunk): UserContentEffect(self.agent, self.route).dispatch_sync(update.content) @handles(AgentMessageChunk) - def message(self, update): + def message(self, update: AgentMessageChunk): AgentContentEffect(self.agent, self.route).dispatch_sync(update.content) @handles(AgentThoughtChunk) - def thought(self, update): + def thought(self, update: AgentThoughtChunk): ThoughtContentEffect(self.agent, self.route).dispatch_sync(update.content) @handles(ToolCallStart) - def tool_start(self, update): + def tool_start(self, update: ToolCallStart): self.agent.tools.begin(update) @handles(ToolCallProgress) - def tool_progress(self, update): + def tool_progress(self, update: ToolCallProgress): self.agent.tools.update(update) @handles(AgentPlanUpdate) - def plan(self, update): + def plan(self, update: AgentPlanUpdate): self.agent.controller.publish_plan(decode_plan(update.entries)) @handles(AvailableCommandsUpdate) - def commands(self, update): + def commands(self, update: AvailableCommandsUpdate): self.agent.controller.publish_commands(update.available_commands) @handles(CurrentModeUpdate) - def mode(self, update): + def mode(self, update: CurrentModeUpdate): self.agent.configuration.receive_current_mode(update.current_mode_id) @handles(ConfigOptionUpdate) - def config(self, update): + def config(self, update: ConfigOptionUpdate): self.agent.configuration.receive(update.config_options) @handles(SessionInfoUpdate) - def info(self, update): - if 'title' in update.model_fields_set: + def info(self, update: SessionInfoUpdate): + if "title" in update.model_fields_set: self.agent.events.publish(events.SessionInfoUpdate(update.title)) @handles(UsageUpdate) - def usage(self, update): + def usage(self, update: UsageUpdate): self.agent.context_measurement = ContextMeasurement.live(update.used, update.size, update.cost) self.agent.update_status_line() class MessageContentEffect(MroDispatch): - def __init__(self, agent, route): + def __init__(self, agent: Agent, route): self.agent, self.route = agent, route @handles(TextContentBlock) - def text(self, content): + def text(self, content: TextContentBlock): if content.text: self.publish(content) @abstractmethod - def publish(self, content): ... + def publish(self, content: TextContentBlock): ... class UserContentEffect(MessageContentEffect): - def publish(self, content): + def publish(self, content: TextContentBlock): self.agent.events.publish(events.UserMessage(content.type, content.text)) class ThoughtContentEffect(MessageContentEffect): - def publish(self, content): + def publish(self, content: TextContentBlock): self.agent.events.publish(events.Thinking(content.type, content.text)) class AgentContentEffect(MessageContentEffect): - def publish(self, content): + def publish(self, content: TextContentBlock): text = content.text - if text.startswith('[agent error]'): - text += f'\n\n{AcpLogLink.markdown(self.agent.presentation.log_path)}' + if text.startswith("[agent error]"): + text += f"\n\n{AcpLogLink.markdown(self.agent.presentation.log_path)}" from toad.response_delivery import ResponseDelivery + stream = self.agent.presentation.turns.owner.response_stream(ResponseDelivery.from_route(self.route)) self.agent.events.publish(events.Update(content.type, text, stream)) class SessionNotificationOwner(ClientRequestOwner): - def __init__(self, agent): + def __init__(self, agent: Agent): super().__init__(agent) self.lock = asyncio.Lock() @@ -122,23 +142,25 @@ async def receive(self, notification, raw_update): if authority.retired or not authority.binding.accepts_notification(notification.session_id): return validation = await self.agent.controller.task_runner.run( - ValidateSessionUpdateTask(notification, raw_update)) + ValidateSessionUpdateTask(notification, raw_update) + ) if authority.retired: return validation.publish(self, notification.session_id, raw_update, notification.field_meta) - def reject(self, session_id, update, metadata, error): + def reject(self, session_id: str, update, metadata, error: str): self.agent.log( f"[ACP rejected session/update] raw={{'sessionId': {session_id!r}, " - f"'update': {update!r}, '_meta': {metadata!r}}}; validation={error}") + f"'update': {update!r}, '_meta': {metadata!r}}}; validation={error}" + ) self.agent.events.publish(events.RejectedSessionUpdate()) - def publish(self, session_id, update, facts): + def publish(self, session_id: str, update: SessionNotification, facts: tuple[AgentCommsUpdate, ...]): metadata = update.update.field_meta consumer = self.agent.comms_consumer_class(self.agent, session_id) effect = SessionUpdateEffect(self.agent, consumer) try: - effect.require_supported(update.update) + effect.require_supported(update) except (TypeError, ValueError) as error: self.reject(session_id, update, metadata, str(error)) return diff --git a/src/toad/agent.py b/src/toad/agent.py index 43bf05a7b..153e411f1 100644 --- a/src/toad/agent.py +++ b/src/toad/agent.py @@ -1,5 +1,6 @@ from abc import abstractmethod from pathlib import Path +from typing import TYPE_CHECKING from agent_comms.thread_presentation import ThreadPresentation from textual.content import Content @@ -11,6 +12,15 @@ from toad.core.events import CoreEventStream from toad.surface_binding import SurfaceBinding +if TYPE_CHECKING: + from agent_comms.session_load import ExistingSessionLoadPlan + + from toad.conversation_turn import TurnOwner + from toad.screens.main import MainScreen + from toad.session_observation import SessionObservation + from toad.session_presentation import OperationalSessionSources + from toad.widgets.conversation import Conversation, ConversationSessionBinding + class AgentBase(AgentConnection): """Base class for an 'agent'.""" @@ -35,7 +45,7 @@ def attach_view(self, view) -> None: def matches_agent(self, agent) -> bool: return self is agent - def cancel_allowed(self, turn): + def cancel_allowed(self, turn: TurnOwner): return turn.can_cancel def matches_session(self, session_id): @@ -44,14 +54,16 @@ def matches_session(self, session_id): def borrowed_processes(self): return (self.process,) - async def reconnect_session(self, source, plan) -> None: + async def reconnect_session( + self, source: MainScreen, plan: ExistingSessionLoadPlan + ) -> None: await self.session.reconnect(plan) - async def change_project(self, view, path) -> None: + async def change_project(self, view: Conversation, path: Path) -> None: project = await self.update_project(str(path)) view.feedback.flash(Content(f"Project changed to {project}"), style="success") - def request_compaction(self, view, instructions) -> None: + def request_compaction(self, view: Conversation, instructions) -> None: view.compact_context(instructions) def begin_submission(self, owner, submission): @@ -69,49 +81,51 @@ def accepts_failed_input(self, message): def send_now(self, owner): return owner.send_bound_now(self) - def invalidate_observation(self, observation) -> None: + def invalidate_observation(self, observation: SessionObservation) -> None: observation.schedule(self) @property def automatic_titles(self) -> bool: return self.observed_thread() is None - async def project_changed(self, path) -> None: + async def project_changed(self, path: Path) -> None: from toad.db import DB self.project_root_path = path if (session_pk := self.session.pk) is not None: await DB().session_update_project(session_pk, path) - def refresh_context(self, view) -> None: + def refresh_context(self, view: Conversation) -> None: view.status = self.context_measurement.status() async def compact_context(self, instructions) -> None: await self.controller.compact_context(instructions) - async def cancel_turn(self, view) -> None: + async def cancel_turn(self, view: Conversation) -> None: await view.cancel_bound_turn(self) def accepts_message(self, message) -> bool: return message.publisher is self and message.event.session_id == self.session_id - def refresh_projection(self, view) -> None: + def refresh_projection(self, view: ConversationSessionBinding) -> None: view.status = self.context_measurement.status() view.turns.bound() view.submissions.publish_pending() view.delivery_observation.invalidate() - def start_session(self, view) -> None: + def start_session(self, view: ConversationSessionBinding) -> None: if self.ready: view.session_ready() - def start_agent(self, sources, view) -> None: + def start_agent( + self, sources: OperationalSessionSources, view: Conversation + ) -> None: """A connected agent is not created a second time.""" - def update_view(self, view) -> None: + def update_view(self, view: ConversationSessionBinding) -> None: view.apply_bound_agent(self) - def connected(self, view) -> None: + def connected(self, view: ConversationSessionBinding) -> None: view.agent_connected(self) @property @@ -125,7 +139,7 @@ def current_turn(self): def attach_surface(self, binding: SurfaceBinding) -> None: binding.close() - async def retire_surface(self, surface) -> None: + async def retire_surface(self, surface: Conversation) -> None: await self.stop() def observed_thread(self) -> tuple[str, str] | None: diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 26de6263d..ee3c3150b 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -4,15 +4,24 @@ from abc import abstractmethod from functools import cached_property -from typing import ClassVar, cast +from typing import TYPE_CHECKING, ClassVar, cast + from agent_comms.declared_family import DeclaredFamily +from agent_comms.ui_model.message_category import MessageCategory, OtherCategory from textual.geometry import Region from textual.widget import Widget +from ui_theme.effects import NEW_TEXT, Fade, Mix # type: ignore[import-untyped] + from toad.block_content import BlockContent from toad.frame_presentation import AnimationEffects -from ui_theme.effects import NEW_TEXT, Fade, Mix # type: ignore[import-untyped] -from toad.widgets.presentation_window import PageEnd, OlderEnd, NewerEnd -from agent_comms.ui_model.message_category import MessageCategory, OtherCategory +from toad.widgets.presentation_window import NewerEnd, OlderEnd, PageEnd + +if TYPE_CHECKING: + from agent_comms.ui_model.message_category import AgentActivityBoundary + from textual.screen import Screen + + from toad.widgets.history_anchor import HistoryWindow + from toad.widgets.transcript_history import LineHistory class CursorDirection(PageEnd, DeclaredFamily, affix="Cursor"): @@ -50,7 +59,7 @@ def select(self, widget: Widget) -> bool: ... def active(self) -> BlockCursor | None: return self if self.selected is not None else None - def accepts(self, owner) -> bool: + def accepts(self, owner: Widget) -> bool: return owner is self.selected def clear(self) -> None: @@ -69,7 +78,7 @@ def get_block_menu(self): def allow_maximize(self) -> bool: return False - def maximize(self, screen) -> None: + def maximize(self, screen: Screen) -> None: pass @property @@ -83,10 +92,10 @@ def owns_source(self, source) -> bool: def visible_region(self) -> Region | None: return None - def show_moved_to(self, window) -> None: + def show_moved_to(self, window: HistoryWindow) -> None: """Scroll for a keyboard move, which can land on a block out of view.""" - def show_clicked(self, window) -> None: + def show_clicked(self, window: HistoryWindow) -> None: """Scroll for a click, which lands on a block that is already in view.""" def export_render(self): @@ -98,13 +107,13 @@ class AtomicBlockCursor(BlockCursor): def selected(self): return self.block - def enter(self, direction): + def enter(self, direction: CursorDirection): return True - def move(self, direction): + def move(self, direction: CursorDirection): return False - def select(self, widget): + def select(self, widget: Widget): return widget is self.block or self.block in widget.ancestors def get_clipboard_text(self): @@ -120,7 +129,7 @@ def get_block_menu(self): def allow_maximize(self): return self.block.allow_maximize - def maximize(self, screen): + def maximize(self, screen: Screen): screen.maximize(self.block, container=False) self.block.focus() @@ -133,10 +142,10 @@ def visible_region(self): geometry = self.block.screen._compositor.visible_widgets.get(self.block) return None if geometry is None else geometry[0] - def show_moved_to(self, window): + def show_moved_to(self, window: HistoryWindow): window.scroll_to_center(self.block, immediate=True) - def show_clicked(self, window): + def show_clicked(self, window: HistoryWindow): # The clicked block stays where it is. Only a block taller than the # viewport whose top is scrolled off moves, so that its top shows. block, viewport = self.block.region, window.content_region @@ -158,7 +167,7 @@ class FragmentBlockCursor(BlockCursor): message (its fragment) and asks the history where its rows are. """ - def __init__(self, block): + def __init__(self, block: LineHistory): super().__init__(block) self.fragment = None @@ -176,7 +185,7 @@ def current(self): def selected(self): return self.block if self.current is not None else None - def enter(self, direction): + def enter(self, direction: CursorDirection): # The cursor arrives at the reader's side of the screen, not at an # end of the loaded history the reader may be far from. fragments = self.block.visible_fragments() or self.block.drawn_fragments @@ -185,7 +194,7 @@ def enter(self, direction): self.fragment = fragments[direction.entry(len(fragments))] return True - def move(self, direction): + def move(self, direction: CursorDirection): fragments = self.block.drawn_fragments current = self.current if current is None or all( @@ -204,7 +213,7 @@ def move(self, direction): # scrolling to it loads the next page. return direction.visible_more(self.block) - def select(self, widget): + def select(self, widget: Widget): fragment = self.block.fragment_under_mouse(widget) if fragment is None: return False @@ -229,7 +238,7 @@ def region(self): visible_region = region - def show_moved_to(self, window): + def show_moved_to(self, window: HistoryWindow): if (fragment := self.current) is not None: self.block.show_fragment(fragment) @@ -252,7 +261,7 @@ def selected(self): cursor = self.active return None if cursor is None else cursor.selected - def _enter_from(self, index, direction): + def _enter_from(self, index: int, direction: CursorDirection): children = self.block.displayed_children while 0 <= index < len(children): self._child = children[index] @@ -264,12 +273,12 @@ def _enter_from(self, index, direction): self._child = None return False - def enter(self, direction): + def enter(self, direction: CursorDirection): return self._enter_from( direction.entry(len(self.block.displayed_children)), direction ) - def move(self, direction): + def move(self, direction: CursorDirection): child = self.current if child is None: return False @@ -278,7 +287,7 @@ def move(self, direction): index = self.block.displayed_children.index(child) + direction.step return self._enter_from(index, direction) - def select(self, widget): + def select(self, widget: Widget): for child in self.block.displayed_children: if child.block_cursor.select(widget): self._child = child @@ -303,7 +312,7 @@ class ConversationEntry(AnimationEffects[Mix], BlockContent): def apply_message_filter(self, selected: frozenset[type[MessageCategory]]) -> None: self.set_display_constraint("message-category", OtherCategory in selected) - def starts_agent_activity(self, boundary) -> bool: + def starts_agent_activity(self, boundary: AgentActivityBoundary) -> bool: return OtherCategory.observe(boundary) def repaint(self) -> None: @@ -331,7 +340,7 @@ def apply_message_filter(self, selected: frozenset[type[MessageCategory]]) -> No "message-category", self.message_category in selected ) - def starts_agent_activity(self, boundary) -> bool: + def starts_agent_activity(self, boundary: AgentActivityBoundary) -> bool: return self.message_category.observe(boundary) @@ -407,7 +416,7 @@ def select(self, widget: Widget) -> bool: return False -def require_blocks(widgets): +def require_blocks(widgets: tuple[Widget, ...]): """Decode the Textual mount boundary once before category consumers.""" for widget in widgets: if not isinstance(widget, ConversationEntry): diff --git a/src/toad/conversation_turn.py b/src/toad/conversation_turn.py index 07ea47e1b..a1a88f9bf 100644 --- a/src/toad/conversation_turn.py +++ b/src/toad/conversation_turn.py @@ -1,12 +1,26 @@ """Local ACP turns and source-fenced projections of the backend's turn owner.""" + +from __future__ import annotations + from abc import abstractmethod +from collections.abc import Callable from dataclasses import dataclass, replace +from typing import TYPE_CHECKING from agent_comms.activity import ActivityState from agent_comms.declared_family import DeclaredFamily -from agent_comms.turn_lease import NoTurn as IdleTurn, TurnState +from agent_comms.turn_lease import NoTurn as IdleTurn +from agent_comms.turn_lease import TurnState from agent_comms.ui_model.status import ThreadStatusModel +if TYPE_CHECKING: + from textual.widget import Widget + + from toad.acp.agent import Agent + from toad.core_event_carrier import CoreEventMessage + from toad.response_delivery import ResponseDelivery + from toad.widgets.conversation import Conversation + class TurnOwner(DeclaredFamily, affix="Turn"): managed_id = None @@ -49,19 +63,22 @@ def accepts_update(self, update) -> bool: def matches_settlement(self, turn_id) -> bool: return not turn_id - def captured_snapshot(self, widgets): + def captured_snapshot(self, widgets: tuple[Widget, ...]): """Each original claim owns capture while this source is active.""" from toad.widgets.committed_presentation import CommitParticipant - return frozenset(widget for widget in widgets - if not isinstance(widget, CommitParticipant) - or widget.commit_claim.capture(not self.busy)) + return frozenset( + widget + for widget in widgets + if not isinstance(widget, CommitParticipant) or widget.commit_claim.capture(not self.busy) + ) def with_activity(self, activity: str) -> "TurnOwner": return ActivityTurn(activity) if activity else self - def response_stream(self, delivery): + def response_stream(self, delivery: ResponseDelivery): from toad.live_output import CompleteResponseStream + return CompleteResponseStream(delivery) @@ -88,6 +105,7 @@ def with_activity(self, activity: str) -> TurnOwner: @dataclass(frozen=True) class AgentTurn(TurnOwner): """A generic ACP peer owns its local request, but cannot control Comms turns.""" + managed_id: str | None = None activity: str = "Thinking…" started_at: float | None = None @@ -100,14 +118,16 @@ def matches_settlement(self, turn_id) -> bool: def with_activity(self, activity: str) -> TurnOwner: return replace(self, activity=activity or "Thinking…") - def response_stream(self, delivery): + def response_stream(self, delivery: ResponseDelivery): from toad.live_output import ResponseStream + return ResponseStream(delivery, turn_id=self.managed_id) @dataclass(frozen=True) class ManagedTurn(TurnOwner): """The published lease owns phase, permissions, activity and identity.""" + state: TurnState = TurnState() @property @@ -152,13 +172,14 @@ def accepts_update(self, update) -> bool: def matches_settlement(self, turn_id): return self.state.matches(turn_id) - def with_activity(self, activity): + def with_activity(self, activity: str): # Text, tool and heartbeat rendering cannot change backend phase. return self - def response_stream(self, delivery): + def response_stream(self, delivery: ResponseDelivery): if self.busy: from toad.live_output import ResponseStream + return ResponseStream(delivery, turn_id=self.managed_id) return super().response_stream(delivery) @@ -175,7 +196,7 @@ def owner(self) -> TurnOwner: ... def status_row(self, observed: ThreadStatusModel) -> ThreadStatusModel: ... @abstractmethod - def accepts(self, message) -> bool: ... + def accepts(self, message: CoreEventMessage) -> bool: ... @abstractmethod def describe(self, activity: str) -> None: ... @@ -183,7 +204,7 @@ def describe(self, activity: str) -> None: ... @abstractmethod def reset(self) -> None: ... - async def present_input(self, view, text) -> None: + async def present_input(self, view: Conversation, text: str) -> None: """Display managed inputs only when the native process reports they started.""" def start_client(self) -> None: @@ -201,22 +222,23 @@ def __init__(self, agent=None): def owner(self): return self._owner - def status_row(self, observed): + def status_row(self, observed: ThreadStatusModel): owner = self.owner return ThreadStatusModel.known("", owner.activity_state.presentation("", owner.activity)) - def accepts(self, message): + def accepts(self, message: CoreEventMessage): """A local turn cannot accept a backend-owned managed publication.""" return False - def describe(self, activity): + def describe(self, activity: str): self._owner = self._owner.with_activity(activity) def reset(self): self._owner = NoTurn() - async def present_input(self, view, text): + async def present_input(self, view: Conversation, text: str): from toad.widgets.user_input import UserInput + await view.post(UserInput(text)) def start_client(self): @@ -229,7 +251,7 @@ def finish_client(self): class ManagedTurnBinding(TurnBinding): managed = True - def __init__(self, agent): + def __init__(self, agent: Agent): self.agent = agent self._owner = ManagedTurn() self.sequence = 0 @@ -238,7 +260,7 @@ def __init__(self, agent): def owner(self): return self._owner - def status_row(self, observed): + def status_row(self, observed: ThreadStatusModel): return observed def accepts(self, message): @@ -255,7 +277,7 @@ def receive(self, state: TurnState): self.sequence += 1 return True - def describe(self, activity): + def describe(self, activity: str): """Only the backend publication changes a managed phase.""" def reset(self): @@ -265,7 +287,8 @@ def reset(self): class ConversationTurn: """Read the actual source binding; the widget keeps no managed state or cursor.""" - def __init__(self, changed, source): + + def __init__(self, changed: Callable[[TurnOwner], None], source: Callable[[], TurnBinding]): self._source = source self._changed = changed @@ -284,7 +307,7 @@ def bound(self): def managed_id(self): return self.owner.managed_id - def changed(self, message): + def changed(self, message: CoreEventMessage): if not self.binding.accepts(message): return False # The binding changed before publishing this event. Comparing another @@ -292,12 +315,12 @@ def changed(self, message): self._changed(self.owner) return True - def describe(self, activity): + def describe(self, activity: str): before = self.owner self.binding.describe(activity) self._notify(before) - def _notify(self, before): + def _notify(self, before: TurnOwner): # Streaming describes the turn on every chunk and publications repeat # unchanged leases; consumers re-sync prompt, footer bindings and # details, so they hear only an actual change of owner. diff --git a/src/toad/markdown_links.py b/src/toad/markdown_links.py index 2a0e5083b..91b237c63 100644 --- a/src/toad/markdown_links.py +++ b/src/toad/markdown_links.py @@ -1,12 +1,14 @@ """One syntax and link resolver for line, inline and Textual Markdown views.""" + from __future__ import annotations -from functools import partial import re from abc import abstractmethod from dataclasses import dataclass +from functools import partial from pathlib import Path from threading import local +from typing import TYPE_CHECKING from agent_comms.declared_family import DeclaredFamily from agent_comms.display_references import ReferenceContext @@ -15,9 +17,13 @@ from markdown_it.rules_core import StateCore from markdown_it.token import Token +from toad.file_position import FilePosition from toad.line_blocks import LineLink, LinkHref, OpenReference from toad.project_link import ProjectLink -from toad.file_position import FilePosition + +if TYPE_CHECKING: + from textual.widget import Widget + _PATH_PATTERN = re.compile( r"(? Command: + def for_script(cls, script: str, *, env: Mapping[str, str] | None = None, cwd: str | None = None) -> Command: """Catalog shell scripts use the standard shell and terminal environment.""" - return cls(script, TerminalEnvironment.for_child(os.environ) | (env or {}), - cwd, shell="/bin/sh") + return cls(script, TerminalEnvironment.for_child(os.environ) | (env or {}), cwd, shell="/bin/sh") @classmethod - def for_argv(cls, command: str, args: list[str], *, - env: Mapping[str, str], cwd: str) -> Command: + def for_argv(cls, command: str, args: list[str], *, env: Mapping[str, str], cwd: str) -> Command: """Compile ACP executable/arguments exactly once at its typed boundary.""" return cls(shlex.join([command, *args]), env, cwd) @@ -72,6 +79,7 @@ def return_code(self) -> int | None: def exit_status(self) -> protocol.TerminalExitStatus | None: return None # Official ACP absence, never an internal lifecycle field. + class RunningTerminalOutcome(TerminalOutcome): """The original execution task has not returned a completion witness.""" @@ -82,6 +90,7 @@ class UnstartedTerminalOutcome(TerminalOutcome): class RetiredTerminalOutcome(TerminalOutcome): """Acquisition was revoked before any command could start.""" + finished = True @@ -150,17 +159,18 @@ def wait_response(self): class _TerminalCompletionDecoder(MroDispatch): """Typed original POSIX members enter the terminal family at acquisition.""" + completion: TerminalCompletion - def __init__(self, original): + def __init__(self, original: ChildOutcome): self.dispatch_sync(original) @handles(ExitedOutcome) - def exited(self, original): + def exited(self, original: ExitedOutcome): self.completion = ExitedTerminalOutcome(original) @handles(SignaledOutcome) - def signaled(self, original): + def signaled(self, original: SignaledOutcome): self.completion = SignaledTerminalOutcome(original) @@ -169,6 +179,7 @@ class FailedTerminalOutcome(TerminalOutcome): error: BaseException finished = True + @dataclass(frozen=True) class ToolState: output: str @@ -176,20 +187,31 @@ class ToolState: outcome: TerminalOutcome def response(self): - return protocol.TerminalOutputResponse(output=self.output, truncated=self.truncated, - exit_status=self.outcome.exit_status()) + return protocol.TerminalOutputResponse( + output=self.output, truncated=self.truncated, exit_status=self.outcome.exit_status() + ) @dataclass(frozen=True) class PtyProcess: """Complete acquired handles, scoped by the execution's AsyncExitStack.""" + child: AttachedChild master: FileIO reader: asyncio.StreamReader @classmethod - async def acquire(cls, command: tuple[str, ...], *, env, cwd, width, height, - cleanup: AsyncExitStack, buffer_size=128 * 1024): + async def acquire( + cls, + command: tuple[str, ...], + *, + env: Mapping[str, str], + cwd, + width: int, + height: int, + cleanup: AsyncExitStack, + buffer_size: int = 128 * 1024, + ): master, slave = pty.openpty() master_file = cleanup.enter_context(os.fdopen(master, "rb", 0)) slave_file = cleanup.enter_context(os.fdopen(slave, "wb", 0)) @@ -200,13 +222,17 @@ async def acquire(cls, command: tuple[str, ...], *, env, cwd, width, height, reader = asyncio.StreamReader(buffer_size) protocol = asyncio.StreamReaderProtocol(reader) transport, _ = await asyncio.get_running_loop().connect_read_pipe( - lambda: protocol, master_file, + lambda: protocol, + master_file, ) cleanup.callback(transport.close) async def acquire_child(): child = await AttachedChild.start( - command, stdio=TerminalChildStdio(slave_file), env=env, cwd=cwd, + command, + stdio=TerminalChildStdio(slave_file), + env=env, + cwd=cwd, ) cleanup.push_async_callback(child.stop) return child @@ -222,12 +248,12 @@ async def acquire_child(): slave_file.close() return cls(child, master_file, reader) - def resize(self, width, height): + def resize(self, width: int, height: int): with suppress(OSError, ValueError): self.resize_fd(self.master.fileno(), width, height) @staticmethod - def resize_fd(fd, width, height): + def resize_fd(fd: int, width: int, height: int): termios.tcsetwinsize(fd, (height, width)) async def write(self, data: bytes) -> int: @@ -235,7 +261,7 @@ async def write(self, data: bytes) -> int: # thread that could write to an unrelated reused descriptor. try: return os.write(self.master.fileno(), data) - except (OSError, ValueError): + except OSError, ValueError: return 0 def kill(self) -> None: @@ -255,10 +281,10 @@ def outcome(self) -> TerminalOutcome: ... def kill(self) -> None: pass - def resize(self, width, height): + def resize(self, width: int, height: int): pass - async def write(self, data): + async def write(self, data: bytes): return 0 async def close(self): @@ -305,7 +331,7 @@ class ActiveTerminalOperation(TerminalOperation): def start(self, execution): raise RuntimeError("Terminal execution already started") - def settle_startup(self, task): + def settle_startup(self, task: asyncio.Task[TerminalCompletion]): # A cancelled task may never enter run(), so its original cancellation # must settle the startup waiter too. Entered failures settle in run(). if task.cancelled() and not self.ready.done(): @@ -321,11 +347,11 @@ def outcome(self): except BaseException as error: return FailedTerminalOutcome(error) - def resize(self, width, height): + def resize(self, width: int, height: int): if self.ready.done() and not self.task.done(): self.ready.result().resize(width, height) - async def write(self, data): + async def write(self, data: bytes): if self.ready.done() and not self.task.done(): return await self.ready.result().write(data) return 0 @@ -363,10 +389,16 @@ async def retire_task(self): class TerminalExecution: """One ANSI/output owner and one acquired operation, independent of views.""" - def __init__(self, command: Command, output_byte_limit: int | None = None, *, - width: int = ansi.TerminalState.DEFAULT_WIDTH, - height: int = ansi.TerminalState.DEFAULT_HEIGHT, - state: ansi.TerminalState | None = None): + + def __init__( + self, + command: Command, + output_byte_limit: int | None = None, + *, + width: int = ansi.TerminalState.DEFAULT_WIDTH, + height: int = ansi.TerminalState.DEFAULT_HEIGHT, + state: ansi.TerminalState | None = None, + ): self._command = command self._output_byte_limit = output_byte_limit self._output: deque[bytes] = deque() @@ -374,8 +406,7 @@ def __init__(self, command: Command, output_byte_limit: int | None = None, *, self._output_bytes_count = 0 self.operation: TerminalOperation = NewTerminalOperation() # An existing ANSI resource retains prefixes and sequential command output. - self.state = (ansi.TerminalState(self.write_stdin, width=width, height=height) - if state is None else state) + self.state = ansi.TerminalState(self.write_stdin, width=width, height=height) if state is None else state self._view = lambda: None @property @@ -401,7 +432,7 @@ def project(self, scrollback: set[int] | None, alternate: set[int] | None): if (terminal := self._view()) is not None and terminal.is_attached: terminal.present_execution(scrollback, alternate) - def update_size(self, width, height): + def update_size(self, width: int, height: int): self.state.update_size(width, height) self.operation.resize(width, height) @@ -419,13 +450,17 @@ async def start(self): self.state.show_cursor = True await asyncio.shield(operation.ready) - async def run(self, ready): + async def run(self, ready: asyncio.Future[PtyProcess]): try: async with AsyncExitStack() as cleanup: command = self.command acquired = await PtyProcess.acquire( - command.shell_command, env=os.environ | command.env, cwd=command.cwd, - width=self.state.width, height=self.state.height, cleanup=cleanup, + command.shell_command, + env=os.environ | command.env, + cwd=command.cwd, + width=self.state.width, + height=self.state.height, + cleanup=cleanup, ) ready.set_result(acquired) decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") @@ -447,7 +482,7 @@ async def run(self, ready): # The task's result becomes authoritative only after this returns. asyncio.get_running_loop().call_soon(self.project, None, None) - async def write_stdin(self, text: str | bytes, hide_echo=False): + async def write_stdin(self, text: str | bytes, hide_echo: bool = False): data = text.encode("utf-8", "ignore") if isinstance(text, str) else text return await self.operation.write(data) @@ -493,10 +528,7 @@ def is_continuation(byte_value: int) -> bool: return (byte_value & 0b11000000) == 0b10000000 truncated = self._bytes_read > len(output_bytes) - if ( - self._output_byte_limit is not None - and len(output_bytes) > self._output_byte_limit - ): + if self._output_byte_limit is not None and len(output_bytes) > self._output_byte_limit: truncated = True output_bytes = output_bytes[len(output_bytes) - self._output_byte_limit :] # Must start on a utf-8 boundary diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index 7722aefd7..5f1b7b804 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -49,6 +49,13 @@ if TYPE_CHECKING: from toad.tool_output import ToolOutputPart from toad.widgets.committed_presentation import CommitClaim + from toad.widgets.transcript_lines import ( + BlockLines, + Lines, + LineTheme, + RichLineRenderer, + TranscriptLinesRenderTask, + ) @dataclass(frozen=True) @@ -73,10 +80,9 @@ def _split_table(self, part: PreparedMarkdownPart) -> Iterator[PreparedMarkdownP table = part.tokens[0] assert table.map is not None ranges = [] - for index, row in enumerate(lines[2:table.map[1]], 2): + for index, row in enumerate(lines[2 : table.map[1]], 2): if index > first and ( - index - first >= row_limit - or len(header) + row_characters + len(row) > character_limit + index - first >= row_limit or len(header) + row_characters + len(row) > character_limit ): ranges.append((first, index)) first = index @@ -108,8 +114,13 @@ def split(self, source: PreparedMarkdownPart) -> Iterator[PreparedMarkdownPart]: } starts = sorted({0, *blocks, len(lines)}) parts = [ - (start, stop, blocks.get(start, (0, None))[0], - blocks.get(stop, (len(source.tokens), None))[0], blocks.get(start, (0, None))[1]) + ( + start, + stop, + blocks.get(start, (0, None))[0], + blocks.get(stop, (len(source.tokens), None))[0], + blocks.get(start, (0, None))[1], + ) for start, stop in zip(starts, starts[1:]) ] pending_first = 0 @@ -118,15 +129,13 @@ def split(self, source: PreparedMarkdownPart) -> Iterator[PreparedMarkdownPart]: pending_token_stop = 0 pending_characters = 0 pending_lines = 0 + 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)) + return source.select_blocks(text[offsets[first] : offsets[stop]], first, slice(token_first, token_stop)) for start, stop, token_first, token_stop, kind in parts: - part = text[offsets[start]:offsets[stop]] - if kind == "table_open" and ( - len(part) > self.characters or part.count("\n") > self.lines - ): + part = text[offsets[start] : offsets[stop]] + if kind == "table_open" and (len(part) > self.characters or part.count("\n") > self.lines): if pending_characters: yield select(pending_first, pending_stop, pending_token_first, pending_token_stop) pending_characters = 0 @@ -134,12 +143,8 @@ def select(first: int, stop: int, token_first: int, token_stop: int): yield from self._split_table(select(start, stop, token_first, token_stop)) continue part_lines = part.count("\n") + int(bool(part) and not part.endswith("\n")) - exceeds = ( - pending_characters - and ( - pending_characters + len(part) > self.characters - or pending_lines + part_lines > self.lines - ) + exceeds = pending_characters and ( + pending_characters + len(part) > self.characters or pending_lines + part_lines > self.lines ) if exceeds: yield select(pending_first, pending_stop, pending_token_first, pending_token_stop) @@ -241,10 +246,10 @@ class LastPart(PartPosition): def notification_references(self, event): return (event.source,) - def status_blocks(self, shown): + def status_blocks(self, shown: MessageShown): return shown.status.blocks(shown) - def outgoing_sources(self, publication): + def outgoing_sources(self, publication: PublishedSentMessage): return (publication.reference,) @@ -252,13 +257,13 @@ class InnerPart(PartPosition): __slots__ = () has_details = False - def notification_references(self, event): + def notification_references(self, event: TranscriptEvent): return () - def status_blocks(self, shown): + def status_blocks(self, shown: MessageShown): return () - def outgoing_sources(self, publication): + def outgoing_sources(self, publication: PublishedSentMessage): return () @@ -271,6 +276,7 @@ class TranscriptFragment: shown: Shown = field(default_factory=Shown, kw_only=True) # Whether the fragment has details a reader can show and hide. has_details: ClassVar[bool] = False + @property def arrival_position(self) -> int | str: """The first event owns the fragment's stable source position.""" @@ -302,7 +308,7 @@ def outgoing_sources(self): def capture_part(cls, event, text: str, **kwargs): return cls((replace(event, text=text),), **kwargs) - def text_rows(self, lines) -> range: + def text_rows(self, lines: Lines) -> range: """The fragment's content occupies its drawn rows unless it owns a header.""" return range(lines.height) @@ -343,8 +349,7 @@ def timestamp(self) -> float | None: @property def clipboard_text(self) -> str | None: """What copying this fragment gives: its text as written.""" - return "\n\n".join(event.text for event in self.events - if isinstance(event, TextTranscript)) or None + return "\n\n".join(event.text for event in self.events if isinstance(event, TextTranscript)) or None @property def block_menu(self) -> tuple[MenuItem, ...]: @@ -362,11 +367,11 @@ def link_place(self) -> int: return link_place() - def lines_for(self, width: int, theme): + def lines_for(self, width: int, theme: LineTheme): """Cached lookup is geometry-only; source changes use declared invalidation.""" return self._lines.for_view(width, theme) - def retains_lines(self, lines, theme) -> bool: + def retains_lines(self, lines: BlockLines, theme: LineTheme) -> bool: """Whether measured rows still belong to this source and palette. RowAcquisition.for_view owns wrapping those rows at the layout width. @@ -376,14 +381,14 @@ def retains_lines(self, lines, theme) -> bool: def invalidate_lines(self) -> None: self._lines = NoRows() - def hold_lines(self, request, lines) -> bool: + def hold_lines(self, request: TranscriptLinesRenderTask, lines: BlockLines) -> bool: """Report whether these rows were retained for the fragment's source.""" if request.blocks is self.line_blocks: self._lines = AcquiredRows(request.preparation, lines) return True return False - def plain_lines(self, width: int, renderer): + def plain_lines(self, width: int, renderer: RichLineRenderer): """Plain wrapped lines drawn until styled lines arrive; one per width.""" self._plain = acquired = self._plain.plain(width, renderer, self.line_blocks) return acquired.lines @@ -408,7 +413,7 @@ def has_details(self) -> bool: self.tool_call, any(part.has_details for part in self.output_parts) ) - def blocks(self, shown): + def blocks(self, shown: Shown): return AgentActivityPresentation.tool( self.tool_call, has_details=self.has_details, @@ -426,11 +431,11 @@ class BusMessageFragment(TranscriptFragment): row: MessageRow @classmethod - def capture_part(cls, event, text: str, **kwargs): + def capture_part(cls, event: TextTranscript, text: str, **kwargs): return cls((event,), text_part=text, **kwargs) @classmethod - def text_parts(cls, source, budget): + def text_parts(cls, source: PreparedMarkdownPart, budget: RenderBudget): # Even an empty bus message owns its header and notification footer. return super().text_parts(source, budget) or (source,) @@ -482,6 +487,7 @@ def outgoing_sources(self): def shown_live_by(self, claim: CommitClaim) -> bool: return claim.repeats_sent(self) + class ContextTranscriptFragment(TranscriptFragment): """The coordination context an agent was given: one line, and its text when opened.""" @@ -491,8 +497,7 @@ def blocks(self, shown): summary = shown.summary("Agent coordination context") if not shown.details_open: return (summary,) - return (summary, *(Body(event.text, role=MutedRole, bar=MutedRole) - for event in self.events)) + return (summary, *(Body(event.text, role=MutedRole, bar=MutedRole) for event in self.events)) class TranscriptFragmentConsumer(MroProjection[None]): @@ -520,8 +525,12 @@ def sent(self, event: SentTranscript) -> None: @handles(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)) + 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) -> None: @@ -529,9 +538,14 @@ def text(self, event: TextTranscript, kind: type[TranscriptFragment] = Transcrip source = PreparedMarkdownPart.capture(event.text) parts = kind.text_parts(source, self.budget) self.fragments.extend( - kind.capture_part(event, part.text, continuation=index > 0, - part_position=LastPart(index=index) if index == len(parts) - 1 else InnerPart(index=index), - starts_agent_activity=starts_activity and index == 0, **kwargs) + kind.capture_part( + event, + part.text, + continuation=index > 0, + part_position=LastPart(index=index) if index == len(parts) - 1 else InnerPart(index=index), + starts_agent_activity=starts_activity and index == 0, + **kwargs, + ) for index, part in enumerate(parts) ) @@ -539,7 +553,9 @@ def text(self, event: TextTranscript, kind: type[TranscriptFragment] = Transcrip 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), + len(self.fragments), + [], + self.boundary.observe(event.category), ) self.fragments.append(None) # Reserve the call's original position. _, events, _ = self.tools[event.tool_call_id] @@ -547,7 +563,8 @@ def tool(self, event: ToolTranscript) -> None: def transcript_fragments( - events: tuple[TranscriptEvent, ...], *, + events: tuple[TranscriptEvent, ...], + *, resolve_sent: Callable[[Iterable[SentTranscript]], dict[int, SentTranscript | PublishedSentMessage]], ) -> tuple[TranscriptFragment, ...]: from toad.tool_output import ToolOutput @@ -577,12 +594,17 @@ def transcript_fragments( elif index in tools: tool_events, starts_activity, call, parts = tools[index] parts = call.project_output( - parts, partial(ToolOutput.apply_sent, resolved=resolved), + parts, + partial(ToolOutput.apply_sent, resolved=resolved), + ) + fragments.append( + ToolTranscriptFragment( + tool_events, + starts_agent_activity=starts_activity, + tool_call=call, + output_parts=parts, + ) ) - fragments.append(ToolTranscriptFragment( - tool_events, starts_agent_activity=starts_activity, - tool_call=call, output_parts=parts, - )) else: fragments.append(source) return tuple(fragments) diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index 59a3f9f51..c5d26db72 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from toad.textual_colours import PaletteState, TextualColours + from toad.widgets.transcript_fragments import Shown, TranscriptFragment from agent_comms.mro_dispatch import MroDispatch, handles from rich._wrap import divide_line @@ -73,29 +74,44 @@ def style(self, role: type[LineRole]) -> Style: def _style(role: type[LineRole], style: Style, palette: PaletteState) -> Style: if role.background_tint: style = palette.tint_style(style, role.background_tint) - return style + Style(italic=role.italic or None, bold=role.bold or None, - underline=role.underline or None) + return style + Style(italic=role.italic or None, bold=role.bold or None, underline=role.underline or None) def with_colour(self, colour: type[ThemeColour], style: Style, palette: PaletteState) -> LineTheme: """This theme with one colour drawn another way, as a fade draws it part of the way through.""" - return replace(self, styles={role: self._style(role, style, palette) - if role.colour is colour else held for role, held in self.styles.items()}) + return replace( + self, + styles={ + role: self._style(role, style, palette) if role.colour is colour else held + for role, held in self.styles.items() + }, + ) @classmethod def of(cls, colours: TextualColours) -> LineTheme: # Native ANSI text retains its distinct dim attribute rather than a palette guess. - muted = (Style(dim=True) if colours.colour(ForegroundColour).ansi is not None - else Style(color=colours.colour(MutedColour).rich_color)) - return cls({role: cls._style(role, muted if role.colour is MutedColour - else Style(color=colours.colour(role.colour).rich_color), - colours.schemes[colours.app.theme].palette) for role in LineRole.members_with(LineRole)}, - colours.depth.name, diff=colours.app.settings.diff, syntax=SyntaxPalette.of(colours.app)) + muted = ( + Style(dim=True) + if colours.colour(ForegroundColour).ansi is not None + else Style(color=colours.colour(MutedColour).rich_color) + ) + return cls( + { + role: cls._style( + role, + muted if role.colour is MutedColour else Style(color=colours.colour(role.colour).rich_color), + colours.schemes[colours.app.theme].palette, + ) + for role in LineRole.members_with(LineRole) + }, + colours.depth.name, + diff=colours.app.settings.diff, + syntax=SyntaxPalette.of(colours.app), + ) def link_style(link: LineLink | None) -> Style: """The style that makes text run a link's action when clicked.""" - return Style.null() if link is None else Style( - underline=link.underlined or None, meta={"@click": link.click}) + return Style.null() if link is None else Style(underline=link.underlined or None, meta={"@click": link.click}) def _fit(text: str, width: int) -> str: @@ -113,6 +129,7 @@ def _text_width(block: Body | RichTextBody, width: int) -> int: def _clickable(rows: list[Row]) -> list[Row]: """Give each Markdown link the click action that opens it; Rich marks a link's cells with its address only.""" + def linked(segment: Segment) -> Segment: style = segment.style if style is None or not style.link: @@ -175,10 +192,14 @@ class PatchLayout: 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) + longest = max( + (cell_len(line.expandtabs(4)) for line in (*patch.before.values(), *patch.after.values())), default=0 + ) self.split = settings.view.use_split(width, 2 * (longest + self.number_width + 4) + 3) self.pane_width = max(1, (width - 3) // 2 if self.split else width) - self.prefix_width = (self.number_width + 1 if self.split else 2 * self.number_width + 2) + (2 if settings.annotations else 0) + self.prefix_width = (self.number_width + 1 if self.split else 2 * self.number_width + 2) + ( + 2 if settings.annotations else 0 + ) self.pane_width = settings.wrap.fitted_width(self.pane_width, longest + self.prefix_width) self.code_width = max(1, self.pane_width - self.prefix_width) @@ -208,7 +229,8 @@ def strips_at(self, place: Hashable) -> tuple[Strip, ...] | JoinedStrips: if strips is None: width = self.width strips = self.__dict__["strips"] = tuple( - Strip(row).extend_cell_length(width) for row in _each_link_apart(self, place)) + Strip(row).extend_cell_length(width) for row in _each_link_apart(self, place) + ) return strips @@ -221,9 +243,11 @@ class PreparedLines(Lines, LineBlock): retained_bytes: int = field(init=False) def __post_init__(self) -> None: - object.__setattr__(self, "retained_bytes", - 64 + sum(64 + 64 * len(row) + 4 * sum(len(segment.text) for segment in row) - for row in self.rows)) + object.__setattr__( + self, + "retained_bytes", + 64 + sum(64 + 64 * len(row) + 4 * sum(len(segment.text) for segment in row) for row in self.rows), + ) def __iter__(self) -> Iterator[Row]: return iter(self.rows) @@ -232,7 +256,7 @@ def __iter__(self) -> Iterator[Row]: def height(self) -> int: return len(self.rows) - def prepared_rows(self, width, draw) -> PreparedLines: + def prepared_rows(self, width: int, draw) -> PreparedLines: if width != self.width: raise ValueError("Prepared rows belong to a different width") return self @@ -281,7 +305,7 @@ class PreparedBlock(LineBlock): _rows: PreparedLines = PreparedLines(width=-1, rows=()) - def prepared_rows(self, width, draw) -> PreparedLines: + def prepared_rows(self, width: int, draw) -> PreparedLines: rows = self._rows if rows.width != width: self._rows = rows = draw(self) @@ -297,8 +321,15 @@ class SpanColumnLayout(Columns, PreparedBlock): 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] + 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 + ] super().__init__(cells, padding=(0, 2)) def rows(self, console: Console) -> tuple[Row, ...]: @@ -331,8 +362,13 @@ def __init__(self, theme: LineTheme, width: int = 1) -> None: @cached_property def console(self) -> Console: - return Console(width=self.width, color_system=self.theme.color_system, - force_terminal=True, file=io.StringIO(), legacy_windows=False) + return Console( + width=self.width, + color_system=self.theme.color_system, + force_terminal=True, + file=io.StringIO(), + legacy_windows=False, + ) def at_width(self, width: int) -> RichLineRenderer: self.width = self.console.width = max(1, width) @@ -355,6 +391,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: PatchCell | None, source: dict[int, Text], old: str, new: str): if cell is None: return [Text(" " * pane_width)] @@ -372,16 +409,23 @@ def paint(cell: PatchCell | None, source: dict[int, Text], old: str, new: str): for index, chunk in enumerate(chunks): chunk.truncate(code_width, overflow="crop", pad=True) chunk.stylize(tint) - gutter = Text(prefix if index == 0 else " " * prefix_width, - style=style + tint if cell.role is not TextRole else self.theme.style(MutedRole)) + gutter = Text( + prefix if index == 0 else " " * prefix_width, + style=style + tint if cell.role is not TextRole else self.theme.style(MutedRole), + ) result.append(gutter + chunk) return result title = patch.after_path or patch.before_path or "Edit" if patch.before_path != patch.after_path: title = f"{patch.before_path} → {patch.after_path}" - self.output.append((Segment(_fit(f"{title} (+{patch.additions}, −{patch.deletions})", self.width), - self.theme.style(MutedRole)),)) + self.output.append( + ( + Segment( + _fit(f"{title} (+{patch.additions}, −{patch.deletions})", self.width), self.theme.style(MutedRole) + ), + ) + ) for group_index, group in enumerate(patch.row_groups(split)): if group_index: self.output.append((Segment("…", self.theme.style(MutedRole)),)) @@ -390,7 +434,9 @@ def paint(cell: PatchCell | None, source: dict[int, Text], old: str, new: str): left, right = paint(old, before, "", ""), paint(new, after, "", "") blank = Text(" " * pane_width) for a, b in zip_longest(left, right, fillvalue=blank): - self.output.append(tuple((a + Text(" │ ", style=self.theme.style(MutedRole)) + b).render(console))) + self.output.append( + tuple((a + Text(" │ ", style=self.theme.style(MutedRole)) + b).render(console)) + ) else: cell = old or new source = before if old is not None else after @@ -401,8 +447,12 @@ def paint(cell: PatchCell | None, source: dict[int, Text], old: str, new: str): def rich_text(self, block: RichTextBody) -> None: inner = _text_width(block, self.width) console = self.console - rows = [tuple(row) for row in console.render_lines( - block.content, console.options.update(width=inner), style=self.theme.style(block.role), pad=False)] + rows = [ + tuple(row) + for row in console.render_lines( + block.content, console.options.update(width=inner), style=self.theme.style(block.role), pad=False + ) + ] self._body_rows(block, rows) @handles(Divider) @@ -416,15 +466,17 @@ def divider(self, block: Divider) -> None: # A title longer than the rule ends in an ellipsis; what follows is not drawn. text = _fit(span.text, room) room -= cell_len(text) - title.append(Segment(text, (rule if span.link is None else self.theme.style(span.role)) - + bold + link_style(span.link))) + title.append( + Segment( + text, (rule if span.link is None else self.theme.style(span.role)) + bold + link_style(span.link) + ) + ) if room == 0: break title.append(Segment(" ", rule)) length = sum(segment.cell_length for segment in title) left = max(0, (self.width - length) // 2) - self.output.append((Segment("─" * left, rule), *title, - Segment("─" * max(0, self.width - left - length), rule))) + self.output.append((Segment("─" * left, rule), *title, Segment("─" * max(0, self.width - left - length), rule))) @handles(Summary) def summary(self, block: Summary) -> None: @@ -434,15 +486,16 @@ def summary(self, block: Summary) -> None: text = _fit(span.text, room) room -= cell_len(text) row.append(Segment(text, self.theme.style(span.role) + link_style(span.link))) - row.extend(Segment(span.text, self.theme.style(span.role) + link_style(span.link)) - for span in block.kept) + row.extend(Segment(span.text, self.theme.style(span.role) + link_style(span.link)) for span in block.kept) self.output.append(tuple(row)) @handles(Spans) def spans(self, block: Spans) -> None: - self.output.extend((Segment(line[start:start + self.width]),) - for line in _plain_lines(block.text) - for start in _plain_spans(line, self.width)) + self.output.extend( + (Segment(line[start : start + self.width]),) + for line in _plain_lines(block.text) + for start in _plain_spans(line, self.width) + ) @handles(PreparedLines) def prepared(self, block: PreparedLines) -> None: @@ -467,17 +520,31 @@ def prepared_columns(self, block: SpanColumnLayout) -> None: @handles(StyledBody) def styled_body(self, block: StyledBody) -> None: source = block.source - rows = _clickable([tuple(line) for line in self.console.render_lines( - Renderables(block.content), self.console.options.update(width=_text_width(source, self.width)), - style=self.theme.style(source.role), pad=False)]) + rows = _clickable( + [ + tuple(line) + for line in self.console.render_lines( + Renderables(block.content), + self.console.options.update(width=_text_width(source, self.width)), + style=self.theme.style(source.role), + pad=False, + ) + ] + ) self._body_rows(source, rows) @handles(Body) def body(self, block: Body) -> None: inner = _text_width(block, self.width) style = self.theme.style(block.role) - self._body_rows(block, [(Segment(line[start:start + inner], style),) - for line in _plain_lines(block.text) for start in _plain_spans(line, inner)]) + self._body_rows( + block, + [ + (Segment(line[start : start + inner], style),) + for line in _plain_lines(block.text) + for start in _plain_spans(line, inner) + ], + ) def _body_rows(self, block: Body | RichTextBody, rows: list[Row]) -> None: if block.bar is not None: @@ -495,8 +562,7 @@ class LinePreparation: retained_bytes: int = field(init=False) def __post_init__(self) -> None: - object.__setattr__(self, "retained_bytes", - 256 + sum(block.retained_bytes for block in self.blocks)) + object.__setattr__(self, "retained_bytes", 256 + sum(block.retained_bytes for block in self.blocks)) def wrap(self, width: int, renderer: RichLineRenderer) -> BlockLines: return BlockLines(width, self, renderer) @@ -553,6 +619,7 @@ class PlainLines(BlockLines): def __init__(self, width: int, renderer: RichLineRenderer, blocks: tuple[LineBlock, ...]) -> None: super().__init__(width, LinePreparation(blocks, renderer.theme), renderer) + class RowAcquisition(ABC): """The source witness and rows acquired by one fragment.""" @@ -578,10 +645,10 @@ class NoRows(RowAcquisition): def preparation(self) -> NoRows: return self - def for_view(self, width: int, theme): + def for_view(self, width: int, theme: LineTheme): return None - def retains(self, lines, theme) -> bool: + def retains(self, lines: BlockLines, theme: LineTheme) -> bool: return False @@ -594,16 +661,16 @@ class AcquiredRows(RowAcquisition): def preparation(self) -> LinePreparation: return self.lines.preparation - def for_view(self, width: int, theme): + def for_view(self, width: int, theme: LineTheme): if self.source.theme is not theme: return None self.lines = self.lines.rewrap(width) return self.lines - def retains(self, lines, theme) -> bool: + def retains(self, lines: BlockLines, theme: LineTheme) -> bool: return self.source.theme is theme and self.lines.preparation is lines.preparation - def plain(self, width: int, renderer, blocks): + def plain(self, width: int, renderer: RichLineRenderer, blocks: tuple[LineBlock, ...]): if self.source.theme is renderer.theme: self.lines = self.lines.rewrap(width) return self @@ -611,7 +678,7 @@ def plain(self, width: int, renderer, blocks): class PlainRows(AcquiredRows): - def retains(self, lines, theme) -> bool: + def retains(self, lines: BlockLines, theme: LineTheme) -> bool: return self.source.theme is theme and self.lines.preparation.blocks is lines.preparation.blocks @@ -633,11 +700,13 @@ def blocks(self) -> tuple[LineBlock, ...]: def current_for(self, view) -> bool: return self.context.current_for(view) and self.preparation.theme is view.app.colours.line_theme - def hold(self, view, fragment, lines) -> bool: - return self.current_for(view) and fragment.hold_lines(self, lines.wrap(view.line_width, view.app.colours.line_renderer)) + def hold(self, view, fragment: TranscriptFragment, lines: LinePreparation) -> bool: + return self.current_for(view) and fragment.hold_lines( + self, lines.wrap(view.line_width, view.app.colours.line_renderer) + ) @staticmethod - def show(view, fragment, shown, rendered) -> None: + def show(view, fragment: TranscriptFragment, shown: Shown, rendered) -> None: """Apply the requested state, retaining rows only through their render request.""" fragment.show(shown) if rendered is not None: @@ -647,7 +716,9 @@ def show(view, fragment, shown, rendered) -> None: fragment.hold_lines(request, lines.wrap(view.line_width, view.app.colours.line_renderer)) def execute(self) -> LinePreparation: - return LinePreparation(tuple(next(self.handlers_for(block))(block) for block in self.blocks), self.preparation.theme) + return LinePreparation( + tuple(next(self.handlers_for(block))(block) for block in self.blocks), self.preparation.theme + ) @handles(LineBlock) def unchanged(self, block: LineBlock) -> LineBlock: @@ -658,8 +729,9 @@ def body(self, block: Body) -> LineBlock: return block.prepare(self.markdown) def markdown(self, block: Body) -> StyledBody: - console = MarkdownStyleConsole(force_terminal=True, file=io.StringIO(), - color_system=self.preparation.theme.color_system) + console = MarkdownStyleConsole( + force_terminal=True, file=io.StringIO(), color_system=self.preparation.theme.color_system + ) return StyledBody(block, console.prepare(block.text, self.context)) @handles(WireBody) @@ -677,19 +749,33 @@ def columns(self, block: SpanColumns) -> SpanColumnLayout: @handles(Spans) def spans(self, block: Spans) -> StyledBody: - text = Text.assemble(*((span.text, self.preparation.theme.style(span.role) + link_style(span.link)) - for span in block.spans), tab_size=4) + text = Text.assemble( + *((span.text, self.preparation.theme.style(span.role) + link_style(span.link)) for span in block.spans), + tab_size=4, + ) return self.rich_text(RichTextBody(text, role=TextRole, bar=None)) @handles(Patch) def patch(self, block: Patch) -> StyledPatch: 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") + syntax = Syntax( + code, + Syntax.guess_lexer(path, code), + theme=self.preparation.theme.syntax.pygments_theme, + background_color="default", + ) return dict(zip(lines, syntax.highlight(code).split("\n", allow_blank=True))) - return StyledPatch(block.before_path, block.after_path, block.before, block.after, block.groups, - highlight(block.before, block.before_path), highlight(block.after, block.after_path)) + + return StyledPatch( + block.before_path, + block.after_path, + block.before, + block.after, + block.groups, + highlight(block.before, block.before_path), + highlight(block.after, block.after_path), + ) @dataclass(frozen=True)