Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
438ac1f
StyledRows owns the receipt that its rows arrived
trissim Nov 7, 2026
7d50a10
welcome_view waits for the opened thread's tab to be selected, not on…
trissim Nov 7, 2026
f676a66
channel_any_mode waits for the bus to record the toggle
trissim Nov 7, 2026
f99b41d
ACP log controls are pressed when they accept a press
trissim Nov 7, 2026
8f85e0b
A taller status waits for its page to measure again
trissim Nov 7, 2026
364d4cd
A sender link is aimed at once the message's status row has lifted th…
trissim Nov 7, 2026
17516c1
Tab strip tests wait for the underline's slide on Toad's own clock
trissim Nov 7, 2026
5172c14
Saved view test waits for its chat to mount and its read-only decision
trissim Nov 7, 2026
53ff90d
Clicks on a sent message wait for its delivery status
trissim Nov 7, 2026
e15cf1e
One owner for 'drawn where it will be clicked'
trissim Nov 7, 2026
a1a0138
A read acknowledgement revoked in flight leaves the read to the work …
trissim Nov 7, 2026
a3206f2
A page read on a view that lost its binding reads again instead of fa…
trissim Nov 7, 2026
23e4d2c
A history step moves the visit cursor when it is taken, not when its …
trissim Nov 7, 2026
8cae872
A commit's prepared page publishes only with its commit
trissim Nov 7, 2026
dfc9cc5
A history that has left its window holds no current source snapshot
trissim Nov 7, 2026
f3bb131
Sidebar direction test reads the arrows once both bars recompose them
trissim Nov 7, 2026
cb2fe5e
Relock Textual to e54d6c4 (OpenHCSDev/textual#257: the pilot settles …
trissim Nov 7, 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
19 changes: 14 additions & 5 deletions src/toad/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
from toad.terminal_origin import TerminalOrigin
from toad.clipboard import Clipboard
from toad.constants import WELCOME
from toad.tab_identity import TabIdentity
from toad.tab_order import TabOrder
from toad.thread_navigation import ThreadNavigator
from toad.session_navigation import SessionNavigation
Expand Down Expand Up @@ -189,7 +190,7 @@ def __init__(
self.sidebar_state = SidebarState()
self._mode_switch_lock = asyncio.Lock()
self.tab_order = TabOrder(
lambda identity, index: self.select_session(identity.mode_name, history_index=index),
lambda identity: self.show_history_step(identity.mode_name),
home=WELCOME,
db=DB(),
scopes=connection.tab_scopes(),
Expand Down Expand Up @@ -386,12 +387,20 @@ async def prepare_session(self, mode: str) -> SessionScope:
async with self._mode_switch_lock:
return await self.workspace_sessions.prepare(mode)

def select_session(self, mode: str, *, history_index: int | None = None) -> AwaitComplete:
def select_session(self, mode: str) -> AwaitComplete:
"""A person's selection: its visit is recorded once the tab is shown."""
return self._switch(mode, self.tab_order.record_visit)

def show_history_step(self, mode: str) -> AwaitComplete:
"""A history step: TabOrder recorded its visit when the step was taken."""
return self._switch(mode, lambda identity: None)

def _switch(self, mode: str, visited: Callable[[TabIdentity], object]) -> AwaitComplete:
if mode != self.selected_mode:
self.navigation_reader.invalidate()
return AwaitComplete(self._switch_mode_ready(mode, history_index=history_index))
return AwaitComplete(self._switch_mode_ready(mode, visited))

async def _switch_mode_ready(self, mode: str, *, history_index: int | None = None) -> None:
async def _switch_mode_ready(self, mode: str, visited: Callable[[TabIdentity], object]) -> None:
async with self._mode_switch_lock:
previous = self.selected_mode
try:
Expand All @@ -413,7 +422,7 @@ async def _switch_mode_ready(self, mode: str, *, history_index: int | None = Non
await self.workspace_screen.layout_navigation()
if mode != previous:
if entry := self.session_navigation.get(mode):
self.tab_order.record_visit(entry.identity, history_index)
visited(entry.identity)
self.events.publish(core_events.SessionSelected(mode, previous))
finally:
self.screen.refresh()
Expand Down
29 changes: 13 additions & 16 deletions src/toad/tab_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

class TabOrder:
def __init__(
self, focus: Callable[[TabIdentity, int | None], object], home: str, db: DB,
self, focus: Callable[[TabIdentity], object], home: str, db: DB,
scopes: tuple[TabScope, ...],
) -> None:
# What is shown while no tab is open.
Expand Down Expand Up @@ -105,12 +105,15 @@ def history_target(self, direction: int) -> tuple[int, TabIdentity] | None:
return None

def navigate(self, direction: int) -> None:
"""The cursor moves when the step is taken: a switch runs over several frames, and
a second step taken meanwhile counts from this one, not from where it started."""
if target := self.history_target(direction):
index, mode = target
if mode == self._visits[self._cursor]:
self.record_visit(mode, index)
else:
self._focus(mode, index)
switching = mode != self._visits[self._cursor]
self._cursor = index
self.events.publish(TabHistoryChanged())
if switching:
self._focus(mode)

def came_from(self, current: TabIdentity) -> str:
"""The tab selected before ``current``: the newest other visit, then the open tabs;
Expand All @@ -120,19 +123,13 @@ def came_from(self, current: TabIdentity) -> str:
current.mode_name if current in self else self.home,
)

def record_visit(self, mode: TabIdentity, history_index: int | None = None) -> None:
def record_visit(self, mode: TabIdentity) -> None:
"""A new visit: it drops the steps ahead of the cursor and becomes the newest."""
if mode not in self:
return
if (
history_index is not None
and 0 <= history_index < len(self._visits)
and self._visits[history_index] == mode
):
self._cursor = history_index
else:
del self._visits[self._cursor + 1 :]
self._visits.append(mode)
self._cursor = len(self._visits) - 1
del self._visits[self._cursor + 1 :]
self._visits.append(mode)
self._cursor = len(self._visits) - 1
self.events.publish(TabHistoryChanged())

async def close(self, modes: Collection[TabIdentity]) -> None:
Expand Down
14 changes: 12 additions & 2 deletions src/toad/transcript_publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from agent_comms.child_process import join_retirement
from agent_comms.coordination.errors import (
CoordinationReadUnavailable,
ReadAgain,
ReloadRequired,
StaleRevision,
TranscriptChanged,
Expand Down Expand Up @@ -345,7 +346,9 @@ def ready_to_commit(self) -> bool:
return False

async def read_page(self, scope, *, position: PagePosition, limit: PageLimit) -> TranscriptPage:
raise ValueError("Transcript paging requires a bound thread")
# A page request queued before the view lost its binding: its answer no longer
# stands, and the request a new binding makes reads again.
raise ReadAgain()

async def publish(self) -> bool:
"""Nothing to publish: the request is done."""
Expand Down Expand Up @@ -1020,10 +1023,17 @@ def require_commit(self) -> None:
async def mark_read(
self, through: TranscriptCursor, acknowledge: Callable[[TranscriptCursor], None], selected: Callable[[], bool]
) -> None:
"""A replaced source gets one fresh painted cursor, never a fatal read worker."""
"""A replaced source gets one fresh painted cursor, never a fatal read worker.

An acknowledgement is in-flight view work: if this source's work was revoked
while it ran (suspended, retired, closed or changed), the work that replaced it
paints and acknowledges its own cursor, so a stale one asks for nothing."""
generation = self.generation
try:
await asyncio.to_thread(acknowledge, through)
except TranscriptChanged:
if self.generation != generation:
return
try:
fresh = await self.refresh_cursor()
if selected():
Expand Down
3 changes: 2 additions & 1 deletion src/toad/transcript_source_preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ def current(self, owner: TranscriptSourcePreparation) -> bool:
Being on screen decides whether rows can be drawn, not whether the read may
finish: requiring it here discarded every read a tab started while it was
not visible, so the whole missed run loaded in one burst on return.
A history that has left its window holds no source, so no snapshot is current.
"""
return self == owner.source_snapshot()
return owner.is_attached and self == owner.source_snapshot()


class TranscriptSourcePreparation(CoreEventReceiver):
Expand Down
5 changes: 5 additions & 0 deletions src/toad/transcript_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,11 @@ def source(self, owner: ConversationHistory):
async def page(self, owner: TranscriptSourcePreparation, source: PreparedPageSource):
return self.initial.for_view()

def defer(self, owner: TranscriptSourcePreparation) -> LatestViewportState:
"""A commit's page publishes only with its commit, which records the painted cursor and
retries itself; drawn later on its own, its rows would show a source the cursor never reached."""
return NoLatestRequest()


@dataclass(frozen=True, eq=False)
class WaitingSourceWork[Result]:
Expand Down
8 changes: 8 additions & 0 deletions src/toad/widgets/styled_rows.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ class StyledRows(RestylesOnTheme, LinkedRows, ToadNode, Widget):
def styled_blocks(self) -> tuple[LineBlock, ...]:
"""The semantic rows the next render styles."""

@property
def rows_ready(self) -> bool:
"""Whether the drawn rows are the render worker's for the current blocks.

The rows come from the render processes, which no message queue owes, so a caller that
needs them (a click aimed at text, a copy of what is drawn) waits on this receipt."""
return self._render_worker.is_finished and not self._styled.unstyled(self)

def invalidate_lines(self) -> None:
"""Restyle for a new theme or link context; the drawn rows stand until then."""
self._request_render()
Expand Down
2 changes: 1 addition & 1 deletion tests/model/test_session_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def record(self, event: SessionChangedEvent, _subscription) -> None:


def test_session_tracker_publishes_only_changed_labels(tmp_path):
order = TabOrder(lambda identity, index: None, home="home", db=DB(tmp_path / "toad.db"),
order = TabOrder(lambda identity: None, home="home", db=DB(tmp_path / "toad.db"),
scopes=(TabScope("test-core"),))
tracker = SessionTracker(order)
details = tracker.new_session(AcpTab(1), title="old")
Expand Down
9 changes: 4 additions & 5 deletions tests/model/test_tab_visit_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,23 @@ async def test_history_branches_closing_prunes_it_and_a_new_kind_of_tab_needs_no
database = DB(tmp_path / "toad.db")
await database.create()
order = TabOrder(
lambda mode, index: focused.append((mode, index)),
focused.append,
home="home", db=database, scopes=(TabScope("test-core"),),
)
await order.load()
for mode in (first, second, third):
await order.open(mode)
order.record_visit(mode)
order.navigate(-1)
mode, index = focused[-1]
order.record_visit(mode, index)
mode = focused[-1]
await order.open(new_kind, after=mode)
order.record_visit(new_kind)
assert order.names == (first, second, new_kind, third)
assert order.history_target(+1) is None
assert order.previous(new_kind) == second.mode_name
await order.close({second, third})
order.navigate(-1)
assert focused[-1][0] == first
assert focused[-1] == first
assert order.previous(new_kind) == first.mode_name
assert order.project({first: 1, new_kind: 2, closed: 3}) == (1, 2)

Expand All @@ -39,7 +38,7 @@ async def test_home_is_shown_only_while_no_tab_is_open(tmp_path):
only, closed = PreviewTab(tmp_path / "only"), PreviewTab(tmp_path / "closed")
database = DB(tmp_path / "toad.db")
await database.create()
order = TabOrder(lambda mode, index: None, home="home", db=database, scopes=(TabScope("test-core"),))
order = TabOrder(lambda mode: None, home="home", db=database, scopes=(TabScope("test-core"),))
await order.load()
assert order.previous(closed) == "home"
await order.open(only)
Expand Down
5 changes: 2 additions & 3 deletions tests/native/test_native_tool_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from textual.selection import SELECT_ALL
from toad.widgets.tool_call import ToolCall
from toad.widgets.tool_content import ToolContent
from support.styling import awaits_styling

SOURCE = "# Native tool output\nvalue = 1\n"
CALLS = (
Expand Down Expand Up @@ -56,7 +55,7 @@ def drawn(tool, text):
read.scroll_visible(animate=False)
await ui.until(lambda: bool(read.query(ToolContent)), why="the read output")
code = read.query_one(ToolContent)
await ui.until(lambda: not awaits_styling(code._styled), why="prepared read rows")
await ui.until(lambda: code.rows_ready, why="prepared read rows")
assert "value = 1" in code.get_selection(SELECT_ALL)[0]
await ui.until(lambda: drawn(read, "value = 1"), why="the read output on screen")
read.set_expanded(False)
Expand All @@ -80,7 +79,7 @@ def drawn(tool, text):
bash.scroll_visible(animate=False)
await ui.until(lambda: bool(bash.query(ToolContent)), why="the bash output")
text = bash.query_one(ToolContent)
await ui.until(lambda: not awaits_styling(text._styled), why="prepared ANSI rows")
await ui.until(lambda: text.rows_ready, why="prepared ANSI rows")
assert "NATIVE_TOOL_TEXT" in text.get_selection(SELECT_ALL)[0]
assert "\x1b" not in text.get_selection(SELECT_ALL)[0]
await ui.until(lambda: drawn(bash, "NATIVE_TOOL_TEXT"), why="the bash output on screen")
Expand Down
24 changes: 1 addition & 23 deletions tests/support/textual_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from toad.widgets.side_bar import ThreadSidebar

import asyncio
from support.styling import awaits_styling


async def reveal_project_tree(ui):
Expand Down Expand Up @@ -54,27 +53,6 @@ async def reveal_session_details(ui, target=None):
return details


async def command_dialog(ui, *, seconds: float = 20):
"""Wait for the command dialog to be mounted, focused on its name field and clickable."""
from textual.widgets import Input
from toad.widgets.comms_command_dialog import CommandDialog

def usable():
dialog = ui.app.screen
if not (isinstance(dialog, CommandDialog) and dialog.is_mounted and dialog.is_attached):
return False
field = dialog.form.values.get("name")
entry = field.editor if field is not None else None
return (isinstance(entry, Input) and entry.is_mounted and entry.is_attached
and dialog.focused is entry and ui.app.focused is entry
and entry.region.width > 0 and entry.region.height > 0
and entry.region.offset in dialog.size.region
and dialog.get_widget_at(*entry.region.offset)[0] is entry)

await ui.until(usable, seconds=seconds, why="the command dialog")
return ui.app.screen


async def refresh_chat(view) -> None:
"""Run the chat view's history read now and wait for it; a view that is not shown reads nothing."""
history = view.message_history
Expand Down Expand Up @@ -135,7 +113,7 @@ async def wait_for_tool_diff(ui, tool):

content = tool.query_one(ToolContent)
# Replaced blocks keep their old rows until the render of the new ones lands, so wait for it.
await ui.until(lambda: content._render_worker.is_finished and not awaits_styling(content._styled),
await ui.until(lambda: content.rows_ready,
seconds=10, why="the styled tool rows")
await ui.step()
return content
Expand Down
25 changes: 25 additions & 0 deletions tests/support/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from typing import TYPE_CHECKING

from textual.actions import StyleAction
from textual.errors import NoWidget
from textual.geometry import Offset

from rich.cells import cell_len
from support.agents import AppAgents, command
Expand Down Expand Up @@ -254,6 +256,11 @@ async def until_shows(self, text: str, *, seconds: float = 10) -> None:
lambda: self.shows(text), seconds=seconds, why=f"{text!r} on screen"
)

async def until_delivery_shown(self) -> None:
"""A sent message's delivery status arrives from Core after its rows and lifts the
bottom-anchored page; a click aimed at the message waits for it."""
await self.until_shows("Sent · Observed")


class TextualUi(AppAgents, Ui):
"""Toad drawn by Textual, driven headless through Textual's pilot; its agents attach to that app."""
Expand All @@ -271,6 +278,24 @@ def textual(self) -> tuple[ToadApp, Pilot]:
def visible_lines(self) -> list[str]:
return [strip.text for strip in self.app.screen._compositor.render_strips()]

def clickable(self, widget, offset: tuple[int, int] = (0, 0)) -> bool:
"""Whether a click at `offset` in `widget` reaches it: a click lands on what the
published frame shows there, which a newly pushed or moving widget may not be yet."""
if not widget.is_attached:
return False
region, screen = widget.region, widget.screen
point = Offset(region.x + offset[0], region.y + offset[1])
if point not in region or point not in screen.size.region:
return False
try:
return screen.get_widget_at(*point)[0] is widget
except NoWidget:
# A screen pushed since the last frame has none of its widgets placed yet.
return False

async def until_clickable(self, widget, offset: tuple[int, int] = (0, 0), *, why: str) -> None:
await self.until(lambda: self.clickable(widget, offset), why=why)

async def menu(self, why: str) -> dict:
"""The open context menu's actions and their labels, once it shows."""
from toad.widgets.comms_menu import ContextMenu
Expand Down
Loading
Loading