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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Changed

- Smarter filesystem monitoring to avoid refreshes where nothing has changed

Copilot AI Jan 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a spelling error in this line. The word "were" should be "where" to make the sentence grammatically correct: "Smarter filesystem monitoring to avoid refreshes where nothing has changed".

Suggested change
- Smarter filesystem monitoring to avoid refreshes where nothing has changed
- Smarter filesystem monitoring to avoid refreshes where nothing has changed

Copilot uses AI. Check for mistakes.

## [0.5.19] - 2026-01-04

### Added
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies = [
"google-re2>=1.1.20251105",
"notify-py>=0.3.43",
"pyperclip>=1.11.0",
"watchdog>=6.0.0",
]

[tool.uv.workspace]
Expand Down
76 changes: 76 additions & 0 deletions src/toad/directory_watcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import asyncio
from pathlib import Path
import rich.repr

from textual.message import Message
from textual.widget import Widget


from watchdog.events import (
FileSystemEvent,
FileSystemEventHandler,
FileCreatedEvent,
FileDeletedEvent,
FileMovedEvent,
DirCreatedEvent,
DirDeletedEvent,
DirMovedEvent,
)
from watchdog.observers import Observer


class DirectoryChanged(Message):
"""The directory was changed."""


@rich.repr.auto
class DirectoryWatcher(FileSystemEventHandler):
"""Watch for changes to a directory, ignoring purely file data changes."""

def __init__(self, path: Path, widget: Widget) -> None:
"""

Args:
path: Root path to monitor.
widget: Widget which will receive the `DirectoryChanged` event.
"""
self._path = path
Comment on lines +35 to +37

Copilot AI Jan 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The on_any_event method posts a message but doesn't handle potential exceptions. If the widget is being torn down or the message queue is full, post_message() could raise an exception, causing the watchdog observer to potentially stop working or log errors. Consider wrapping this in a try-except block to gracefully handle edge cases where the widget might no longer be mounted.

Copilot uses AI. Check for mistakes.
self._widget = widget
self._observer = Observer()
super().__init__()

def on_any_event(self, event: FileSystemEvent) -> None:
"""Send DirectoryChanged event when the FS is updated."""
self._widget.post_message(DirectoryChanged())

def __rich_repr__(self) -> rich.repr.Result:
yield self._path
yield self._widget

def start(self) -> None:
"""Start the watcher."""

self._observer.schedule(
self,
str(self._path),
recursive=True,
event_filter=[
FileCreatedEvent,
Comment on lines +46 to +58

Copilot AI Jan 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The event_filter parameter may not be available in current versions of the watchdog library (as of early 2025, the standard API uses event_handler, path, and recursive parameters). If this is a feature from a newer version, verify that watchdog 6.0.0 exists and supports this parameter. Otherwise, consider implementing event filtering by overriding specific event handler methods (like on_created, on_deleted, on_moved) instead of using on_any_event, or filter events within the on_any_event method using isinstance() checks.

Copilot uses AI. Check for mistakes.
FileDeletedEvent,
FileMovedEvent,
DirCreatedEvent,
DirDeletedEvent,
DirMovedEvent,
],
)
self._observer.start()

Comment on lines +50 to +67

Copilot AI Jan 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The start() method is called synchronously (line 1208) but could potentially block if the Observer takes time to initialize. Consider making this method async and using asyncio.to_thread() similar to the stop() method to avoid blocking the event loop during startup. This would ensure consistent async behavior for both lifecycle methods.

Suggested change
def start(self) -> None:
"""Start the watcher."""
self._observer.schedule(
self,
str(self._path),
recursive=True,
event_filter=[
FileCreatedEvent,
FileDeletedEvent,
FileMovedEvent,
DirCreatedEvent,
DirDeletedEvent,
DirMovedEvent,
],
)
self._observer.start()
async def start(self) -> None:
"""Start the watcher."""
def run() -> None:
"""Start the observer in a thread."""
self._observer.schedule(
self,
str(self._path),
recursive=True,
event_filter=[
FileCreatedEvent,
FileDeletedEvent,
FileMovedEvent,
DirCreatedEvent,
DirDeletedEvent,
DirMovedEvent,
],
)
self._observer.start()
await asyncio.to_thread(run)

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +67

Copilot AI Jan 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observer is stopped with a 1-second timeout in the join() call. If the observer thread doesn't stop within this timeout, it will be left running. Consider checking the return value or adding error handling to ensure the thread is properly cleaned up. Alternatively, document why a 1-second timeout is sufficient and what happens if it's exceeded.

Copilot uses AI. Check for mistakes.
async def stop(self) -> None:
"""Stop the watcher."""

def close() -> None:
"""Close the observer in a thread."""
self._observer.stop()
self._observer.join(timeout=1)

await asyncio.to_thread(close)
25 changes: 23 additions & 2 deletions src/toad/widgets/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from toad.acp.agent import Mode
from toad.answer import Answer
from toad.agent import AgentBase, AgentReady, AgentFail
from toad.directory_watcher import DirectoryWatcher, DirectoryChanged
from toad.history import History
from toad.widgets.flash import Flash
from toad.widgets.menu import Menu
Expand Down Expand Up @@ -321,6 +322,9 @@ def __init__(self, project_path: Path, agent: AgentData | None = None) -> None:
self._turn_count = 0
self._shell_count = 0

self._directory_changed = False

Copilot AI Jan 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _directory_changed flag is accessed from multiple contexts without synchronization: it's set in on_directory_changed (which is called from the watchdog observer thread via post_message), and checked/reset in on_terminal_finalized and agent_turn_over.

While Textual's message queue should serialize message handling, there's a potential race condition if filesystem events occur rapidly. Consider using atomic operations or adding a comment explaining the thread-safety guarantees provided by Textual's event system.

Copilot uses AI. Check for mistakes.
self._directory_watcher: DirectoryWatcher | None = None

@property
def agent_title(self) -> str | None:
if self._agent_data is not None:
Expand Down Expand Up @@ -433,6 +437,11 @@ def add_focusable_terminal(self, terminal: Terminal) -> None:
if not terminal.is_finalized:
self._focusable_terminals.append(terminal)

@on(DirectoryChanged)
def on_directory_changed(self, event: DirectoryChanged) -> None:
event.stop()
self._directory_changed = True

@on(Terminal.Finalized)
def on_terminal_finalized(self, event: Terminal.Finalized) -> None:
"""Terminal was finalized, so we can remove it from the list."""
Expand All @@ -441,6 +450,9 @@ def on_terminal_finalized(self, event: Terminal.Finalized) -> None:
except ValueError:
pass
self.prompt.project_directory_updated()
if self._directory_changed:
self._directory_changed = False
self.post_message(messages.ProjectDirectoryUpdated())

@on(Terminal.AlternateScreenChanged)
def on_terminal_alternate_screen_(
Expand Down Expand Up @@ -593,6 +605,8 @@ async def on_agent_ready(self) -> None:
async def on_unmount(self) -> None:
if self.agent is not None:
await self.agent.stop()
if self._directory_watcher is not None:
await self._directory_watcher.stop()
if self._agent_data is not None and self.session_start_time is not None:
session_time = monotonic() - self.session_start_time
await self.app.capture_event(
Expand Down Expand Up @@ -712,8 +726,12 @@ async def agent_turn_over(self, stop_reason: str | None) -> None:
await self._loading.remove()
self._agent_response = None
self._agent_thought = None
self.post_message(messages.ProjectDirectoryUpdated())
self.prompt.project_directory_updated()

if self._directory_changed:
self._directory_changed = False
self.post_message(messages.ProjectDirectoryUpdated())
self.prompt.project_directory_updated()

self._turn_count += 1

if stop_reason != "end_turn":
Expand Down Expand Up @@ -1185,6 +1203,9 @@ async def watch_agent_ready(self, ready: bool) -> None:
with suppress(asyncio.TimeoutError):
async with asyncio.timeout(2.0):
await self.shell.wait_for_ready()
if ready:
self._directory_watcher = DirectoryWatcher(self.project_path, self)
self._directory_watcher.start()
Comment on lines +1206 to +1208

Copilot AI Jan 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential resource leak: the directory watcher is created when agent_ready becomes True, but if agent_ready transitions from True back to False (e.g., when the agent changes as seen in watch_agent at line 1200), the existing watcher is not stopped before creating a new one. This could lead to multiple watchers running simultaneously or orphaned observer threads. Consider stopping any existing watcher before creating a new one, or only create the watcher if one doesn't already exist.

Suggested change
if ready:
self._directory_watcher = DirectoryWatcher(self.project_path, self)
self._directory_watcher.start()
existing_watcher = getattr(self, "_directory_watcher", None)
if not ready:
# Agent is no longer ready; stop any existing directory watcher.
if existing_watcher is not None:
existing_watcher.stop()
self._directory_watcher = None
return
# Agent is ready; restart the directory watcher to reflect current state.
if existing_watcher is not None:
existing_watcher.stop()
self._directory_watcher = DirectoryWatcher(self.project_path, self)
self._directory_watcher.start()

Copilot uses AI. Check for mistakes.
if ready and (agent_data := self._agent_data) is not None:
welcome = agent_data.get("welcome", None)
if welcome is not None:
Expand Down