From 5c199b049acd0c97c73d058223f1ddc298581712 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 16:40:45 +0000 Subject: [PATCH 01/10] Watch directory --- pyproject.toml | 1 + src/toad/widgets/conversation.py | 26 ++++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 357709a9..230ad138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 61a18939..5bb72857 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -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 @@ -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 + self._directory_watcher: DirectoryWatcher | None = None + @property def agent_title(self) -> str | None: if self._agent_data is not None: @@ -433,6 +437,12 @@ 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 + self.notify("directory changed") + @on(Terminal.Finalized) def on_terminal_finalized(self, event: Terminal.Finalized) -> None: """Terminal was finalized, so we can remove it from the list.""" @@ -441,6 +451,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_( @@ -593,6 +606,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: + 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( @@ -712,8 +727,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": @@ -1185,6 +1204,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() if ready and (agent_data := self._agent_data) is not None: welcome = agent_data.get("welcome", None) if welcome is not None: From b0e5d782fde91dfb017ffcdf8502f79afb6a9e10 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 16:43:39 +0000 Subject: [PATCH 02/10] directory watcher --- src/toad/directory_watcher.py | 57 ++++++++++++++++++++++++++++++++ src/toad/widgets/conversation.py | 1 - 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/toad/directory_watcher.py diff --git a/src/toad/directory_watcher.py b/src/toad/directory_watcher.py new file mode 100644 index 00000000..cf56f4ab --- /dev/null +++ b/src/toad/directory_watcher.py @@ -0,0 +1,57 @@ +import rich.repr + +from textual.message import Message +from textual.widget import Widget + +from pathlib import Path +import threading +from watchdog.events import ( + DirModifiedEvent, + FileModifiedEvent, + FileSystemEvent, + FileSystemEventHandler, +) +from watchdog.observers import Observer + + +class DirectoryChanged(Message): + """The directory was changed.""" + + +@rich.repr.auto +class DirectoryWatcher(threading.Thread, FileSystemEventHandler): + """Watch a directory for changes.""" + + def __init__(self, path: Path, widget: Widget) -> None: + self._path = path + self._widget = widget + self._observer = Observer() + self._exit_event = threading.Event() + super().__init__(name=repr(self)) + + def on_any_event(self, event: FileSystemEvent) -> None: + print(event) + if not isinstance(event, (DirModifiedEvent, FileModifiedEvent)): + # We aren't interested in modifications. Only when files are potentially added / removed + 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) + self._observer.start() + super().start() + + def stop(self) -> None: + """Stop the watcher.""" + self._exit_event.set() + + def run(self): + """Run the thread.""" + while not self._exit_event.wait(1): + # Nothing to do here. The Observer object is doing the work. + pass diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 5bb72857..75909ab8 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -441,7 +441,6 @@ def add_focusable_terminal(self, terminal: Terminal) -> None: def on_directory_changed(self, event: DirectoryChanged) -> None: event.stop() self._directory_changed = True - self.notify("directory changed") @on(Terminal.Finalized) def on_terminal_finalized(self, event: Terminal.Finalized) -> None: From ac29bfbd75b797bc994dac0f2d2161435b42904e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 16:44:30 +0000 Subject: [PATCH 03/10] changelog --- CHANGELOG.md | 6 ++++++ src/toad/directory_watcher.py | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2126ec..6fc7b58d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 were nothing has changed + ## [0.5.19] - 2026-01-04 ### Added diff --git a/src/toad/directory_watcher.py b/src/toad/directory_watcher.py index cf56f4ab..e2bf1a77 100644 --- a/src/toad/directory_watcher.py +++ b/src/toad/directory_watcher.py @@ -30,7 +30,6 @@ def __init__(self, path: Path, widget: Widget) -> None: super().__init__(name=repr(self)) def on_any_event(self, event: FileSystemEvent) -> None: - print(event) if not isinstance(event, (DirModifiedEvent, FileModifiedEvent)): # We aren't interested in modifications. Only when files are potentially added / removed self._widget.post_message(DirectoryChanged()) From 298489b96016057dc63e4d961dd951d18bbba2bf Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 16:54:45 +0000 Subject: [PATCH 04/10] no need for threads --- src/toad/directory_watcher.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/toad/directory_watcher.py b/src/toad/directory_watcher.py index e2bf1a77..3bd8002a 100644 --- a/src/toad/directory_watcher.py +++ b/src/toad/directory_watcher.py @@ -4,7 +4,6 @@ from textual.widget import Widget from pathlib import Path -import threading from watchdog.events import ( DirModifiedEvent, FileModifiedEvent, @@ -19,15 +18,14 @@ class DirectoryChanged(Message): @rich.repr.auto -class DirectoryWatcher(threading.Thread, FileSystemEventHandler): +class DirectoryWatcher(FileSystemEventHandler): """Watch a directory for changes.""" def __init__(self, path: Path, widget: Widget) -> None: self._path = path self._widget = widget self._observer = Observer() - self._exit_event = threading.Event() - super().__init__(name=repr(self)) + super().__init__() def on_any_event(self, event: FileSystemEvent) -> None: if not isinstance(event, (DirModifiedEvent, FileModifiedEvent)): @@ -43,14 +41,7 @@ def start(self) -> None: self._observer.schedule(self, str(self._path), recursive=True) self._observer.start() - super().start() def stop(self) -> None: """Stop the watcher.""" - self._exit_event.set() - - def run(self): - """Run the thread.""" - while not self._exit_event.wait(1): - # Nothing to do here. The Observer object is doing the work. - pass + self._observer.stop() From 68ae73222aeeadf1fd588fc4402082bae3c11c25 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 17:08:57 +0000 Subject: [PATCH 05/10] thread stop --- src/toad/directory_watcher.py | 11 +++++++++-- src/toad/widgets/conversation.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/toad/directory_watcher.py b/src/toad/directory_watcher.py index 3bd8002a..e1b619dd 100644 --- a/src/toad/directory_watcher.py +++ b/src/toad/directory_watcher.py @@ -1,3 +1,4 @@ +import asyncio import rich.repr from textual.message import Message @@ -42,6 +43,12 @@ def start(self) -> None: self._observer.schedule(self, str(self._path), recursive=True) self._observer.start() - def stop(self) -> None: + async def stop(self) -> None: """Stop the watcher.""" - self._observer.stop() + + def close() -> None: + """Close the observer in a thread.""" + self._observer.stop() + self._observer.join() + + await asyncio.to_thread(close) diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 75909ab8..93399dab 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -606,7 +606,7 @@ async def on_unmount(self) -> None: if self.agent is not None: await self.agent.stop() if self._directory_watcher is not None: - self._directory_watcher.stop() + 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( From 33896c0aa5d9420282b933893e0dfb4ec7ca815b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 17:17:07 +0000 Subject: [PATCH 06/10] comments --- src/toad/directory_watcher.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/toad/directory_watcher.py b/src/toad/directory_watcher.py index e1b619dd..17d1aab4 100644 --- a/src/toad/directory_watcher.py +++ b/src/toad/directory_watcher.py @@ -20,7 +20,7 @@ class DirectoryChanged(Message): @rich.repr.auto class DirectoryWatcher(FileSystemEventHandler): - """Watch a directory for changes.""" + """Watch for changes to a directory, ignoring purely file data changes.""" def __init__(self, path: Path, widget: Widget) -> None: self._path = path @@ -29,6 +29,10 @@ def __init__(self, path: Path, widget: Widget) -> None: super().__init__() def on_any_event(self, event: FileSystemEvent) -> None: + """We want to respond to files or directories being added, removed, or removed. + + We want to ignore any changes changes that are purely file data or metadata. + """ if not isinstance(event, (DirModifiedEvent, FileModifiedEvent)): # We aren't interested in modifications. Only when files are potentially added / removed self._widget.post_message(DirectoryChanged()) @@ -49,6 +53,6 @@ async def stop(self) -> None: def close() -> None: """Close the observer in a thread.""" self._observer.stop() - self._observer.join() + self._observer.join(timeout=1) await asyncio.to_thread(close) From 967b1cab0143e8f258e7919ba3898e4469f2645f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 17:19:20 +0000 Subject: [PATCH 07/10] disallowlist --- src/toad/directory_watcher.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/toad/directory_watcher.py b/src/toad/directory_watcher.py index 17d1aab4..a590cbb3 100644 --- a/src/toad/directory_watcher.py +++ b/src/toad/directory_watcher.py @@ -10,6 +10,12 @@ FileModifiedEvent, FileSystemEvent, FileSystemEventHandler, + FileCreatedEvent, + FileDeletedEvent, + FileMovedEvent, + DirCreatedEvent, + DirDeletedEvent, + DirMovedEvent, ) from watchdog.observers import Observer @@ -33,9 +39,19 @@ def on_any_event(self, event: FileSystemEvent) -> None: We want to ignore any changes changes that are purely file data or metadata. """ - if not isinstance(event, (DirModifiedEvent, FileModifiedEvent)): - # We aren't interested in modifications. Only when files are potentially added / removed - self._widget.post_message(DirectoryChanged()) + if not isinstance( + event, + ( + FileCreatedEvent, + FileDeletedEvent, + FileMovedEvent, + DirCreatedEvent, + DirDeletedEvent, + DirMovedEvent, + ), + ): + return + self._widget.post_message(DirectoryChanged()) def __rich_repr__(self) -> rich.repr.Result: yield self._path From c48e134aa4289065bd7075cf6c06185bd5d767bc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 17:26:09 +0000 Subject: [PATCH 08/10] filtering --- src/toad/directory_watcher.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/toad/directory_watcher.py b/src/toad/directory_watcher.py index a590cbb3..cb0ac8a3 100644 --- a/src/toad/directory_watcher.py +++ b/src/toad/directory_watcher.py @@ -6,8 +6,6 @@ from pathlib import Path from watchdog.events import ( - DirModifiedEvent, - FileModifiedEvent, FileSystemEvent, FileSystemEventHandler, FileCreatedEvent, @@ -35,22 +33,7 @@ def __init__(self, path: Path, widget: Widget) -> None: super().__init__() def on_any_event(self, event: FileSystemEvent) -> None: - """We want to respond to files or directories being added, removed, or removed. - - We want to ignore any changes changes that are purely file data or metadata. - """ - if not isinstance( - event, - ( - FileCreatedEvent, - FileDeletedEvent, - FileMovedEvent, - DirCreatedEvent, - DirDeletedEvent, - DirMovedEvent, - ), - ): - return + """Send DirectoryChanged event when the FS is updated.""" self._widget.post_message(DirectoryChanged()) def __rich_repr__(self) -> rich.repr.Result: @@ -60,7 +43,19 @@ def __rich_repr__(self) -> rich.repr.Result: def start(self) -> None: """Start the watcher.""" - self._observer.schedule(self, str(self._path), recursive=True) + self._observer.schedule( + self, + str(self._path), + recursive=True, + event_filter=[ + FileCreatedEvent, + FileDeletedEvent, + FileMovedEvent, + DirCreatedEvent, + DirDeletedEvent, + DirMovedEvent, + ], + ) self._observer.start() async def stop(self) -> None: From 7fa55a94d025f97bace3753edd40ca00439e7d96 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 17:31:00 +0000 Subject: [PATCH 09/10] docstrings --- src/toad/directory_watcher.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/toad/directory_watcher.py b/src/toad/directory_watcher.py index cb0ac8a3..9b84a505 100644 --- a/src/toad/directory_watcher.py +++ b/src/toad/directory_watcher.py @@ -1,10 +1,11 @@ import asyncio +from pathlib import Path import rich.repr from textual.message import Message from textual.widget import Widget -from pathlib import Path + from watchdog.events import ( FileSystemEvent, FileSystemEventHandler, @@ -27,6 +28,12 @@ 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 self._widget = widget self._observer = Observer() From 7dfa78ce650627beac6a3309712c1aacf31b6f59 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 4 Jan 2026 17:33:00 +0000 Subject: [PATCH 10/10] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc7b58d..3f9ad562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Smarter filesystem monitoring to avoid refreshes were nothing has changed +- Smarter filesystem monitoring to avoid refreshes where nothing has changed ## [0.5.19] - 2026-01-04