Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e8591b6
Keep narrow message headers and receipts readable through shared rows
trissim Oct 31, 2026
2276713
Derive four-edge sidebar geometry and controls from crossing capabili…
trissim Oct 31, 2026
e532fb4
Migrate the tool header to the shared variable-height line block
trissim Oct 31, 2026
6675dc7
Inherit the cursor grouping and direction contract once
trissim Oct 31, 2026
2961e45
Own sidebar content clearance once and preserve resize minimums
trissim Oct 31, 2026
3450c66
Resize from native parent geometry without percentage round trips
trissim Oct 31, 2026
c698fda
Own session sidebars before native composition and through retirement
trissim Nov 1, 2026
1953fd0
Derive sidebar presentation from current native attachment
trissim Nov 1, 2026
92261ec
Constrain sidebar headers and repair native fixture contracts
trissim Nov 1, 2026
2328941
Project sidebar controls through the shared placement refresh
trissim Nov 1, 2026
09734e3
Use the thread deletion owner in successor fixtures
trissim Nov 1, 2026
608673f
Keep project and read owners in UI fixtures
trissim Nov 1, 2026
08bf52e
Ask reader positions and read cursors in paint fixtures
trissim Nov 1, 2026
edc1086
Drive paint fixtures through committed paging intent
trissim Nov 1, 2026
d318ab3
Respect lazy row preparation and latest-message intent in paint fixtures
trissim Nov 1, 2026
656786c
Let sidebar and row owners carry mobile presentation contracts
trissim Nov 1, 2026
0756627
Make concrete selection return contracts truthful
trissim Nov 1, 2026
7c125f2
Put native sidebar geometry cases in the Textual layer
trissim Nov 1, 2026
d4c887b
Derive sidebar placement roles instead of using DOM identity
trissim Nov 1, 2026
63666a5
Use native layout invalidation and await keyboard focus
trissim Nov 1, 2026
a0b9bfd
Resolve sidebar provider types in their runtime namespace
trissim Nov 1, 2026
8b618cc
Integrate sidebar placement with in-place notification repaint
trissim Nov 2, 2026
de0a65a
Keep editor submit intent with its queued callback
trissim Nov 1, 2026
0b451c8
Read the mode chooser through SettingsScreen values
trissim Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/toad/application_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from toad.target_commands import TargetContext
from toad.thread_actions import ThreadAction
from toad.core.session_requests import SessionNavigate
from toad.sidebar_layout import Side
from toad.sidebar_layout import LeftSidebarEdge, RightSidebarEdge, SidebarEdge
from agent_comms.errors import RelationViolationError
from agent_comms.cli_verbs.new import NewThreadCliCommand
from agent_comms.channel_targets import ProjectTarget
Expand Down Expand Up @@ -234,7 +234,7 @@ class VimHistoryForwardAction(VimHistoryAction):


class VimSidebarAction(VimWorkspaceAction, declared_name=None):
side: Side
side: type[SidebarEdge]

async def apply(self, app: ToadApp) -> None:
app.workspace_chrome.toggle_sidebar(self.side)
Expand All @@ -243,13 +243,13 @@ async def apply(self, app: ToadApp) -> None:
class VimLeftSidebarAction(VimSidebarAction):
"""Toggle the sidebars on the left edge."""
keys = {VimMode: ("ctrl+shift+h",)}
side = "left"
side = LeftSidebarEdge


class VimRightSidebarAction(VimSidebarAction):
"""Toggle the sidebars on the right edge."""
keys = {VimMode: ("ctrl+shift+l",)}
side = "right"
side = RightSidebarEdge


class VimMoveTabAction(VimWorkspaceAction, declared_name=None):
Expand Down
14 changes: 5 additions & 9 deletions src/toad/line_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from agent_comms.display_references import DisplayReference
from agent_comms.nominal.field_codec import FieldCodec

from typing import ClassVar
from typing import ClassVar, Self

from ui_theme.scheme import (
AccentColour, ErrorColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour, SuccessColour,
Expand Down Expand Up @@ -303,6 +303,10 @@ def text(self) -> str:
def selection_text(self) -> str:
return self.text

@classmethod
def titled(cls, title: str, role: type[LineRole] = MutedRole) -> Self:
return cls((Span(title, role),))


@dataclass(frozen=True)
class Spans(SpanText):
Expand Down Expand Up @@ -348,14 +352,6 @@ class Summary(SpanText):
def text(self) -> str:
return super().text + "".join(span.text for span in self.kept)

@classmethod
def titled(cls, title: str, role: type[LineRole] = MutedRole,
link: LineLink | None = None) -> Summary:
return cls((Span(title, role, link),))

@property
def selection_text(self) -> str:
return self.text


@dataclass(frozen=True)
Expand Down
20 changes: 15 additions & 5 deletions src/toad/prompt_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from textual.document._editing import VimMode
from textual.widgets.text_area import EditingMode
from toad.widget_actions import Action
from toad.core.input_events import UserInputSubmitted
from typing import TYPE_CHECKING


Expand Down Expand Up @@ -47,16 +48,22 @@ class SubmitInputAction(PromptAction, declared_name=None):
def available(self, editor):
return editor.submission_allowed

async def apply(self, editor: PromptTextArea) -> None:
editor.schedule_submission(self)

def coalesce(self, following: SubmitInputAction) -> SubmitInputAction:
return following

def submission_event(self, text: str, shell: bool) -> UserInputSubmitted:
return UserInputSubmitted(text, shell)


class SubmitAction(SubmitInputAction):
"""Send the prompt to the agent."""
keys = {EditingMode: ("enter",)}
title = "Send"
key_display = "⏎"

async def apply(self, editor: PromptTextArea) -> None:
editor.schedule_submission()


class SubmitNowAction(SubmitInputAction):
"""Send the prompt now, ahead of the queue."""
Expand All @@ -68,8 +75,11 @@ def available(self, editor):
return (super().available(editor) and editor.prompt.agent_ready and editor.agent_busy
and editor.prompt.agent_binding.presentation.queue_supported)

async def apply(self, editor: PromptTextArea) -> None:
editor.schedule_submission(immediate=True)
def coalesce(self, following: SubmitInputAction) -> SubmitInputAction:
return self

def submission_event(self, text: str, shell: bool) -> UserInputSubmitted:
return UserInputSubmitted(text, shell, immediate=True)


class CopyOrClearInputAction(PromptAction):
Expand Down
39 changes: 18 additions & 21 deletions src/toad/screens/comms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from toad.core_event_carrier import CoreEventMessage
from toad.core import events as core_events
from toad.core import session_requests
from toad.screens.session_view import SessionView
from toad.screens.session_view import SidebarSessionView
from toad.project_path_owner import CheckoutRoot
import asyncio
from functools import partial
Expand All @@ -24,7 +24,6 @@
from toad.widgets.comms_chat import CommsChatView
from toad.core.input_events import SelectHistoricalIdentity
from toad.widgets.comms_sidebar import CoordinationStatus, CommsSidebar
from toad.session_tracker import SidebarState
from toad.navigation_context import NavigationContext
from toad.navigation_target import HistoryTarget, NavigationTarget, SelectTarget
from toad.widgets.session_tabs import SessionsTabs
Expand Down Expand Up @@ -78,7 +77,7 @@ def apply(self, context: CommsScreen) -> None:
chat.prepare_prompt()


class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, ToadNode, can_focus=False,
class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SidebarSessionView[ThreadSidebar], ToadNode, can_focus=False,
actions=(ViewAction, ReturnToViewAction, CloseViewAction)):
"""A channel or DM represented as a native concurrent Toad session."""

Expand All @@ -102,7 +101,6 @@ def __init__(
self._identity = SessionIdentity.for_thread(me, owner_root)
self.conversation = conversation
self.wire_root = wire_root
self._thread_sidebar_state = SidebarState()
self._content_ready = asyncio.Event()
self._content_error: BaseException | None = None
self._content_loaded = False
Expand Down Expand Up @@ -143,9 +141,21 @@ def rebind_project(self, project: Checkout) -> None:
chat.project_path = project
chat.working_directory = str(project)

def sidebars(self):
sidebar = self.query_one_optional(ThreadSidebar)
return (sidebar,) if sidebar is not None else ()
def make_thread_sidebar(self) -> ThreadSidebar:
return ThreadSidebar(
SideBar.Panel(
"Connection",
CoordinationStatus(),
id="coordination-panel",
),
SideBar.Panel(
"Comms",
ThreadCommsSidebar(),
id="thread-comms-panel",
header_control=RelationshipSort(),
),
state=self._thread_sidebar_state,
)

def compose(self) -> ComposeResult:
content = (
Expand All @@ -156,20 +166,7 @@ def compose(self) -> ComposeResult:
)
)
yield containers.Center(
ThreadSidebar(
SideBar.Panel(
"Connection",
CoordinationStatus(),
id="coordination-panel",
),
SideBar.Panel(
"Comms",
ThreadCommsSidebar(),
id="thread-comms-panel",
header_control=RelationshipSort(),
),
state=self._thread_sidebar_state,
),
self.thread_sidebar,
containers.Vertical(*content, id="comms-content"),
).set_styles(layout=self.app.workspace_chrome)

Expand Down
14 changes: 6 additions & 8 deletions src/toad/screens/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@
ThreadTarget,
)
from toad.screens.mcp_inventory import MCPInventoryScreen
from toad.screens.session_view import SessionView
from toad.screens.session_view import SidebarSessionView
from toad.session_presentation import OperationalSessionPresentation
from toad.session_scope import AgentBinding, NoAgent, ViewSessionOrigin
from toad.session_tracker import SessionDetails, SidebarState
from toad.session_tracker import SessionDetails
from toad.tab_identity.history_origin import SessionHistoryOrigin
from toad.thread_identity import NoThreadIdentity, ThreadIdentity
from toad.widgets.channels_sidebar import ChannelsSidebar
Expand Down Expand Up @@ -294,7 +294,7 @@ async def attached(self, screen: "MainScreen") -> None:
await screen.prepare_presentation()


class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, ToadNode,
class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SidebarSessionView[SessionThreadSidebar], ToadNode,
scopes={HiddenActions: (SessionNavigationAction,)}, can_focus=False):
footer_compact = True

Expand All @@ -310,7 +310,6 @@ class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, Vi

def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]:
return self.content.keyboard_scopes(self)
thread_sidebar = getters.query_one(SessionThreadSidebar)

@property
def side_bar(self):
Expand All @@ -332,7 +331,6 @@ def __init__(
self._identity = binding.initial_identity(NoThreadIdentity())
self._session_pk = session_pk
self._initial_prompt = initial_prompt
self._thread_sidebar_state = SidebarState()
self._project_panel: ProjectPanel | None = None
self.content: ScreenContent = OpeningContent()
self.presentation = OperationalSessionPresentation()
Expand Down Expand Up @@ -436,12 +434,12 @@ def activate_session(self) -> None:
if self._project_panel is not None:
self.call_after_refresh(self._project_panel.refresh_if_visible)

def sidebars(self):
return (self.thread_sidebar,)
def make_thread_sidebar(self) -> SessionThreadSidebar:
return SessionThreadSidebar(self)

def compose(self) -> ComposeResult:
with containers.Center().set_styles(layout=self.app.workspace_chrome):
yield SessionThreadSidebar(self)
yield self.thread_sidebar
with containers.Vertical(id="session-content"):
yield self.content.render(self)
yield self.presentation.compose_content(self)
Expand Down
35 changes: 31 additions & 4 deletions src/toad/screens/session_view.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Logical session surfaces hosted by the one native WorkspaceScreen."""

from abc import abstractmethod
from functools import cached_property
from textual.containers import Vertical
from textual.reactive import reactive
from contextlib import contextmanager
Expand All @@ -10,6 +12,8 @@
from toad.thread_identity import SessionIdentity, NoThreadIdentity
from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope
from toad.navigation_target import NavigationTarget, NoTarget
from toad.session_tracker import SidebarState
from toad.widgets.side_bar import SideBar, ThreadSidebar
from toad.widget_actions import DeclaredWidgetActions
from toad.widgets.message_notifications import (
NothingToRead,
Expand All @@ -24,9 +28,9 @@ class SessionView(DeclaredWidgetActions, VisibleReadMarking, CoreEventReceiver,

DEFAULT_CSS = "SessionView { width: 1fr; height: 1fr; }"

def __init__(self, *args, **kwargs):
self._identity: SessionIdentity = NoThreadIdentity()
super().__init__(*args, **kwargs)
# The absent identity is an immutable value; concrete bindings replace it
# on the instance. Native construction stays with Vertical/Widget.
_identity: SessionIdentity = NoThreadIdentity()

def close_core_events(self) -> None:
"""Retire this logical source's descendants before its tab entry disappears."""
Expand Down Expand Up @@ -89,7 +93,7 @@ async def open_direct_messages(self) -> None:
"Direct messages are unavailable from this view", title="Direct messages"
)

def sidebars(self):
def sidebars(self) -> tuple[SideBar, ...]:
"""Supply this view's native sidebar resources through its declaration."""
return ()

Expand Down Expand Up @@ -158,3 +162,26 @@ async def close_presentation(self) -> None:
def retained_native_presentations(self):
"""Declare accepted native trees; non-native views own no such trees."""
return ()


class SidebarSessionView[SidebarT: ThreadSidebar](SessionView):
"""One session-owned sidebar survives construction, mounting and retirement.

Composition mounts this resource; it does not create its identity. Chrome
may ask for membership before the native tree exists, and close still owns
the same resource after it has left that tree.
"""

@cached_property
def _thread_sidebar_state(self) -> SidebarState:
return SidebarState()

@abstractmethod
def make_thread_sidebar(self) -> SidebarT: ...

@cached_property
def thread_sidebar(self) -> SidebarT:
return self.make_thread_sidebar()

def sidebars(self) -> tuple[SidebarT, ...]:
return (self.thread_sidebar,)
4 changes: 2 additions & 2 deletions src/toad/session_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from agent_comms.messages import Message
from agent_comms.thread_presentation import ThreadPresentation
from textual.command import Provider
from textual.dom import DOMNode
from toad.widgets.side_bar import SideBar

from toad.agent_schema import AgentDefinition
from toad.comms_root import CoordinationAccess
Expand Down Expand Up @@ -362,7 +362,7 @@ def thread_identity(self) -> SessionIdentity: ...
def belongs_to_wire(self, root: str | Path) -> bool: ...

@abstractmethod
def sidebars(self) -> tuple[DOMNode, ...]: ...
def sidebars(self) -> tuple[SideBar, ...]: ...

@abstractmethod
def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: ...
Expand Down
Loading
Loading