Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion src/toad/acp/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,10 @@ async def new(self) -> None:
cursor_token=cursor_token, queue_token=queue_token,
).consume_metadata(response.field_meta)

if self.supports_load:
if self.pk is not None:
await DB().session_started(self.pk, self.agent.session_id)
authority.require()
elif self.supports_load:
db = DB()
session_name = (
self.chosen_name
Expand Down
8 changes: 5 additions & 3 deletions src/toad/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ def __init__(
self.clipboard_transport = Clipboard.for_platform()
self.terminal_attention = TerminalAttention(self)

self._session_tracker = SessionTracker()
self.session_navigation = SessionNavigation(self)
self.thread_navigation = ThreadNavigator(self)
self.thread_actions = ThreadActions(self)
Expand All @@ -187,9 +186,11 @@ def __init__(
self.sidebar_layout = SidebarLayout()
self._mode_switch_lock = asyncio.Lock()
self.tab_order = TabOrder(
lambda mode, index: self.select_session(mode, history_index=index),
lambda identity, index: self.select_session(identity.mode_name, history_index=index),
home=mode or WELCOME,
db=DB(),
)
self._session_tracker = SessionTracker(self.tab_order)
self.coordination_access = CoordinationAccess(self)

from toad.file_kind import FileKind
Expand Down Expand Up @@ -369,7 +370,8 @@ async def _switch_mode_ready(self, mode: str, *, history_index: int | None = Non
await after_input()
await self.workspace_screen.layout_navigation()
if mode != previous:
self.tab_order.record_visit(mode, history_index)
if entry := self.session_navigation.get(mode):
self.tab_order.record_visit(entry.identity, history_index)
self.events.publish(core_events.SessionSelected(mode, previous))
finally:
self.screen.refresh()
Expand Down
16 changes: 12 additions & 4 deletions src/toad/application_lifetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

from toad import get_version
from toad.version import VersionMonitor
from toad.tab_identity.kinds import MailboxTab
from toad.tab_identity import TabTargetGone

if TYPE_CHECKING:
from ssl import SSLContext
Expand Down Expand Up @@ -146,16 +148,22 @@ async def start(self) -> None:
if app.saved_settings.ensure_installation():
app.call_later(self.usage.publish, "toad-install")
self.usage.publish("toad-run")
if self.resumed_session is None:
# No tab is open: the launch starts no agent and registers no thread.
await app.select_session(app.tab_order.home)
else:
for identity in await app.tab_order.load():
try:
await identity.restore(app.session_navigation)
except TabTargetGone as error:
await app.tab_order.close((identity,))
app.log.error(f"Removed stale tab {error.identity}: {error.reason}")
if self.resumed_session is not None:
# The thread's declaration decides how it opens, as from the sidebar.
navigation = await app.preparation.run_thread(self.launch_navigation)
await app.session_navigation.new(
MailboxTab(navigation.thread.incarnation, navigation.root),
partial(navigation.launch_screen, app),
original=(navigation.root, navigation.thread.incarnation),
)
if not app.tab_order.names:
await app.select_session(app.tab_order.home)
app.terminal_attention.attach()
app.set_timer(1, self.check_version)
app.run_worker(partial(self.set_process_title, "toad"), thread=True)
Expand Down
2 changes: 1 addition & 1 deletion src/toad/comms_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def tab(self, entry: SessionEntry, sessions: SessionNavigation):
return entry.unobserved_tab()

def tabs(self, sessions: SessionNavigation):
return {entry.mode: self.tab(entry, sessions) for entry in sessions.members}
return {entry.identity: self.tab(entry, sessions) for entry in sessions.members}
def require(self, access: CoordinationAccess, selected: RouteSelection):
return access.open_selected(selected)
def references_for(self, root: str) -> ReferenceContext:
Expand Down
56 changes: 42 additions & 14 deletions src/toad/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from toad import paths
from toad.agent_schema import AgentDefinition
from toad.tab_identity import TabIdentity

SessionTimestamp = Annotated[RecordedTimestamp, TimestampText, Column(storage=TextStorage)]

Expand All @@ -42,7 +43,7 @@ class Session(ToadTables, TypedTable, declared_name="sessions"):
id: Annotated[int | None, Column(primary_key=True, auto_increment=True)] = None
agent: str
agent_identity: str
agent_session_id: str
agent_session_id: str | None
title: str
protocol: str = "acp"
prompt_count: int = 0
Expand All @@ -52,6 +53,12 @@ class Session(ToadTables, TypedTable, declared_name="sessions"):
meta_json: SessionMeta = field(default_factory=SessionMeta)


@dataclass(frozen=True)
class OpenTabRow(ToadTables, TypedTable, declared_name="open_tabs"):
identity: Annotated[TabIdentity, Column(primary_key=True)]
position: int


@dataclass(frozen=True)
class ModelHistory(ToadTables, TypedTable, declared_name="model_history"):
agent_identity: Annotated[str, Column(primary_key=True)]
Expand All @@ -78,17 +85,30 @@ def remember(self, db: sqlite3.Connection) -> None:


class DB:
"""Toad's durable state; one transaction owner runs entirely off the UI thread."""
"""Toad's durable state; its transaction owner runs off the UI thread."""

def __init__(self):
self.path = paths.get_state() / "toad.db"
def __init__(self, path: Path | None = None):
self.path = path if path is not None else paths.get_state() / "toad.db"

def transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T:
with closing(SQLiteFile.writer(self.path)) as db, db:
return operation(db)

async def _transaction[T](self, operation: Callable[[sqlite3.Connection], T]) -> T:
def transaction():
with closing(SQLiteFile.writer(self.path)) as db, db:
return operation(db)
return await asyncio.to_thread(self.transaction, operation)

async def tab_roster(self, identities: tuple[TabIdentity, ...]) -> None:
def write(db):
OpenTabRow.delete(db)
for position, identity in enumerate(identities):
OpenTabRow(identity, position).upsert(db)

return await asyncio.to_thread(transaction)
await self._transaction(write)

async def tab_list(self) -> list[OpenTabRow]:
return await self._transaction(lambda db: OpenTabRow.select(
db, order_by=("position",),
))

async def record_model_usage(
self, agent_identity: str, model_id: str, *, last_used: int | None = None,
Expand All @@ -103,20 +123,20 @@ async def recent_models(self, agent_identity: str, limit: int = 20) -> list[str]
return [row.model_id for row in rows]

async def create(self) -> None:
def create(db):
ToadTables.install_schema(db, WriterOwnedFile())
await asyncio.to_thread(self.prepare)

await self._transaction(create)
def prepare(self) -> None:
self.transaction(lambda db: ToadTables.install_schema(db, WriterOwnedFile()))

async def session_new(
self,
title: str,
agent: str,
agent_identity: str,
agent_session_id: str,
agent_session_id: str | None,
protocol: str = "acp",
meta: SessionMeta | None = None,
) -> int | None:
) -> int:
row = Session(
title=title,
agent=agent,
Expand All @@ -125,7 +145,15 @@ async def session_new(
protocol=protocol,
meta_json=meta if meta is not None else SessionMeta(),
)
return await self._transaction(lambda db: row.insert(db).lastrowid)
def insert(db):
identity = row.insert(db).lastrowid
assert identity is not None, "A saved ACP session must have a SQLite row id"
return identity

return await self._transaction(insert)

async def session_started(self, id: int, agent_session_id: str) -> None:
await self._update_session(id, agent_session_id=agent_session_id)

async def _update_session(self, id: int, **changes) -> None:
def update(db):
Expand Down
3 changes: 3 additions & 0 deletions src/toad/navigation_preparation.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from toad.owner_preparation import ExistingOwnerPlan
from toad.session_tracker import CommsViewKey
from toad.tab_identity import SessionTabIdentity

ResultT = TypeVar("ResultT")

Expand Down Expand Up @@ -193,11 +194,13 @@ def __init__(
owner_mode: str,
request: ThreadNavigationRequest,
origin: ThreadOrigin,
identity: SessionTabIdentity,
) -> None:
from toad.screens.main import MainScreen

self.navigator, self.owner_mode = navigator, owner_mode
self.request, self.origin = request, origin
self.identity = identity
self.view = MainScreen.opening(request, origin, navigator.app)
self.task = asyncio.create_task(self.run(), name="thread-navigation")

Expand Down
2 changes: 1 addition & 1 deletion src/toad/navigation_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ async def open(self, context: NavigationContext) -> str:
class SessionTarget(NavigationTarget):
async def open(self, context: NavigationContext) -> str:
app = context.app
destination = context.owner_mode if app.session_tracker.get_session(context.owner_mode) else "store"
destination = context.owner_mode if app.session_navigation.session_details(context.owner_mode) else "store"
if app.selected_mode != destination:
await app.select_session(destination)
return app.selected_mode
Expand Down
14 changes: 9 additions & 5 deletions src/toad/screens/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from toad.session_presentation import OperationalSessionPresentation
from toad.session_scope import AgentBinding, NoAgent, ViewSessionOrigin
from toad.session_tracker import SessionDetails, SidebarState
from toad.tab_identity.history_origin import SessionHistoryOrigin
from toad.thread_identity import NoThreadIdentity, ThreadIdentity
from toad.widgets.channels_sidebar import ChannelsSidebar
from toad.widgets.conversation import Conversation
Expand Down Expand Up @@ -311,7 +312,7 @@ def bind_thread(self, owner, root: str) -> None:
def identity_changed(self, previous) -> None:
if self.id is not None:
self.app.session_tracker.bind_identity(
self.id, previous.thread_name(), self.thread_identity.thread_name()
self.session_details.identity, previous.thread_name(), self.thread_identity.thread_name()
)

@classmethod
Expand Down Expand Up @@ -364,6 +365,9 @@ def session_disposition(self) -> SessionDisposition:
return self.presentation.operational_binding.session_disposition

session_details: SessionDetails

def history_origin(self) -> SessionHistoryOrigin:
return SessionHistoryOrigin(self.session_details.identity)
"""The registered entry supplies its existing details before mounting this view."""

def make_conversation(self) -> Conversation:
Expand Down Expand Up @@ -559,26 +563,26 @@ def on_session_tabs_changed(self, message: CoreEventMessage) -> None:
def on_session_title_changed(self, message: CoreEventMessage) -> None:
"""The user or the agent process named this session; the tab keeps it."""
if self.id is not None:
self.app.session_tracker.name_session(self.id, message.event.name)
self.app.session_tracker.name_session(self.session_details.identity, message.event.name)

@handles(core_events.SessionThreadTitle)
def on_session_thread_title(self, message: CoreEventMessage) -> None:
"""The thread presented a title; an authored name keeps the tab's label."""
if (details := self.app.session_tracker.get_session(self.id)) is not None:
if (details := self.app.session_tracker.get_session(self.session_details.identity)) is not None:
if details.update_thread_title(message.event.title):
self.app.session_tracker.events.publish(SessionChangedEvent(self.id))

@handles(core_events.SessionSubtitleChanged)
def on_session_subtitle_changed(self, message: CoreEventMessage) -> None:
if self.id is not None:
self.app.session_tracker.update_session(
self.id, subtitle=message.event.subtitle
self.session_details.identity, subtitle=message.event.subtitle
)

@handles(core_events.SessionPathChanged)
def on_session_path_changed(self, message: CoreEventMessage) -> None:
if self.id is not None:
self.app.session_tracker.update_session(self.id, path=message.event.path)
self.app.session_tracker.update_session(self.session_details.identity, path=message.event.path)

def on_mount(self) -> None:
self.observe_core(self.app.events)
Expand Down
26 changes: 16 additions & 10 deletions src/toad/screens/welcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
from toad.navigation_target import channel_target, ThreadTarget
from toad.screens.session_view import SessionView
from toad.session_scope import ProjectSessionOrigin
from toad.tab_identity.history_origin import WelcomeHistoryOrigin
from toad.thread_identity import ThreadIdentity
from toad.widgets.comms_sidebar import CommsRow, NewSessionButton, ThreadStatusRow
from toad.application_actions import NewThreadAction
from toad.widgets.sidebar_tree import (
Expand Down Expand Up @@ -211,6 +213,9 @@ class WelcomeScreen(ProjectSessionOrigin, SessionView, can_focus=False):
"[b]↑ ↓[/b] choose [b]enter[/b] open"
)

def history_origin(self) -> WelcomeHistoryOrigin:
return WelcomeHistoryOrigin()

def __init__(self) -> None:
super().__init__()
app = cast("ToadApp", self.app)
Expand All @@ -235,19 +240,20 @@ def navigation_context(self) -> NavigationContext:
self.app, self.id, self.project_path, self.thread_identity.thread_name()
)

async def open_sidebar_target(self, target: NavigationTarget) -> str:
def read_history_identity(self, project: str) -> ThreadIdentity:
service = self.app.coordination_access.observation.observed().service
return ThreadIdentity(
service.messaging.user_identity(project).incarnation, str(service.root)
)

async def prepare_history_origin(self) -> None:
app = cast("ToadApp", self.app)
project = str(self.project_path)
from toad.thread_identity import ThreadIdentity

def identity():
service = app.coordination_access.observation.observed().service
return ThreadIdentity(
service.messaging.user_identity(project).incarnation, str(service.root)
)

with self.changing_identity():
self._identity = await app.preparation.run_thread(identity)
self._identity = await app.preparation.run_thread(self.read_history_identity, project)

async def open_sidebar_target(self, target: NavigationTarget) -> str:
await self.prepare_history_origin()
return await super().open_sidebar_target(target)

async def prepare_presentation(self) -> None:
Expand Down
Loading
Loading