diff --git a/CursorLang.Agent.Tests/EndToEndTests.cs b/CursorLang.Agent.Tests/EndToEndTests.cs
index d79e5b2..28d874a 100644
--- a/CursorLang.Agent.Tests/EndToEndTests.cs
+++ b/CursorLang.Agent.Tests/EndToEndTests.cs
@@ -133,6 +133,34 @@ public sealed partial class EndToEndTests
Assert.False(launch.Process.HasExited);
}
+ ///
+ /// "Exit" in the tray menu ends the application whole: the settings window goes with
+ /// the agent instead of staying on the screen belonging to nothing.
+ ///
+ ///
+ /// The menu itself is out of reach of a test — it is a TrackPopupMenuEx menu
+ /// with a modal loop of its own — so what is checked is the request the menu makes.
+ /// The settings window here is the real one, started by the agent, and it must be
+ /// listening by the time it is on the screen.
+ ///
+ [Fact]
+ public void The_agents_exit_closes_the_settings_window()
+ {
+ using Launch launch = Launch.Start();
+ launch.WaitForSettingsWindow();
+
+ Process settings = Launch.SettingsProcesses().Single();
+ try
+ {
+ Assert.True(SettingsCloseSignal.RequestClose(), "the settings window was not listening");
+ Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived the agent's exit");
+ }
+ finally
+ {
+ settings.Dispose();
+ }
+ }
+
/// A started application that shuts down together with the check.
private sealed class Launch : IDisposable
{
diff --git a/CursorLang.Agent/Agent.cs b/CursorLang.Agent/Agent.cs
index 6c95f70..c114b9b 100644
--- a/CursorLang.Agent/Agent.cs
+++ b/CursorLang.Agent/Agent.cs
@@ -125,5 +125,12 @@ internal sealed class Agent : IDisposable
private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
- private void OnExitRequested(object? sender, EventArgs e) => _window.Quit();
+ // "Exit" means the application, not just the background half of it. A settings
+ // window left open would outlive the tray icon it was opened from, so it is asked
+ // to close first — it may be the very window the user is looking at
+ private void OnExitRequested(object? sender, EventArgs e)
+ {
+ SettingsCloseSignal.RequestClose();
+ _window.Quit();
+ }
}
diff --git a/CursorLang.Core.Tests/Services/SettingsCloseSignalTests.cs b/CursorLang.Core.Tests/Services/SettingsCloseSignalTests.cs
new file mode 100644
index 0000000..ceeb4de
--- /dev/null
+++ b/CursorLang.Core.Tests/Services/SettingsCloseSignalTests.cs
@@ -0,0 +1,115 @@
+using System.Collections.Concurrent;
+using CursorLang.Core.Services;
+using CursorLang.Tests.Shared;
+
+namespace CursorLang.Core.Tests.Services;
+
+///
+/// The one thing the agent says to the settings window: quit with me.
+///
+///
+/// The event names here are the tests' own. The application's name is fixed, and a test
+/// listening on it would answer for a settings window someone is using — or, signalling,
+/// close it.
+///
+public sealed class SettingsCloseSignalTests
+{
+ [Fact]
+ public void The_request_reaches_the_settings_window()
+ {
+ string suffix = UniqueSuffix();
+ var signal = new SettingsCloseSignal(suffix);
+
+ ConcurrentQueue requests = new();
+ signal.CloseRequested += (_, e) => requests.Enqueue(e);
+
+ try
+ {
+ signal.Listen();
+
+ Assert.True(RequestApart(suffix), "the request found nobody listening");
+ Pump.WaitFor(() => !requests.IsEmpty, "the settings window got the request to close");
+ }
+ finally
+ {
+ signal.Dispose();
+ }
+ }
+
+ // The usual case: the user quits from the tray with no settings window on the screen
+ [Fact]
+ public void A_request_with_no_settings_window_open_passes_without_consequence()
+ {
+ Assert.False(RequestApart(UniqueSuffix()));
+ }
+
+ // The window has closed on its own, and the process is on its way out anyway
+ [Fact]
+ public void No_request_arrives_after_the_window_is_gone()
+ {
+ string suffix = UniqueSuffix();
+ var signal = new SettingsCloseSignal(suffix);
+
+ ConcurrentQueue requests = new();
+ signal.CloseRequested += (_, e) => requests.Enqueue(e);
+
+ signal.Listen();
+ signal.Dispose();
+
+ RequestApart(suffix);
+ Pump.Pause(TimeSpan.FromMilliseconds(80));
+
+ Assert.Empty(requests);
+ }
+
+ [Fact]
+ public void Listening_twice_leaves_one_listener()
+ {
+ string suffix = UniqueSuffix();
+ var signal = new SettingsCloseSignal(suffix);
+
+ var requests = 0;
+ signal.CloseRequested += (_, _) => Interlocked.Increment(ref requests);
+
+ try
+ {
+ signal.Listen();
+ signal.Listen();
+
+ Assert.True(RequestApart(suffix));
+ Pump.WaitFor(() => Volatile.Read(ref requests) > 0, "the request arrived");
+ Pump.Pause(TimeSpan.FromMilliseconds(80));
+
+ Assert.Equal(1, Volatile.Read(ref requests));
+ }
+ finally
+ {
+ signal.Dispose();
+ }
+ }
+
+ [Fact]
+ public void Closing_without_listening_passes_without_consequence()
+ {
+ var signal = new SettingsCloseSignal(UniqueSuffix());
+
+ signal.Dispose();
+ signal.Dispose();
+ }
+
+ // Every test gets a namespace of kernel objects of its own
+ private static string UniqueSuffix() => "." + Guid.NewGuid().ToString("N");
+
+ ///
+ /// Asks for the close the way the agent does it — from another process, and here
+ /// from another thread, which is as foreign as a test can get.
+ ///
+ private static bool RequestApart(string suffix)
+ {
+ var heard = false;
+
+ Pump.RunApart(() => heard = SettingsCloseSignal.RequestClose(suffix));
+
+ return heard;
+ }
+}
diff --git a/CursorLang.Core/Services/SettingsCloseSignal.cs b/CursorLang.Core/Services/SettingsCloseSignal.cs
new file mode 100644
index 0000000..5794a93
--- /dev/null
+++ b/CursorLang.Core/Services/SettingsCloseSignal.cs
@@ -0,0 +1,98 @@
+namespace CursorLang.Core.Services;
+
+///
+/// Tells the settings window that the agent is quitting and it is to close with it.
+///
+///
+/// The one thing the agent says to the settings window, and the mirror image of
+/// . "Exit" in the tray menu means the application is done
+/// with, and a settings window left alone on the screen after it is a window belonging
+/// to nothing: the tray icon it was opened from is gone, and closing it would be the
+/// user's only remaining move.
+///
+/// A named event rather than a window message, because the agent has no handle to send
+/// one to: the settings window lives in a process the agent starts and deliberately
+/// does not keep hold of. The name has no Global prefix, so it lives in the
+/// session namespace — same reasoning as , and the same
+/// consequence: with fast user switching each user's halves talk to their own.
+///
+/// Only the settings window creates the object; the agent opens what is already there
+/// and stays silent when there is nothing. Were it the other way round, the request
+/// would sit in an auto-reset event waiting for the next settings window to open and
+/// close it the moment it did.
+///
+internal sealed class SettingsCloseSignal : IDisposable
+{
+ private const string EventName = "CursorLang.CloseSettings";
+
+ private readonly string _eventName;
+
+ private EventWaitHandle? _request;
+ private RegisteredWaitHandle? _wait;
+
+ ///
+ /// Listens on the name the two halves agree on.
+ ///
+ ///
+ /// A namespace of its own. Empty for the application; the tests pass one so that
+ /// they do not answer for — or worse, close — a settings window someone is using.
+ ///
+ internal SettingsCloseSignal(string nameSuffix = "") => _eventName = EventName + nameSuffix;
+
+ /// The agent asks for the window to be closed.
+ ///
+ /// Raised on a thread pool thread, wherever the wait happened to be answered — a
+ /// window obeys only its own, so the handler has to get back to it.
+ ///
+ internal event EventHandler? CloseRequested;
+
+ ///
+ /// Asks the settings window of this session, if one is open, to close. Silence is
+ /// a normal answer: most of the time the user quits with no window on the screen.
+ ///
+ /// Whether there was anybody to hear it.
+ internal static bool RequestClose(string nameSuffix = "")
+ {
+ if (!EventWaitHandle.TryOpenExisting(EventName + nameSuffix, out EventWaitHandle? request))
+ {
+ return false;
+ }
+
+ using (request)
+ {
+ return request.Set();
+ }
+ }
+
+ /// Starts waiting for the request. Called once, by the settings window.
+ internal void Listen()
+ {
+ if (_request is not null)
+ {
+ return;
+ }
+
+ _request = new EventWaitHandle(false, EventResetMode.AutoReset, _eventName);
+
+ // As in the gate: the thread pool holds the wait, there is no reason to keep a
+ // thread of our own for a request that may never come
+ _wait = ThreadPool.RegisterWaitForSingleObject(
+ _request,
+ OnCloseSignalled,
+ state: null,
+ Timeout.Infinite,
+ executeOnlyOnce: false);
+ }
+
+ public void Dispose()
+ {
+ _wait?.Unregister(null);
+ _wait = null;
+
+ _request?.Dispose();
+ _request = null;
+ }
+
+ private void OnCloseSignalled(object? state, bool timedOut) =>
+ CloseRequested?.Invoke(this, EventArgs.Empty);
+}
diff --git a/CursorLang.Settings/App.xaml.cs b/CursorLang.Settings/App.xaml.cs
index 9174ee3..8e5c3f8 100644
--- a/CursorLang.Settings/App.xaml.cs
+++ b/CursorLang.Settings/App.xaml.cs
@@ -26,6 +26,7 @@ public partial class App : Application
{
private ServiceProvider? _services;
private SingleInstanceGate? _instanceGate;
+ private SettingsCloseSignal? _closeSignal;
protected override void OnStartup(StartupEventArgs e)
{
@@ -42,6 +43,10 @@ public partial class App : Application
_instanceGate = gate;
_instanceGate.ActivationRequested += OnActivationRequested;
+ _closeSignal = new SettingsCloseSignal();
+ _closeSignal.CloseRequested += OnCloseRequested;
+ _closeSignal.Listen();
+
var services = new ServiceCollection();
ConfigureServices(services);
_services = services.BuildServiceProvider();
@@ -61,6 +66,12 @@ public partial class App : Application
{
_services?.Dispose();
+ if (_closeSignal is not null)
+ {
+ _closeSignal.CloseRequested -= OnCloseRequested;
+ _closeSignal.Dispose();
+ }
+
if (_instanceGate is not null)
{
_instanceGate.ActivationRequested -= OnActivationRequested;
@@ -95,6 +106,27 @@ public partial class App : Application
private void OnActivationRequested(object? sender, EventArgs e) =>
Dispatcher.BeginInvoke(ShowMainWindow);
+ // The agent is quitting. Answered on a thread pool thread, and the window is closed
+ // on its own one
+ private void OnCloseRequested(object? sender, EventArgs e) =>
+ Dispatcher.BeginInvoke(CloseMainWindow);
+
+ ///
+ /// Closes the window the way the title bar button does, so that everything hanging
+ /// off closing happens; ends the process
+ /// after it. Without a window there is nothing to close and the process simply ends.
+ ///
+ private void CloseMainWindow()
+ {
+ if (MainWindow is { } window)
+ {
+ window.Close();
+ return;
+ }
+
+ Shutdown();
+ }
+
private void ShowMainWindow()
{
if (MainWindow is not { } window)
diff --git a/README.RU.md b/README.RU.md
index 33765e5..7db9213 100644
--- a/README.RU.md
+++ b/README.RU.md
@@ -84,20 +84,28 @@ MSIX всегда выполняются в контексте вошедшег
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
в фоновый процесс.
-Связь между ними — только `settings.json`. Окно пишет его целиком, во временный
-файл, который одним движением встаёт на место, и посылает агенту
-зарегистрированное оконное сообщение, по которому тот перечитывает файл.
+Всё, что окно сообщает агенту, идёт только через `settings.json`. Окно пишет его
+целиком, во временный файл, который одним движением встаёт на место, и посылает
+агенту зарегистрированное оконное сообщение, по которому тот перечитывает файл.
Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли
единственного источника правды. Работающий агент необязателен — без него окно
работает так же. За файлом никто не следит: писатель у него один, и он сам
сообщает о записи.
+В обратную сторону агент говорит одно слово, и то последнее: пункт «Выход» в меню
+трея просит открытое окно настроек закрыться, прежде чем агент уйдёт, — чтобы на
+экране не осталось окна, которому больше ничего не принадлежит. Просьба идёт
+именованным событием сессии, а не оконным сообщением: агент запускает этот процесс
+и намеренно не держит на него ссылки. Чаще всего слушать её некому, и это
+нормальный ответ.
+
## Трей
Окно настроек — гость на экране, а не само приложение: оно показывается после
установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в
заголовке окна означают ровно то, что написано: окно закрывается, а его процесс
-завершается. Выход из самого приложения — пункт «Выход» в меню трея.
+завершается. Выход из самого приложения — пункт «Выход» в меню трея, и открытое
+окно настроек он закрывает вместе с агентом.
Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку,
выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF
diff --git a/README.md b/README.md
index 9ad357d..d71e85c 100644
--- a/README.md
+++ b/README.md
@@ -81,19 +81,26 @@ the next time the window is asked for.
the layout tracking. No UI, and it must stay that way — whatever lands there lands
in the background process.
-The connection between the two is `settings.json` and nothing else. The window
-writes it — whole, into a temporary file moved into place in one step — and then
+Everything the window has to say to the agent goes through `settings.json` and
+nothing else. The window writes it — whole, into a temporary file moved into place in one step — and then
posts a registered window message to the agent, which re-reads. The message carries
no data: sending the changed values along would make the file stop being the only
source of truth. An agent that is not running is a normal case — the window works the
same. Nobody watches the file: it has one writer, and that writer speaks up.
+The one word in the other direction is the last one: Exit in the tray menu asks an
+open settings window to close before the agent goes, so that no window is left
+belonging to nothing. It travels as a named event of the session rather than a
+window message — the agent starts that process and deliberately keeps no handle to
+it. Nobody listening is the usual case, and it is a normal answer.
+
## The tray
The settings window is a guest on the screen rather than the app itself: it shows
up after the installation and whenever the icon or its menu is asked for. Both
buttons in its title bar mean what they say — the window closes and its process
-ends. The way out of the app itself is the Exit item of the tray menu.
+ends. The way out of the app itself is the Exit item of the tray menu — and it
+closes an open settings window along with the agent.
The menu of the icon is a system one, drawn by Windows. Its captions still follow
the language chosen in the settings, but the theme no longer reaches it: a WPF