diff --git a/CHANGELOG.md b/CHANGELOG.md
index f453bbf..6a8b4ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,12 @@ is not part of this repository.
## [Unreleased]
+### Changed
+
+- A minimised window no longer reads the list of services every second. It reads it again the
+ moment it is restored, so the list is current as soon as it is on screen. A window started
+ minimised, from a shortcut set to "Run: Minimized", reads it once and then waits the same way.
+
## [0.3.0] - 2026-09-25
### Added
diff --git a/src/Bws.Cli/CommandLine.cs b/src/Bws.Cli/CommandLine.cs
index 6b5020f..842e9cc 100644
--- a/src/Bws.Cli/CommandLine.cs
+++ b/src/Bws.Cli/CommandLine.cs
@@ -38,9 +38,11 @@ internal sealed partial record CommandLine
///
/// Read who signed each binary, which the listing does not do by default.
///
- /// Measured at 4620-7656 ms over 810 entries and 544 distinct files, against 476-551 ms
- /// for the rest of a listing, so it is asked for rather than assumed. A query about
- /// signatures turns it on by itself - somebody who wrote signed:no has already asked.
+ /// Measured 2026-09-28 at about 12 s of processor over 797 entries and 531 distinct files -
+ /// 1.0-1.2 s of clock on sixteen processors, 4.3-5.0 s on two - against 136-144 ms for the
+ /// cold reading of the manager every command makes. (It was 4620-7656 ms on 2026-08-01, one
+ /// file at a time.) So it is asked for rather than assumed. A query about signatures turns it
+ /// on by itself - somebody who wrote signed:no has already asked.
///
/// The spread is wider than most whole operations here, which is itself the point: one
/// run of this tells nobody anything.
@@ -106,8 +108,9 @@ internal sealed partial record CommandLine
///
/// Whether the listing should go and read who depends on each entry.
///
- /// Asked for rather than always read: a call per entry, measured at 236-259 ms over 313
- /// services on 2026-09-05, against 423-500 ms for the whole listing. A query naming the
+ /// Asked for rather than always read: a call per entry, measured 2026-09-28 at 148-155 ms over
+ /// 797 entries, against 107-113 ms for the whole reading on sixteen processors - more than the
+ /// listing it sits behind. A query naming the
/// field turns it on by itself, exactly as one about signatures or memory does.
///
internal bool RequiredBy { get; private init; }
diff --git a/src/Bws.Cli/OptionSurface.cs b/src/Bws.Cli/OptionSurface.cs
index b3faa11..2ae1160 100644
--- a/src/Bws.Cli/OptionSurface.cs
+++ b/src/Bws.Cli/OptionSurface.cs
@@ -143,8 +143,8 @@ internal static readonly (string Option, CommandKind[] Verbs)[] Surface =
// Listing only. A plan never asks who signed anything, so accepting it on a write
// verb would be a switch that does nothing - the silence this table was built to
- // end. Measured at 4620-7656 ms over 810 entries and 544 files, which is why it is
- // asked for rather than assumed.
+ // end. It costs about 12 s of processor over 797 entries (2026-09-28, 1.0-1.2 s of clock on
+ // sixteen processors), which is why it is asked for rather than assumed.
("--signatures", [CommandKind.List]),
// Listing only, for the same reason: a plan is about what will happen to a service,
@@ -153,7 +153,7 @@ internal static readonly (string Option, CommandKind[] Verbs)[] Surface =
// Listing only, and NOT the same switch as --dependents further down. That one is an
// instruction to a write verb - take the services standing on this one with you. This is
- // a reading, and it costs a call per entry: 236-259 ms over 313 services.
+ // a reading, and it costs a call per entry: 148-155 ms over 797 entries, 2026-09-28.
("--required-by", [CommandKind.List]),
// The two verbs that resolve a launch path against the disk. A plan does not - it
diff --git a/src/Bws.Cli/Program.cs b/src/Bws.Cli/Program.cs
index 30dbbc4..a0f10ae 100644
--- a/src/Bws.Cli/Program.cs
+++ b/src/Bws.Cli/Program.cs
@@ -77,7 +77,8 @@
// Comparing two files never opens the service control manager, and it matters that it
// does not. A pipeline step comparing two snapshots on a build agent has no business
// needing rights over that agent's own services, and reading eight hundred entries this
- // branch never looks at would spend half a second saying nothing.
+ // branch never looks at would spend a cold reading of the manager saying nothing - 136-144 ms
+ // here and 215-234 ms on an eight-processor machine, measured 2026-09-28.
var offline = options.Kind == CommandKind.SnapshotDiff && !options.Live;
// Skip unless somebody said otherwise, everywhere, including the branches that never see
@@ -94,8 +95,9 @@
var read = offline ? 0 : stopwatch.ElapsedMilliseconds;
// Its own number, and not folded into the time spent filtering. The second pass is by
- // far the most expensive thing this tool does - measured at around five seconds against
- // a third of one for the read - and reporting it under the word "filtered" would put a
+ // far the most expensive thing this tool does - 869-954 ms against 136-144 ms for the read on
+ // sixteen processors, and 1451-1591 ms against 215-234 ms on eight, both measured 2026-09-28
+ // through --timing - and reporting it under the word "filtered" would put a
// true number next to a sentence about something else.
long inspected = 0;
@@ -188,7 +190,8 @@
// nothing to preview that the file itself does not already say.
//
// Refused before the expensive work rather than after it, and the order is the whole
- // courtesy: verifying signatures takes about a second, and spending it to then say
+ // courtesy: verifying signatures takes about a second here and half as much again on an
+ // eight-processor machine (2026-09-28), and spending it to then say
// "there is already a file there" would be a second nobody got anything for.
//
// Found 2026-08-02 by reading a security document from another project, and confirmed
@@ -386,9 +389,10 @@
else
{
// The second pass, and the first thing in this tool that is asked for rather than
- // simply done. Measured at 4620-7656 ms over 810 entries and 544 distinct files
- // against 476-551 ms for everything above, so a listing does not verify signatures
- // unless somebody wants them.
+ // simply done. Measured 2026-09-28 at about 12 s of processor over 797 entries and 531
+ // distinct files - 1.0-1.2 s of clock on sixteen processors and 4.3-5.0 s on two - against
+ // 136-144 ms for the reading above, so a listing does not verify signatures unless
+ // somebody wants them.
//
// A query about them counts as wanting them. Answering "signed:no" with an empty
// list because nobody had looked would be a correct query returning what reads
diff --git a/src/Bws.Cli/Refusals.cs b/src/Bws.Cli/Refusals.cs
index e44b887..5b42614 100644
--- a/src/Bws.Cli/Refusals.cs
+++ b/src/Bws.Cli/Refusals.cs
@@ -19,7 +19,8 @@ namespace Bws.Cli;
///
/// Not one of these opens a handle or reads an entry, which is the property that makes the
/// whole block worth having in front of the expensive work. Refusing a typo after eight hundred
-/// entries have been read is half a second spent to say "you made a typo".
+/// entries have been read is a whole cold reading spent to say "you made a typo" - 136-144 ms here
+/// and 215-234 ms on an eight-processor machine, measured 2026-09-28.
///
internal static class Refusals
{
@@ -178,7 +179,8 @@ internal static class Refusals
// WERE ASKED AFTER THE MACHINE HAD BEEN READ. That is the one property this whole class
// exists for: nothing here opens a handle, so a typo is answered before eight hundred
// entries are enumerated. Both of these commands read the manager first and then said
- // "you left out an argument" - about half a second spent to report a missing word.
+ // "you left out an argument" - a whole cold reading of the manager spent to report a
+ // missing word.
//
// Nothing about the answers changed. Same two sentences, same code, and the order they are
// asked in is the order they stood in.
diff --git a/src/Bws.Core/IBinaryInspector.cs b/src/Bws.Core/IBinaryInspector.cs
index acc1e64..c9f7b6c 100644
--- a/src/Bws.Core/IBinaryInspector.cs
+++ b/src/Bws.Core/IBinaryInspector.cs
@@ -9,9 +9,9 @@ namespace Bws.Core;
/// need nothing the manager can grant or refuse. Something that only lists services should
/// not have to be handed an object that can open and hash every binary on the machine.
///
-/// It is also the seam ADR-13 needs. The second pass is expensive - measured at 4620-7656
-/// ms over 810 entries and 544 distinct files, against 476-551 ms for everything the
-/// listing does today - so it has to be possible to build a listing without one of these
+/// It is also the seam ADR-13 needs. The second pass is expensive - about 12 s of processor
+/// over 797 entries and 531 distinct files on 2026-09-28, against 107-113 ms of clock for
+/// everything else the listing does - so it has to be possible to build a listing without one of these
/// at all.
///
public interface IBinaryInspector
diff --git a/src/Bws.Core/MemoryPass.cs b/src/Bws.Core/MemoryPass.cs
index 00af51a..0575525 100644
--- a/src/Bws.Core/MemoryPass.cs
+++ b/src/Bws.Core/MemoryPass.cs
@@ -5,7 +5,8 @@ namespace Bws.Core;
///
/// Its own pass rather than part of the listing, and the reason is not cost - measured on
/// 2026-08-01, the whole pass costs 5-8 ms over 810 entries and 110 processes, beside the
-/// 476-551 ms the listing already spends. The calls themselves are under a millisecond and
+/// 476-551 ms the listing spent then (107-113 ms over 797 entries on 2026-09-28, since it reads
+/// several entries at once). The calls themselves are under a millisecond and
/// the rest is building 810 new records, which is worth separating: the probe measured only
/// the calls and the figure it gave was wrong by a factor of six.
///
diff --git a/src/Bws.Core/Planning/BulkPlanBuilder.cs b/src/Bws.Core/Planning/BulkPlanBuilder.cs
index 69ef167..cec8f1d 100644
--- a/src/Bws.Core/Planning/BulkPlanBuilder.cs
+++ b/src/Bws.Core/Planning/BulkPlanBuilder.cs
@@ -90,8 +90,9 @@ public BulkPlan Build(BulkAction action)
/// not the order, which was harmless, but the question asked to work it out:
/// spends one ReadDependents per name, and each of
/// those opens the manager and then the service. Selecting three hundred entries and asking to
- /// disable them paid three hundred round trips to the manager, on the thread drawing the
- /// window, to sort a list whose order did not matter.
+ /// disable them paid three hundred round trips to the manager - on the thread drawing the
+ /// window, as working out a plan then was, until 2026-09-03 - to sort a list whose order did
+ /// not matter.
///
/// A setting that carries a stop goes back to the ordered side (2026-09-24). Then each
/// plan does take its entry down, and two selected entries where one depends on the other have
diff --git a/src/Bws.Core/Planning/PlanRunner.cs b/src/Bws.Core/Planning/PlanRunner.cs
index 3cad34f..975bac8 100644
--- a/src/Bws.Core/Planning/PlanRunner.cs
+++ b/src/Bws.Core/Planning/PlanRunner.cs
@@ -22,7 +22,14 @@ public sealed class PlanRunner(IScmControl control, IClock clock)
/// Our choice, not the system's, and it carries no correctness: the deadline comes from
/// the entry's own wait hint, this only decides how soon we notice. Short because
/// somebody is watching a terminal and most stops are over in well under a second, and
- /// asking costs one call to the manager.
+ /// asking is cheap - five calls to the manager (open it, open the entry, close the manager,
+ /// query, close the entry - WindowsScmControl.Read), not the one call this said until
+ /// 2026-09-28.
+ ///
+ /// Measured on the throwaway machine on 2026-09-28, this cadence is most of every wait:
+ /// Spooler and W32Time changed state in 2-50 ms and each step reported 260-289 ms, because the
+ /// first question goes straight after the request, is almost always too early, and the next
+ /// one is a whole cadence later. Backlog 466, S-6 of the external performance report.
///
private static readonly TimeSpan Cadence = TimeSpan.FromMilliseconds(250);
diff --git a/src/Bws.Core/Querying/QueryAsTyped.cs b/src/Bws.Core/Querying/QueryAsTyped.cs
new file mode 100644
index 0000000..74311f7
--- /dev/null
+++ b/src/Bws.Core/Querying/QueryAsTyped.cs
@@ -0,0 +1,81 @@
+namespace Bws.Core.Querying;
+
+///
+/// A query as it stands in the box, parsed once and then asked as often as anybody likes.
+///
+/// Why it exists - G-4 of the external performance report of 2026-09-28. One keystroke in the
+/// window's search box used to parse the same line eighteen times: once to narrow the list, once to
+/// ask whether it names what the scope leaves out, and once for each of the sixteen filter chips
+/// asking whether it is lit. Each parse of a line holding a pattern builds a new regular expression,
+/// and a pattern the engine refuses throws and is caught on every one of them (S-10 of the same
+/// report). Everything that asks about one text now asks this.
+///
+/// It is the TEXT and the answer together, never the answer alone, and that is what keeps
+/// it from being the second copy of the query the filter chips are forbidden to hold - the rule is
+/// at FilterChip in the window. Whoever keeps one of these keeps it next to the text it was made
+/// from and replaces it the moment the text differs, so it cannot disagree with the box.
+///
+/// Parsed as being typed (), because the box is the one
+/// place a line is read while it is still being written. The command line reads a finished query
+/// and has no use for this.
+///
+/// Not safe to share between threads - the members of a line that does not parse are worked
+/// out on the first question and kept. The window asks from its own thread only.
+///
+public sealed class QueryAsTyped
+{
+ private IReadOnlyList? _members;
+
+ private QueryAsTyped(string text, QueryParseResult parsed)
+ {
+ Text = text;
+ Parsed = parsed;
+ }
+
+ /// The text, exactly as it was typed - an absent text is an empty one.
+ public string Text { get; }
+
+ /// The whole line parsed, once.
+ public QueryParseResult Parsed { get; }
+
+ /// Parses the text once.
+ public static QueryAsTyped Of(string? text) =>
+ new(text ?? string.Empty, QueryParser.Parse(text, QueryInput.BeingTyped));
+
+ ///
+ /// Whether the text already carries this member, on this side.
+ ///
+ /// MEMBER BY MEMBER WHEN THE WHOLE LINE DOES NOT PARSE - UX-GUI-002, 2026-09-23. Until
+ /// then a mistake anywhere in the line meant no member counted, so in
+ /// status:running pid:abc the chip for Running went dark over its own member, and
+ /// clicking it wrote a second copy on the end. The whole line is still asked first, because
+ /// that is the reading that folds repeated fields - and a line that cannot be taken apart at
+ /// all, an unclosed quote, carries nothing, for the reason
+ /// gives. The members are parsed once for every question, not once per question.
+ ///
+ public bool Carries(string field, string value, bool negated)
+ {
+ if (Parsed.IsValid)
+ {
+ return Parsed.Query!.Carries(field, value, negated);
+ }
+
+ return Members().Any(member => member?.Carries(field, value, negated) ?? false);
+ }
+
+ private IReadOnlyList Members() => _members ??= Scanned(Text);
+
+ ///
+ /// Each member of the line on its own, parsed rather than compared as text - which is the whole
+ /// of why quoting cannot fool a chip (the note at the top of ).
+ ///
+ private static IReadOnlyList Scanned(string text)
+ {
+ if (text.Length == 0 || !QueryScanner.TryScan(text, out _, out var spans, out _))
+ {
+ return [];
+ }
+
+ return [.. spans.Select(span => QueryParser.Parse(text[span], QueryInput.BeingTyped).Query)];
+ }
+}
diff --git a/src/Bws.Core/Querying/QueryField.cs b/src/Bws.Core/Querying/QueryField.cs
index 7fd0eb2..796149a 100644
--- a/src/Bws.Core/Querying/QueryField.cs
+++ b/src/Bws.Core/Querying/QueryField.cs
@@ -40,18 +40,18 @@ public enum ExtraRead
{
None = 0,
- /// Who signed each binary. Measured at 4620-7656 ms over 810 entries.
+ /// Who signed each binary. About 12 s of processor over 797 entries, measured 2026-09-28.
Signatures = 1,
/// What each running process is using. Measured at under a millisecond over 110 processes.
Memory = 2,
///
- /// Who breaks if each entry stops. Measured at 236-259 ms over 313 services on 2026-09-05.
+ /// Who breaks if each entry stops. Measured at 148-155 ms over 797 entries on 2026-09-28.
///
/// The third family, and the one that shows why this was flags rather than a yes-or-no from
- /// the start. It sits between the other two - two hundred and fifty milliseconds against
- /// under one and against seven and a half seconds - so a query about dependents must not send
+ /// the start. It sits between the other two - a hundred and fifty milliseconds against
+ /// under one and against twelve seconds of processor - so a query about dependents must not send
/// the window to open eight hundred binaries, and a question about signatures must not walk the
/// manager service by service. Each caller asks for what it needs and gets only that, which
/// Readings.Fill honours one flag at a time.
diff --git a/src/Bws.Core/Querying/QueryFields.cs b/src/Bws.Core/Querying/QueryFields.cs
index aa3dac7..38ed480 100644
--- a/src/Bws.Core/Querying/QueryFields.cs
+++ b/src/Bws.Core/Querying/QueryFields.cs
@@ -333,8 +333,8 @@ private static QueryField[] BuildAll() =>
// and the one services.msc answers only by opening a service and reading a tab.
//
// IT DECLARES A FAMILY AND THE ONE ABOVE DOES NOT, which is the whole difference
- // between the two directions. This takes a call per entry: measured 236-259 ms over
- // 313 services on 2026-09-05, against 423-500 ms for the entire listing. So it is
+ // between the two directions. This takes a call per entry: measured 148-155 ms over
+ // 797 entries on 2026-09-28, against 107-113 ms for the entire listing. So it is
// asked for rather than always read, exactly as signatures and memory are.
//
// THE FIRST HOP ONLY, which a person reading a member has to know: dependents:spooler
diff --git a/src/Bws.Core/Querying/QueryMembers.cs b/src/Bws.Core/Querying/QueryMembers.cs
index c43d548..285e78d 100644
--- a/src/Bws.Core/Querying/QueryMembers.cs
+++ b/src/Bws.Core/Querying/QueryMembers.cs
@@ -127,31 +127,12 @@ public static string Without(string? text, string field, string value, bool nega
}
///
- /// Whether the text already carries this member, on this side.
- ///
- /// MEMBER BY MEMBER WHEN THE WHOLE LINE DOES NOT PARSE - UX-GUI-002, 2026-09-23. Until
- /// then a mistake anywhere in the line meant no member counted, so in
- /// status:running pid:abc the chip for Running went dark over its own member, and
- /// clicking it wrote a second copy on the end. The whole line is still asked first, because
- /// that is the reading that folds repeated fields - and a line that cannot be taken apart at
- /// all, an unclosed quote, carries nothing, for the reason gives.
+ /// Whether the text already carries this member, on this side - for one question about one
+ /// text. Anything asking several questions of the same text keeps a
+ /// instead, which is where the answer and its argument live.
///
- public static bool Carries(string? text, string field, string value, bool negated)
- {
- var parsed = QueryParser.Parse(text, QueryInput.BeingTyped);
-
- if (parsed.IsValid)
- {
- return parsed.Query!.Carries(field, value, negated);
- }
-
- if (string.IsNullOrEmpty(text) || !QueryScanner.TryScan(text, out _, out var spans, out _))
- {
- return false;
- }
-
- return spans.Exists(span => Is(text[span], field, value, negated));
- }
+ public static bool Carries(string? text, string field, string value, bool negated) =>
+ QueryAsTyped.Of(text).Carries(field, value, negated);
/// How this member is written when a chip puts it there.
public static string Member(string field, string value, bool negated) =>
diff --git a/src/Bws.Core/RequiredByPass.cs b/src/Bws.Core/RequiredByPass.cs
index 60269b2..32489ab 100644
--- a/src/Bws.Core/RequiredByPass.cs
+++ b/src/Bws.Core/RequiredByPass.cs
@@ -4,10 +4,11 @@ namespace Bws.Core;
/// Fills in who breaks if each entry stops.
///
/// Its own pass because of one number, and it is the number in the middle. Asking the
-/// manager who depends on an entry takes a call per entry - measured 2026-09-05 through the same
-/// Win32 call reached from .NET, five runs with the first discarded: 236-259 ms over 313 services,
-/// 784 dependents found and nothing refused. The listing this sits behind costs 423-500 ms over
-/// 810 entries, so always running this would roughly double what every F5 costs, for an answer
+/// manager who depends on an entry takes a call per entry - measured 2026-09-28 through this pass
+/// itself, five runs with the first discarded: 148-155 ms over 797 entries, 86 of them with
+/// dependents and 13 refused without rights. (The 236-259 ms over 313 services that stood here from
+/// 2026-09-05 came from a separate probe.) The listing this sits behind costs 107-113 ms over the
+/// same 797 entries, so always running this would more than double what every F5 costs, for an answer
/// nobody had asked for. That is exactly the trade `ADR-13` refuses, and it is the same argument
/// and each make with a different number.
///
diff --git a/src/Bws.Core/ScmDetailReader.cs b/src/Bws.Core/ScmDetailReader.cs
index 54309cc..f4f7fbd 100644
--- a/src/Bws.Core/ScmDetailReader.cs
+++ b/src/Bws.Core/ScmDetailReader.cs
@@ -249,7 +249,7 @@ internal static unsafe Reading ReadSidType(SafeHandle service)
/// configuration already opened - this level needs no right the listing does not have. The
/// whole family costs 212-223 ms over 819 entries, four warm runs, which puts it in the
/// cheap pass beside the privileges rather than in the second pass of `ADR-13` beside the
- /// signatures at 4620-7656 ms.
+ /// signatures at about 12 s of processor (2026-09-28).
///
/// Translated, like the display name, so it is never an identity - `ADR-14`. The
/// longest one measured is 1251 characters and two contain a newline, which is a fact for
diff --git a/src/Bws.Core/ScmEntry.cs b/src/Bws.Core/ScmEntry.cs
index d2a7b68..cf34672 100644
--- a/src/Bws.Core/ScmEntry.cs
+++ b/src/Bws.Core/ScmEntry.cs
@@ -145,10 +145,10 @@ public sealed record ScmEntry
/// NOT free, which is the whole reason it is a family of its own. The declaration above
/// arrives in the configuration structure the start type comes from and costs nothing. This
/// takes a call PER ENTRY: the manager is asked, one service at a time, who is standing on it.
- /// Measured on this machine on 2026-09-05 through the same Win32 call reached from .NET,
- /// five runs with the first discarded: 236-259 ms over 313 services, 784 dependents found,
- /// nothing refused. That is the size of the whole listing again - 423-500 ms over 810
- /// entries - so paying it on every F5 for a column that is off by default is exactly the trade
+ /// Measured on this machine on 2026-09-28 through the product's own pass, five runs with the
+ /// first discarded: 148-155 ms over 797 entries. That is more than the whole listing
+ /// again - 107-113 ms over the same entries - so paying it on every F5 for a column that is
+ /// off by default is exactly the trade
/// `ADR-13` refuses. It is read when somebody asks, and is
/// how they ask.
///
@@ -236,11 +236,11 @@ public sealed record ScmEntry
///
/// The first field whose ordinary state in a plain listing is
/// , and the first one that earns it. Measured on a
- /// real machine on 2026-08-01, seven runs with the first discarded as cold: verifying
- /// 544 distinct files costs 4620-7656 ms, median 4882, against 476-551 ms for everything
- /// the listing does otherwise. Reading it every time would put the listing five to eight
- /// times over its one second budget - a range rather than a figure, because the spread
- /// of this one operation is wider than the whole of the rest of the listing.
+ /// real machine on 2026-08-01, one file at a time: 4620-7656 ms over 544 files. Measured again
+ /// on 2026-09-28, several at a time as it now runs: about 12 s of processor over 531 files -
+ /// 1.0-1.2 s of clock on sixteen processors and 4.3-5.0 s on two - against 107-113 ms for
+ /// everything else the listing does. Reading it every time would put a listing on a
+ /// two-processor machine four to five times over its one second budget.
///
/// That is what ADR-13 was written for. Triggers and launch paths both looked like they
/// would need it and both turned out cheap enough not to - this is the family where the
@@ -269,10 +269,12 @@ public sealed record ScmEntry
///
/// The version claims for itself.
///
- /// Cheap on its own - 0.27 s across the same 544 files - and read in the same pass
- /// anyway, because it comes from a file that has just been opened for the signature.
- /// Splitting it out would mean walking every binary on the machine twice to save a
- /// quarter of a second on a step that already costs three.
+ /// Cheap on its own - 0.27 s across the same 544 files one at a time, and about 4% of the
+ /// pass's processor on 2026-09-28 - and read in the same pass as the signature. Not from
+ /// the same opening of the file, which is what this said until the external performance
+ /// report of 2026-09-28 read the code: each of the three answers opens the file on its own.
+ /// Whether the version and the hash should be a family of their own is the owner's open
+ /// decision, because it changes what the JSON carries - not a cost this comment can settle.
///
/// Absent for a file with no version resource, which is ordinary rather than missing.
///
@@ -285,10 +287,10 @@ public sealed record ScmEntry
/// for the file, and this says whether it is byte for byte the same file. A binary
/// swapped for another one signed by the same publisher changes this and nothing else.
///
- /// Read alongside the signature rather than on its own, because both open the same file
- /// and walking every binary on the machine twice would cost more than the hash does.
- /// Measured on 2026-08-01: 0.52 s across 544 distinct files totalling 368 MB, against
- /// 4620-7656 ms for the signatures in the same pass.
+ /// Read in the same pass as the signature, though NOT from the same opening of the file - each
+ /// answer opens it on its own, which this said the other way round until 2026-09-28. Measured on
+ /// 2026-08-01: 0.52 s across 544 distinct files totalling 368 MB one at a time, and on 2026-09-28
+ /// about 6% of the pass's processor.
///
public required Reading BinaryHash { get; init; }
diff --git a/src/Bws.Core/SecondPass.cs b/src/Bws.Core/SecondPass.cs
index 9ad8f58..2d9d90d 100644
--- a/src/Bws.Core/SecondPass.cs
+++ b/src/Bws.Core/SecondPass.cs
@@ -3,10 +3,12 @@ namespace Bws.Core;
///
/// The second half of ADR-13: fills in what a listing costs too much to know up front.
///
-/// The first pass reads the manager and is cheap - measured at 476-551 ms for 810 entries
+/// The first pass reads the manager and is cheap - 107-113 ms for 797 entries on 2026-09-28,
/// against a budget of a second. This one opens files and asks the trust providers about
/// them, which measured at 4620-7656 ms over 544 distinct files before it was made to ask
-/// about several at once. That gap is the entire reason the two are separate, and it is the
+/// about several at once, and about 12 s of processor over 531 files after it (2026-09-28) -
+/// 1.0-1.2 s of clock on sixteen processors, 4.3-5.0 s on two. That gap is the entire reason the
+/// two are separate, and it is the
/// first family where the separation earned itself: triggers and launch paths both turned
/// out cheap enough to fold into the first pass, and this one is not.
///
diff --git a/src/Bws.Core/WindowsScmCatalog.cs b/src/Bws.Core/WindowsScmCatalog.cs
index 1411a9c..11a4701 100644
--- a/src/Bws.Core/WindowsScmCatalog.cs
+++ b/src/Bws.Core/WindowsScmCatalog.cs
@@ -73,10 +73,12 @@ public sealed partial class WindowsScmCatalog(NetworkPaths networkPaths = Networ
/// old way has nothing to compare against. Nothing in the product passes it.
///
/// Why this is parallel at all, measured rather than assumed. The same loop over
- /// the same 810 entries costs 13-22 ms without opening a handle per entry and 455-475 ms
- /// with - so about 440 ms of a listing is spent waiting on the manager, one entry at a
- /// time, while fifteen processors do nothing. It is the same shape `ADR-22` already found
- /// in signature verification, one layer down.
+ /// the same 810 entries cost 13-22 ms without opening a handle per entry and 455-475 ms
+ /// with, before it was parallel - so about 440 ms of a listing was spent waiting on the
+ /// manager, one entry at a time, while fifteen processors did nothing. It is the same shape
+ /// `ADR-22` already found in signature verification, one layer down. Measured again on
+ /// 2026-09-28 over 797 entries, with the descriptions read here since: 13.3-14.7 ms for the
+ /// enumeration, 721-739 ms one entry at a time and 107-113 ms as it runs.
///
/// Order is held by index, not by collecting and sorting afterwards. Each entry
/// writes into the slot it came from, so the answer is identical to the sequential one by
@@ -301,7 +303,7 @@ private static ScmEntry Describe(SafeHandle manager, EnumeratedEntry enumerated,
// Filled in by RequiredByPass, and only when asked - the OTHER direction of DependsOn
// three fields up, and the reason the two sit apart. That one arrives inside this same
// configuration structure and is free. This one is a call per entry, measured at
- // 236-259 ms over 313 services, which is the whole listing again.
+ // 148-155 ms over 797 entries on 2026-09-28, which is more than the whole listing again.
RequiredBy = Reading>.NotRead()
};
}
@@ -378,7 +380,8 @@ private static unsafe ScmConfiguration ReadConfiguration(
// On this handle rather than in the second pass, and that is a measurement rather
// than a convenience: the whole description family costs 212-223 ms over 819 entries,
- // which is the privileges' order of magnitude and not the signatures' 4620-7656 ms.
+ // which is the privileges' order of magnitude and not the signatures' twelve seconds
+ // of processor.
Description = ScmDetailReader.ReadDescription(service)
};
diff --git a/src/Bws.Gui/MainWindow.Columns.cs b/src/Bws.Gui/MainWindow.Columns.cs
index 028ef68..4da7565 100644
--- a/src/Bws.Gui/MainWindow.Columns.cs
+++ b/src/Bws.Gui/MainWindow.Columns.cs
@@ -236,7 +236,7 @@ internal bool OfferTheColumnMenu(object source)
var model = new ColumnMenu(
id,
_columns.Choices.FirstOrDefault(choice => choice.Column.Id == id),
- () => _model.QueryText,
+ _model.AsTyped,
// The PROPERTY rather than the field, which is the same door a chip and a keystroke go
// through: the setter is what parses the query, applies it and tells the list.
diff --git a/src/Bws.Gui/MainWindow.HandOver.cs b/src/Bws.Gui/MainWindow.HandOver.cs
index 28cca47..4baddc3 100644
--- a/src/Bws.Gui/MainWindow.HandOver.cs
+++ b/src/Bws.Gui/MainWindow.HandOver.cs
@@ -42,7 +42,14 @@ private async Task FirstLook()
await TakeOverAsync(handed.Carried, handed.Refused).ConfigureAwait(true);
}
- _timer.Start();
+ // ONLY IF SOMEBODY CAN SEE IT - a window started minimised reads the machine once and waits
+ // to be looked at, and coming back starts the timer with a tick (WatchWhileSeen).
+ _looked = true;
+
+ if (Seen)
+ {
+ _timer.Start();
+ }
}
///
diff --git a/src/Bws.Gui/MainWindow.xaml.cs b/src/Bws.Gui/MainWindow.xaml.cs
index 5e59239..30b59cd 100644
--- a/src/Bws.Gui/MainWindow.xaml.cs
+++ b/src/Bws.Gui/MainWindow.xaml.cs
@@ -18,9 +18,10 @@ public partial class MainWindow : Window
///
/// How often the list asks what is running.
///
- /// Measured before choosing it: the cheap reading costs 13-22 ms over 810 entries against
+ /// Measured before choosing it: the cheap reading cost 13-22 ms over 810 entries against
/// 423-500 ms for a full one, so asking every second spends under two per cent of one
- /// processor. A slower tick would be cheaper and would also be a window that tells you
+ /// processor. Still true on 2026-09-28: a median of 13.6 ms over an hour of ticks on 797
+ /// entries, against 107-113 ms for a full reading as it now runs. A slower tick would be cheaper and would also be a window that tells you
/// about a service three seconds after you stopped it, which is the habit `A10` exists to
/// break.
///
@@ -40,6 +41,12 @@ public partial class MainWindow : Window
private readonly DispatcherTimer _timer;
+ ///
+ /// Whether the first reading is done. Until it is, the timer is the first look's to start -
+ /// says why.
+ ///
+ private bool _looked;
+
///
/// How long the window waits after the last keystroke before it narrows the list.
///
@@ -246,26 +253,16 @@ internal MainWindow(PreferencesFile preferences, MainViewModel model)
// to are decided beside every other shortcut of this window. MainWindow.Suggesting.cs.
WatchTheBoxForSuggestions();
- // After the window is up, not before. Reading the manager takes about half a second
- // over 810 entries, and doing it in the constructor means the window appears already
+ // After the window is up, not before. Reading the manager takes 107-113 ms over 797
+ // entries on sixteen processors (2026-09-28) and longer on fewer, and doing it in the
+ // constructor means the window appears already
// late - the specification asks for a useful list inside a second, and part of that
// second is spent showing that something is happening.
Loaded += async (_, _) => await FirstLook().ConfigureAwait(true);
- // Nothing to refresh when nobody can see it. A window minimised for an afternoon has
- // no business asking the manager anything, and the first tick after it comes back
- // catches up in one go.
- IsVisibleChanged += (_, _) =>
- {
- if (IsVisible)
- {
- _timer.Start();
- }
- else
- {
- _timer.Stop();
- }
- };
+ // Nothing to refresh when nobody can see it - WatchWhileSeen says why both events.
+ IsVisibleChanged += async (_, _) => await WatchWhileSeen().ConfigureAwait(true);
+ StateChanged += async (_, _) => await WatchWhileSeen().ConfigureAwait(true);
ListenToThePlanSheet();
@@ -327,6 +324,45 @@ private void NothingIsWatching()
_model.NoLongerWanted();
}
+ ///
+ /// Whether anybody can see the list - shown and not minimised.
+ ///
+ private bool Seen => IsVisible && WindowState != WindowState.Minimized;
+
+ ///
+ /// The once-a-second reading runs while the window is seen and stops while it is not. A window
+ /// minimised for an afternoon has no business asking the manager anything - and on a machine
+ /// whose list of entries changed, a tick does a full reading, second phase included.
+ ///
+ /// TWO EVENTS, AND UNTIL 2026-09-28 THERE WAS ONE - backlog 467. Only IsVisibleChanged
+ /// stopped the timer, and WPF leaves IsVisible true on a minimised window, so the comment here
+ /// promised a rest the window never took: measured with tools/perf-probe/window-idle.ps1
+ /// -Minimised, 375 ms of processor in 36 s minimised by hand and 203 ms in 39 s started minimised.
+ ///
+ /// Coming back asks at once rather than a second later, because the list on screen is what
+ /// the machine looked like when the window went away. Not before the first reading, which
+ /// starts the timer itself when it is done: a tick there would begin a full reading of its own
+ /// and the first look, finding a reading out, would return without sorting what arrives.
+ ///
+ private async Task WatchWhileSeen()
+ {
+ if (!Seen)
+ {
+ _timer.Stop();
+
+ return;
+ }
+
+ if (!_looked || _timer.IsEnabled)
+ {
+ return;
+ }
+
+ _timer.Start();
+
+ await Tick().ConfigureAwait(true);
+ }
+
private async Task Tick()
{
// NOT WHILE SOMEBODY IS MID-WORD, AND THIS IS A MEASUREMENT RATHER THAN A COURTESY. On the
diff --git a/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs b/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs
index 3567cf6..900a7b5 100644
--- a/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs
+++ b/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs
@@ -2,6 +2,7 @@
using System.Windows.Controls;
using Bws.Core;
using Bws.Core.Planning;
+using Bws.Core.Querying;
namespace Bws.Gui.ViewModels;
@@ -185,8 +186,12 @@ private static (object Normal, object Extreme)? TemplateSpecimens(string key)
}
/// A row of chips lit by a query, the way the filter row lights them.
- private static IReadOnlyList Chips(string query, string field, params (string Key, string Value)[] members) =>
- [.. members.Select(member => new FilterChip(member.Key, field, member.Value, negated: false, () => query, _ => { }))];
+ private static IReadOnlyList Chips(string query, string field, params (string Key, string Value)[] members)
+ {
+ var typed = QueryAsTyped.Of(query);
+
+ return [.. members.Select(member => new FilterChip(member.Key, field, member.Value, negated: false, () => typed, _ => { }))];
+ }
///
/// The keyed templates of the theme files, each drawn over its specimen - one group per file,
diff --git a/src/Bws.Gui/ViewModels/CellOrder.cs b/src/Bws.Gui/ViewModels/CellOrder.cs
index 8abf14f..6b0e29c 100644
--- a/src/Bws.Gui/ViewModels/CellOrder.cs
+++ b/src/Bws.Gui/ViewModels/CellOrder.cs
@@ -24,7 +24,9 @@ internal sealed class CellOrder(Column column, bool ascending) : System.Collecti
/// listing - a few hundred. A new comparer is built for every sort, so nothing here outlives
/// the order it belongs to. It does hold its rows alive until then, and that is deliberate
/// rather than overlooked: the rows are held anyway by , which keeps one
- /// object per service for as long as the service exists.
+ /// object per service for as long as the service exists. The one exception is a service
+ /// removed while this is the sort - its row stays here until the next sort, one small object per
+ /// removed service (G-14 of the external performance report, 2026-09-28, judged negligible).
///
/// Not thread safe, and it does not need to be. A comparer handed to a ListCollectionView is
/// only ever called from the thread that owns the view, which is the interface thread.
diff --git a/src/Bws.Gui/ViewModels/Chosen.cs b/src/Bws.Gui/ViewModels/Chosen.cs
index 0dc970b..c7d30be 100644
--- a/src/Bws.Gui/ViewModels/Chosen.cs
+++ b/src/Bws.Gui/ViewModels/Chosen.cs
@@ -321,6 +321,18 @@ private void Stop()
private void Moved(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
+ // ONLY WHAT THE PANEL SHOWS - G-8 of the external performance report of 2026-09-28. A row
+ // raises its shapes and its highlight beside the word for every cell, and the highlight goes
+ // out again three seconds later, so one service starting rebuilt the whole panel up to four
+ // times. Every line of the panel is read from the entry, which only ever changes with the
+ // word for every cell - and the name at the head changes with its own. An empty name means
+ // everything changed, by the framework's own convention, so it rebuilds too.
+ if (!string.IsNullOrEmpty(e.PropertyName)
+ && e.PropertyName is not (EntryRow.EveryCell or nameof(EntryRow.DisplayName)))
+ {
+ return;
+ }
+
Rebuild();
// THE ROW MOVED, AND A FULL READING MAY HAVE BEEN WHAT MOVED IT - which hands the row a fresh
diff --git a/src/Bws.Gui/ViewModels/ColumnMenu.cs b/src/Bws.Gui/ViewModels/ColumnMenu.cs
index 74dfc02..6438e88 100644
--- a/src/Bws.Gui/ViewModels/ColumnMenu.cs
+++ b/src/Bws.Gui/ViewModels/ColumnMenu.cs
@@ -34,7 +34,7 @@ public sealed class ColumnMenu
{
private readonly ColumnChoice? _choice;
- internal ColumnMenu(string columnId, ColumnChoice? choice, Func read, Action write)
+ internal ColumnMenu(string columnId, ColumnChoice? choice, Func read, Action write)
{
ArgumentNullException.ThrowIfNull(columnId);
diff --git a/src/Bws.Gui/ViewModels/Columns.cs b/src/Bws.Gui/ViewModels/Columns.cs
index 436c340..c7a2369 100644
--- a/src/Bws.Gui/ViewModels/Columns.cs
+++ b/src/Bws.Gui/ViewModels/Columns.cs
@@ -315,7 +315,8 @@ internal static partial class Columns
ShownAtFirst = false,
// The same family as the signature and the version beside it, because one pass fills
- // all three - SecondPass reads the file once and writes the three answers it found.
+ // all three - SecondPass asks for the three answers of each file together. NOT from one
+ // opening of the file, which this said until 2026-09-28: each answer opens it on its own.
Needs = ExtraRead.Signatures,
Reads = entry => CellFaces.Say(entry.BinaryHash, value => value),
Outcome = entry => entry.BinaryHash.Outcome
@@ -419,8 +420,8 @@ internal static partial class Columns
// something. "What needs this" is asked before stopping something, and it is the only
// one of the two that can talk somebody out of an action.
//
- // IT IS THE ONLY COLUMN IN THIS CATALOGUE THAT COSTS A CALL PER ENTRY - 236-259 ms
- // over 313 services, measured 2026-09-05, against 423-500 ms for the whole listing. So
+ // IT IS THE ONLY COLUMN IN THIS CATALOGUE THAT COSTS A CALL PER ENTRY - 148-155 ms
+ // over 797 entries, measured 2026-09-28, against 107-113 ms for the whole listing. So
// it declares a family and is read when somebody turns it on, exactly as the four
// signature columns and the memory column are.
Id = "requiredBy",
diff --git a/src/Bws.Gui/ViewModels/EntryRow.cs b/src/Bws.Gui/ViewModels/EntryRow.cs
index d83f4d7..a0660dd 100644
--- a/src/Bws.Gui/ViewModels/EntryRow.cs
+++ b/src/Bws.Gui/ViewModels/EntryRow.cs
@@ -221,7 +221,7 @@ internal void StandsAlsoFor(IReadOnlyList instances)
/// than a value: a computed cell answers correctly whenever it is asked, so asking it proves
/// nothing about whether anybody was told to ask.
///
- private const string EveryCell = "Item[]";
+ internal const string EveryCell = "Item[]";
/// When this row last moved, for whoever is clearing the highlight.
internal DateTimeOffset ChangedAt { get; private set; }
@@ -297,14 +297,19 @@ internal bool Absorb(ScmEntry entry, DateTimeOffset now)
{
var qualifies = StartQualifiers.Of(entry);
+ // Worked out once and used twice - to decide whether the row moved and then to show it.
+ // Until 2026-09-28 each was worked out again below (G-11 of the external performance report).
+ var startType = CellFaces.StartLabel(entry, qualifies);
+ var account = SystemAccounts.Shown(entry.Account);
+
// Compared against what the row currently SHOWS rather than against the entry behind
// it. The two are the same thing said twice, and the shown form is the one that now
// carries the qualifiers - so a file going missing under a service counts as the row
// moving, which it is.
var moved = _entry.Status != entry.Status
|| !SameProcess(_entry.ProcessId, entry.ProcessId)
- || CellFaces.StartLabel(entry, qualifies) != _startType
- || SystemAccounts.Shown(entry.Account) != _account
+ || startType != _startType
+ || account != _account
|| _entry.DisplayName != entry.DisplayName;
_entry = entry;
@@ -314,8 +319,8 @@ internal bool Absorb(ScmEntry entry, DateTimeOffset now)
StartShape = CellFaces.StartShape(entry, qualifies);
AgainstShape = CellFaces.AgainstShape(entry.RunsAgainstItsStartType);
- _startType = CellFaces.StartLabel(entry, qualifies);
- _account = SystemAccounts.Shown(entry.Account);
+ _startType = startType;
+ _account = account;
// UNCONDITIONAL, AND THAT IS NOT LAZINESS - IT IS THE ONLY HONEST ANSWER HERE. The five
// comparisons above decide whether the row MOVED, which is a question about what a person
diff --git a/src/Bws.Gui/ViewModels/FilterChips.cs b/src/Bws.Gui/ViewModels/FilterChips.cs
index 6a0f681..6eb42d4 100644
--- a/src/Bws.Gui/ViewModels/FilterChips.cs
+++ b/src/Bws.Gui/ViewModels/FilterChips.cs
@@ -23,18 +23,22 @@ namespace Bws.Gui.ViewModels;
///
public sealed class FilterChip : Observable
{
- private readonly Func _read;
+ // THE QUERY AS TYPED, NOT THE TEXT, since 2026-09-28 - G-4 of the external performance report.
+ // Sixteen chips each parsing the same line on every keystroke was sixteen of the eighteen parses
+ // one keystroke cost. They still hold nothing: what they read is kept by whoever owns the text,
+ // beside the text and replaced when it differs, so it is the same fact and not a second copy.
+ private readonly Func _read;
private readonly Action _write;
private readonly Func _label;
internal FilterChip(
- string labelKey, string field, string value, bool negated, Func read, Action write)
+ string labelKey, string field, string value, bool negated, Func read, Action write)
: this(() => Texts.Of(labelKey), field, value, negated, read, write)
{
}
private FilterChip(
- Func label, string field, string value, bool negated, Func read, Action write)
+ Func label, string field, string value, bool negated, Func read, Action write)
{
_label = label;
_read = read;
@@ -64,7 +68,7 @@ private FilterChip(
/// ORed by the language - so ticking two shows both, which is what a person expects from a
/// list of ticks. Excluding is a different gesture and does not have one yet.
///
- internal static FilterChip Spelled(string field, string value, Func read, Action write) =>
+ internal static FilterChip Spelled(string field, string value, Func read, Action write) =>
new(() => value, field, value, negated: false, read, write);
/// The field this chip constrains, in the language's own spelling.
@@ -100,13 +104,15 @@ internal static FilterChip Spelled(string field, string value, Func read
///
public bool IsOn
{
- get => QueryMembers.Carries(_read(), Field, Value, Negated);
+ get => _read().Carries(Field, Value, Negated);
set
{
+ var text = _read().Text;
+
_write(value
- ? QueryMembers.With(_read(), Field, Value, Negated)
- : QueryMembers.Without(_read(), Field, Value, Negated));
+ ? QueryMembers.With(text, Field, Value, Negated)
+ : QueryMembers.Without(text, Field, Value, Negated));
Rethink();
}
@@ -118,12 +124,12 @@ public bool IsOn
/// Called for every chip whenever the text changes, because one edit can move several of
/// them - clearing the box turns them all off at once, and a chip that only listened to its
/// own click would stay lit over a query that no longer says anything about it.
+ ///
+ /// The label is not raised, since 2026-09-28 - it cannot change while the window runs
+ /// (there is no switching of language in flight), and raising it made every binding to it read
+ /// the text catalogue again on every keystroke for sixteen chips.
///
- internal void Rethink()
- {
- Raise(nameof(IsOn));
- Raise(nameof(Label));
- }
+ internal void Rethink() => Raise(nameof(IsOn));
}
///
@@ -200,7 +206,7 @@ public sealed class FilterBar : Observable
{
private readonly IReadOnlyList _chips;
- internal FilterBar(Func read, Action write)
+ internal FilterBar(Func read, Action write)
{
Groups = FilterChips.Grouped(read, write);
_chips = [.. Groups.SelectMany(group => group.Chips)];
@@ -261,7 +267,7 @@ internal static class FilterChips
/// Adding the field is a change to a surface people write scripts against, which is a decision
/// rather than a slice of this one.
///
- internal static IReadOnlyList Grouped(Func read, Action write) =>
+ internal static IReadOnlyList Grouped(Func read, Action write) =>
[
new FilterGroup("gui.filter.group.state",
[
diff --git a/src/Bws.Gui/ViewModels/MainViewModel.cs b/src/Bws.Gui/ViewModels/MainViewModel.cs
index 0801090..c2f5606 100644
--- a/src/Bws.Gui/ViewModels/MainViewModel.cs
+++ b/src/Bws.Gui/ViewModels/MainViewModel.cs
@@ -6,8 +6,9 @@ namespace Bws.Gui.ViewModels;
///
/// What the window shows and how it got there.
///
-/// Every reading runs off the interface thread, because a full one takes about half a second
-/// over 810 entries and a window that stops answering for half a second looks broken.
+/// Every reading runs off the interface thread, because a full one takes 107-113 ms over 797
+/// entries on sixteen processors and 215-234 ms cold on eight (2026-09-28), a second phase takes
+/// seconds, and a window that stops answering for that long looks broken.
/// `docs/06`, part 4: nothing from a worker thread touches the interface, so what comes back
/// is a plain list and everything the window binds to is built here.
///
@@ -67,6 +68,14 @@ public sealed partial class MainViewModel : Checked
///
private string _queryText = string.Empty;
+ ///
+ /// The text above, parsed once for everything that asks about it - .
+ ///
+ private QueryAsTyped _asTyped = QueryAsTyped.Of(string.Empty);
+
+ /// The answer on screen and how many copies it folded - .
+ private (Narrowed Narrowed, int Instances)? _answered;
+
///
/// The controls standing for members of the query. Declared after the text they read,
/// because they close over it.
@@ -122,7 +131,7 @@ public MainViewModel(
// for the same reason and a second one: TWO things can ask the second phase for something
// and one of them is the picker, which the window builds after this - MainViewModel.Asking.
_readings = new Readings(
- catalog, _index, () => Says, Reread, TellTheList, inspector, reader, () => Asked);
+ catalog, _index, () => Says, Reread, TellTheList, SayTheAnswer, inspector, reader, () => Asked);
// THE PANEL READS WHAT ITS ENTRY LACKS, THROUGH THE SAME READINGS - UX-GUI-005. After them,
// because it is handed them.
@@ -133,7 +142,7 @@ public MainViewModel(
// the query, applies it and tells the list, so a chip goes through the same door a
// keystroke does. Writing the field instead would change the box and leave the list
// showing the answer to the previous question.
- _filters = new FilterBar(() => _queryText, text => QueryText = text);
+ _filters = new FilterBar(AsTyped, text => QueryText = text);
// After the chips, because it reads their labels for the sentence beside a value - the
// same word somebody sees in the row above the list, in the same language. The examples
@@ -358,7 +367,41 @@ private void TellTheList() =>
_index.Ordered.Count,
_scoping.InScope.Count,
_scoping.Current,
- Scopes.AsksElsewhere(_scoping.Current, _queryText));
+ Scopes.AsksElsewhere(_scoping.Current, AsTyped()));
+
+ ///
+ /// The query in the box, parsed once for everything that asks about it - the narrowing, the
+ /// sentence about the scope, the sixteen chips and an open column menu. G-4 of the external
+ /// performance report of 2026-09-28 counted eighteen parses of one text on one keystroke.
+ ///
+ /// KEPT BY THE TEXT, NOT BY THE MOMENT, which is what keeps it from being a second copy of
+ /// the query: it is made again whenever the text differs from the one it was made from, and it
+ /// does not matter who asks first or whether anybody remembered to refresh it.
+ ///
+ internal QueryAsTyped AsTyped() =>
+ string.Equals(_asTyped.Text, _queryText, StringComparison.Ordinal)
+ ? _asTyped
+ : _asTyped = QueryAsTyped.Of(_queryText);
+
+ ///
+ /// The sentence about the answer on screen, said from the last answer drew.
+ ///
+ /// Also by the second pass setting out, since 2026-09-28 - G-11 of the external performance
+ /// report. That changes whether the list is still being filled and nothing else, and it used to
+ /// be said by recutting the scope and running the query again over entries shown a moment before.
+ ///
+ /// Silent after a query with a mistake in it, exactly as Apply is: Apply leaves before this
+ /// sentence then, so the end of the pass would never take back a "still filling" said here.
+ ///
+ private void SayTheAnswer()
+ {
+ if (_answered is { } answered)
+ {
+ Says.AboutTheAnswer(
+ Asked, _holding.Pending, answered.Narrowed,
+ _readings.Have, _readings.Filling, answered.Instances, !_showingOverview);
+ }
+ }
///
/// Reads the query and narrows the list to what it selects.
@@ -374,8 +417,7 @@ private void Apply()
// somebody making a mistake - and a box that shows an error through most of the typing
// teaches people to ignore the box. The command line says Finished, because by the time
// text reaches it there is no later.
- var parsed = QueryParser.Parse(
- _queryText, input: QueryInput.BeingTyped);
+ var parsed = AsTyped().Parsed;
if (!parsed.IsValid)
{
@@ -383,6 +425,7 @@ private void Apply()
// somebody is typing, and fixing one to be told about the next is a poor trade for
// a shorter line.
AboutTheQuery(string.Join(" ", parsed.Problems.Select(QueryMessages.Of)));
+ _answered = null;
// The chips read the TEXT, so they follow it even though the list does not - UX-GUI-002.
// Leaving before this kept Running and Manual lit over `stat:runing`, a query that was
@@ -442,9 +485,8 @@ private void Apply()
// The last argument is which screen has the middle of the window, and only one sentence
// under there asks about it - backlog 263. Passed rather than read out of this class by
// Sentences, because that class has never been allowed to know a window exists.
- Says.AboutTheAnswer(
- Asked, _holding.Pending, narrowed,
- _readings.Have, _readings.Filling, rolled.Instances, !_showingOverview);
+ _answered = (narrowed, rolled.Instances);
+ SayTheAnswer();
TellTheList();
diff --git a/src/Bws.Gui/ViewModels/Narrowing.cs b/src/Bws.Gui/ViewModels/Narrowing.cs
index 832d0d0..555d8f9 100644
--- a/src/Bws.Gui/ViewModels/Narrowing.cs
+++ b/src/Bws.Gui/ViewModels/Narrowing.cs
@@ -37,6 +37,11 @@ internal static class Narrowing
/// compiled once and never reaches out to the system while it is being asked. The budget is
/// 50 ms over the whole listing, from section 8.1 of the specification, and the measurement
/// against it is 0.42-2.25 ms over 810 entries.
+ ///
+ /// THAT IS THIS METHOD, NOT THE KEYSTROKE. The whole reaction in the window was 143 ms
+ /// on 2026-08-11 and 184-228 ms for a query leaving most rows, and nearly all of it is the
+ /// list's own reaction to the change (backlog 153) - so the budget is met here and not on
+ /// screen, and a number from this method is no answer to "is typing fast enough".
///
public static Narrowed Of(Query query, IReadOnlyList everything)
{
diff --git a/src/Bws.Gui/ViewModels/Planned.Running.cs b/src/Bws.Gui/ViewModels/Planned.Running.cs
index a54e150..2f4262a 100644
--- a/src/Bws.Gui/ViewModels/Planned.Running.cs
+++ b/src/Bws.Gui/ViewModels/Planned.Running.cs
@@ -122,7 +122,7 @@ internal void Announce(PlanStep step, int number)
_step = Texts.Of(
"gui.plan.progress",
number,
- _plan?.Steps.Count() ?? 0,
+ _stepsInPlan,
PlanWords.Word(step.Operation),
step.ServiceName);
diff --git a/src/Bws.Gui/ViewModels/Planned.cs b/src/Bws.Gui/ViewModels/Planned.cs
index c294f9f..1bb69f1 100644
--- a/src/Bws.Gui/ViewModels/Planned.cs
+++ b/src/Bws.Gui/ViewModels/Planned.cs
@@ -62,6 +62,9 @@ public sealed partial class Planned : Checked
private BulkPlan? _plan;
private BulkRun? _run;
+ /// How many steps the plan on screen has, counted once when it is shown rather than on every step of a run.
+ private int _stepsInPlan;
+
///
/// What a person calls the one entry this plan is about, handed over by whoever is looking at
/// rows. Empty whenever nobody knew one, which the title and both read.
@@ -253,11 +256,35 @@ private static NamedSentence Line(PlanStep step, int number) =>
///
/// Lines rather than strings since 2026-09-24, because one of them can carry the offer to
/// stop the entry too - says why, and why never on a record.
+ ///
+ /// Built once for each plan and each state of the offer, since 2026-09-28 - G-7 of the
+ /// external performance report counted three builds on every showing, one for each binding that
+ /// asks. Kept beside the plan and the offer it was built for, so it cannot outlive either.
///
- public IReadOnlyList Warnings => _plan is not { } plan
- ? []
- : PlanWarningLine.Of(
- [.. plan.Warnings.Where(warning => !Heavy(warning))], offering: _run is null && !Busy);
+ public IReadOnlyList Warnings
+ {
+ get
+ {
+ if (_plan is not { } plan)
+ {
+ return [];
+ }
+
+ var offering = _run is null && !Busy;
+
+ if (_warnings is not { } kept || !ReferenceEquals(kept.Plan, plan) || kept.Offered != offering)
+ {
+ kept = (plan, offering, PlanWarningLine.Of([.. plan.Warnings.Where(warning => !Heavy(warning))], offering));
+ _warnings = kept;
+ }
+
+ return kept.Lines;
+ }
+ }
+
+ // "Offered" rather than "Offering" for the element, which DeadCodeGuards reads by name and would
+ // take for a caller of ActionBar.Offering.
+ private (BulkPlan Plan, bool Offered, IReadOnlyList Lines)? _warnings;
///
/// The entries that get no plan at all, and why.
@@ -281,8 +308,11 @@ private static NamedSentence Line(PlanStep step, int number) =>
/// Rendered by the core, so these are commands this tool really accepts rather than text that
/// looks like them - a guard in the command line's own tests holds that, and it caught a switch
/// rendered onto a verb that refuses it.
+ ///
+ /// Rendered once, when the plan is shown, since 2026-09-28 - G-7 of the external
+ /// performance report: four bindings read this, and each read rendered every command again.
///
- public IReadOnlyList Commands => _plan is not { } plan ? [] : EquivalentCommand.For(plan);
+ public IReadOnlyList Commands { get; private set; } = [];
///
/// Where this panel is in the only sequence it has: nothing done, doing it, done.
@@ -384,6 +414,8 @@ internal bool Show(BulkPlan plan, string? shownAs = null, string? because = null
// could have: a report of what happened to five services, sitting under the steps of a plan
// for five different ones, with nothing on screen to say the two do not belong together.
_plan = plan;
+ _stepsInPlan = plan.Steps.Count();
+ Commands = EquivalentCommand.For(plan);
_run = null;
Busy = false;
Progress = string.Empty;
@@ -422,6 +454,9 @@ internal bool Hide()
}
_plan = null;
+ _stepsInPlan = 0;
+ Commands = [];
+ _warnings = null;
_run = null;
_shownAs = string.Empty;
_because = string.Empty;
diff --git a/src/Bws.Gui/ViewModels/Readings.OneEntry.cs b/src/Bws.Gui/ViewModels/Readings.OneEntry.cs
index 7cd39b9..9ff2e71 100644
--- a/src/Bws.Gui/ViewModels/Readings.OneEntry.cs
+++ b/src/Bws.Gui/ViewModels/Readings.OneEntry.cs
@@ -8,8 +8,8 @@ namespace Bws.Gui.ViewModels;
///
/// WHY THE PANEL READS AT ALL, and the number decided it. Until 2026-09-24 the panel showed
/// "not read" beside the signature, the memory and who depends on an entry, and sent the reader to
-/// turn on a column - which reads every entry on the machine, 4620-7656 ms for the signatures over
-/// 810. The owner's rule was that the panel reads for itself if the cost allows, and the cost was
+/// turn on a column - which reads every entry on the machine, about 12 s of processor for the
+/// signatures over 797 (2026-09-28). The owner's rule was that the panel reads for itself if the cost allows, and the cost was
/// measured before anything was built (tools/details-probe, 797 entries, five counted runs, each in
/// a fresh process): the three families for one typical service 46.7-66.9 ms the first time a
/// process asks, the signature family 6-13 ms every time after, and 425-518 ms for the one 98 MB
diff --git a/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs b/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs
index 7915413..d603176 100644
--- a/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs
+++ b/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs
@@ -75,7 +75,9 @@ private async Task FillAsync(IReadOnlyList entries)
// Said before the work rather than after it, or the one state this announces would be
// announced only once it had stopped being true - the same argument as the reading above.
- _settled();
+ // The sentence only, since 2026-09-28: the entries have not moved since the reading that
+ // handed them here narrowed and showed them (G-11, _announce).
+ _announce();
IReadOnlyList filled;
@@ -170,9 +172,10 @@ private IReadOnlyList Fill(IReadOnlyList entries, ExtraRead
filled = MemoryPass.Fill(filled, _reader!);
}
- // THE THIRD FAMILY, 2026-09-06, and it sits between the other two in price: 236-259 ms over
- // 313 services against under a millisecond for memory and seven and a half seconds for
- // signatures. Last because it is the only one that goes back to the manager, so a run that
+ // THE THIRD FAMILY, 2026-09-06, and it sits between the other two in price: 148-155 ms over
+ // 797 entries against under a millisecond for memory and about twelve seconds of processor
+ // for signatures (2026-09-28). Last because it is the only one that goes back to the
+ // manager, so a run that
// wants all three has already finished with the files and the processes by the time it
// starts walking services one at a time.
if (wanted.HasFlag(ExtraRead.RequiredBy))
@@ -227,7 +230,8 @@ private IReadOnlyList Fill(IReadOnlyList entries, ExtraRead
/// Answered by reading the machine again rather than by filling in what is held. The
/// entries kept from the last full reading are older than the rows on screen - a tick has been
/// writing statuses into them since - so absorbing them would roll those changes back. Reading
- /// again costs about half a second on top of a pass that costs seconds, and it is the
+ /// again costs about a tenth of a second on sixteen processors on top of a pass that costs
+ /// seconds, and it is the
/// difference between a fresh answer and a stale one.
///
internal bool WantsMore() => Asked();
diff --git a/src/Bws.Gui/ViewModels/Readings.cs b/src/Bws.Gui/ViewModels/Readings.cs
index 3a5a498..1fb0348 100644
--- a/src/Bws.Gui/ViewModels/Readings.cs
+++ b/src/Bws.Gui/ViewModels/Readings.cs
@@ -83,6 +83,15 @@ internal sealed partial class Readings
///
private readonly Action _changed;
+ ///
+ /// That the second pass is setting out, which changes one sentence and nothing else.
+ ///
+ /// Its own callback since 2026-09-28 - G-11 of the external performance report. The pass
+ /// used to announce itself through , which recuts the scope and runs the
+ /// query again over entries the reading just before it had already narrowed and shown.
+ ///
+ private readonly Action _announce;
+
///
/// Whether the window this reads for has gone.
///
@@ -126,6 +135,7 @@ internal Readings(
Func look,
Action settled,
Action changed,
+ Action announce,
IBinaryInspector? inspector = null,
IProcessMemoryReader? reader = null,
Func? wanted = null)
@@ -138,6 +148,7 @@ internal Readings(
_look = look;
_settled = settled;
_changed = changed;
+ _announce = announce;
}
/// Whether the last reading failed outright.
@@ -258,8 +269,9 @@ private async Task LoadEverything()
/// none of them, and a test can call it whenever it likes instead of waiting for seconds
/// to pass.
///
- /// The cheap reading measures 13-22 ms over 810 entries against 423-500 ms for a full one,
- /// which is what makes asking once a second reasonable rather than rude.
+ /// The cheap reading measured 12.5-16.9 ms (p95) over 797 entries across an hour of ticks on
+ /// 2026-09-28, against 107-113 ms for a full one, which is what makes asking once a second
+ /// reasonable rather than rude.
///
internal async Task RefreshAsync()
{
diff --git a/src/Bws.Gui/ViewModels/RowIndex.cs b/src/Bws.Gui/ViewModels/RowIndex.cs
index 69a1188..66d187d 100644
--- a/src/Bws.Gui/ViewModels/RowIndex.cs
+++ b/src/Bws.Gui/ViewModels/RowIndex.cs
@@ -111,7 +111,10 @@ public Freshening Absorb(IReadOnlyList statuses)
moved |= _byName[status.ServiceName].Absorb(status, now);
}
- Fade();
+ // NO FADE HERE SINCE 2026-09-28, which is where it stood beside the window's own on every
+ // tick - G-14 of the external performance report, two walks over every row a second for one
+ // job. The window's tick keeps the other one because it covers more: a tick dropped while a
+ // reading is out never reaches this method, and a highlight must still go out on time.
// Only when something moved. Re-running the filter over 810 entries every second to
// find out that nothing changed would be the one part of this that is genuinely
@@ -163,7 +166,8 @@ public void Fade()
/// What a cheap reading found, and therefore what the window owes.
///
/// Three answers rather than a boolean, because the two that are not "nothing" are owed very
-/// different things - a filter re-run costs milliseconds and a full reading costs half a second.
+/// different things - a filter re-run costs milliseconds and a full reading about a tenth of a
+/// second, with seconds more when a second phase follows it.
/// Returned rather than acted on, so the class that knows about queries stays the class that
/// decides what to do about them.
///
diff --git a/src/Bws.Gui/ViewModels/RowList.cs b/src/Bws.Gui/ViewModels/RowList.cs
index 0b93228..7c477f0 100644
--- a/src/Bws.Gui/ViewModels/RowList.cs
+++ b/src/Bws.Gui/ViewModels/RowList.cs
@@ -168,6 +168,17 @@ public void Reconcile(IReadOnlyList wanted)
return;
}
+ // ONE RESET INSTEAD OF HUNDREDS OF REMOVALS WAS TRIED HERE AND DID NOTHING - backlog 153,
+ // measured 2026-08-11. Swapping 764 separate removals for one reset left the keystroke at the
+ // same 143 ms in the window, because this method and the filter together are under 3 ms and
+ // about 140 ms is the list's own reaction to any change at all. It was taken back, since it
+ // also broke the promise above about selection and scroll for no measured gain. Written here
+ // because the idea is obvious from this code alone - the external performance report of
+ // 2026-09-28 proposed it again for that reason. What is still open is where the 140 ms goes.
+
+ // BUILT ON EVERY CALL, the tick included - unlike `present` below, which waits until
+ // something is out of place. About eight hundred hash insertions a second on a quiet machine
+ // (G-14 of the same report), judged too small to be worth a second code path.
var keeping = new HashSet(wanted);
for (var index = Count - 1; index >= 0; index--)
diff --git a/src/Bws.Gui/ViewModels/Says.cs b/src/Bws.Gui/ViewModels/Says.cs
index 9a07519..5d998c5 100644
--- a/src/Bws.Gui/ViewModels/Says.cs
+++ b/src/Bws.Gui/ViewModels/Says.cs
@@ -350,7 +350,7 @@ internal void Did(string sentence)
/// The quieter of the two, and second in the order for that reason. An action's refusal is
/// about what just failed - newer news than a file read before the window appeared. (A query
/// problem stood in this order too until 2026-09-23, and has a line of its own now.) It outlives the first tick
- /// on purpose: the reading finishes in half a second, and a sentence gone by then is a
+ /// on purpose: the reading finishes in a fraction of a second, and a sentence gone by then is a
/// sentence nobody was given.
///
/// Rule 8 in the one place a window can break it without anything looking wrong. A layout half
diff --git a/src/Bws.Gui/ViewModels/Scopes.cs b/src/Bws.Gui/ViewModels/Scopes.cs
index 947694c..91cf388 100644
--- a/src/Bws.Gui/ViewModels/Scopes.cs
+++ b/src/Bws.Gui/ViewModels/Scopes.cs
@@ -221,10 +221,10 @@ public static class Scopes
/// method which of the six type values belong to which scope, and that is a second definition
/// of what a driver is - the one thing the rest of this file refuses to write down twice.
///
- internal static bool AsksElsewhere(EntryScope scope, string query) => scope switch
+ internal static bool AsksElsewhere(EntryScope scope, QueryAsTyped query) => scope switch
{
- EntryScope.Services => QueryMembers.Carries(query, DriverField, DriverValue, negated: false),
- EntryScope.Drivers => QueryMembers.Carries(query, DriverField, DriverValue, negated: true),
+ EntryScope.Services => query.Carries(DriverField, DriverValue, negated: false),
+ EntryScope.Drivers => query.Carries(DriverField, DriverValue, negated: true),
// Everything leaves nothing out, so nothing can be asked for elsewhere.
EntryScope.Everything => false,
diff --git a/tests/Bws.Architecture.Tests/ShapeCeilings.cs b/tests/Bws.Architecture.Tests/ShapeCeilings.cs
index ba4b2ac..d8f7e88 100644
--- a/tests/Bws.Architecture.Tests/ShapeCeilings.cs
+++ b/tests/Bws.Architecture.Tests/ShapeCeilings.cs
@@ -81,11 +81,13 @@ internal static class ShapeCeilings
internal const int SignaturesNearWidest = 3;
///
- /// 79 members with code in them, the plan's view model Planned across six partial files.
- /// MainWindow is second at 63 across eleven, and those two are the whole band. Neither was ever
- /// near a file ceiling, which is the point of measuring the type rather than the file.
+ /// 78 members with code in them, MainWindow across fourteen partial files. Lowered from 79 on
+ /// 2026-09-28, when the plan's view model Planned - the largest until then - lost one (its
+ /// commands became a value set once per plan, G-7 of the external performance report) and the
+ /// window's own count stood at 78. Neither was ever near a file ceiling, which is the point of
+ /// measuring the type rather than the file.
///
- internal const int MostMethodsInType = 79;
+ internal const int MostMethodsInType = 78;
/// Two at 56 or more: Planned and MainWindow.
internal const int TypesNearMostMethods = 2;
diff --git a/tests/Bws.Gui.Tests/ChosenGuards.cs b/tests/Bws.Gui.Tests/ChosenGuards.cs
index 4e245e4..d16be17 100644
--- a/tests/Bws.Gui.Tests/ChosenGuards.cs
+++ b/tests/Bws.Gui.Tests/ChosenGuards.cs
@@ -134,6 +134,39 @@ public void The_panel_moves_when_the_row_moves()
Assert.NotEqual(before, Says(chosen, "gui.column.account"));
}
+ ///
+ /// A service starting rebuilds the panel ONCE, and its highlight going out three seconds later
+ /// does not rebuild it at all - G-8 of the external performance report of 2026-09-28. A row
+ /// raises its shapes and its highlight beside the word for every cell, and the panel used to
+ /// rebuild all its sections for each of them.
+ ///
+ [Fact]
+ public void A_status_moving_rebuilds_the_panel_once_and_the_highlight_going_out_does_not()
+ {
+ var row = EntryRow.Of(Rows.Entry("Spooler"));
+ var chosen = new Chosen { Row = row };
+
+ chosen.Show();
+
+ var rebuilt = 0;
+
+ chosen.PropertyChanged += (_, changed) =>
+ {
+ if (changed.PropertyName == nameof(Chosen.Sections))
+ {
+ rebuilt++;
+ }
+ };
+
+ Assert.True(row.Absorb(new ScmStatus("Spooler", EntryStatus.Stopped, Reading.Absent()), DateTimeOffset.UnixEpoch));
+ Assert.True(row.RecentlyChanged);
+ Assert.Equal(1, rebuilt);
+
+ row.RecentlyChanged = false;
+
+ Assert.Equal(1, rebuilt);
+ }
+
///
/// A closed panel stops listening, which is the teardown this class would leak through.
///
diff --git a/tests/Bws.Gui.Tests/ColumnMenuGuards.cs b/tests/Bws.Gui.Tests/ColumnMenuGuards.cs
index 86f3abc..0579e13 100644
--- a/tests/Bws.Gui.Tests/ColumnMenuGuards.cs
+++ b/tests/Bws.Gui.Tests/ColumnMenuGuards.cs
@@ -74,7 +74,7 @@ public void Every_column_that_names_a_query_field_names_one_the_language_has()
public void Ticking_a_value_writes_the_member_into_the_query_and_unticking_takes_it_out()
{
var text = string.Empty;
- var menu = new ColumnMenu("status", null, () => text, written => text = written);
+ var menu = new ColumnMenu("status", null, () => QueryAsTyped.Of(text), written => text = written);
Assert.NotEmpty(menu.Values);
@@ -107,7 +107,7 @@ public void Ticking_a_value_writes_the_member_into_the_query_and_unticking_takes
public void A_tick_is_lit_by_the_query_text_rather_than_by_having_been_clicked()
{
var text = "status:stopped";
- var menu = new ColumnMenu("status", null, () => text, written => text = written);
+ var menu = new ColumnMenu("status", null, () => QueryAsTyped.Of(text), written => text = written);
Assert.True(menu.Values.Single(value => value.Label == "stopped").IsOn);
Assert.False(menu.Values.Single(value => value.Label == "running").IsOn);
@@ -137,7 +137,7 @@ public void A_column_with_nothing_to_offer_still_offers_the_way_to_put_it_away()
foreach (var id in noField.Concat(freeText))
{
- var menu = new ColumnMenu(id, null, () => text, written => text = written);
+ var menu = new ColumnMenu(id, null, () => QueryAsTyped.Of(text), written => text = written);
Assert.Empty(menu.Values);
Assert.NotEmpty(menu.HideLabel);
@@ -146,7 +146,7 @@ public void A_column_with_nothing_to_offer_still_offers_the_way_to_put_it_away()
// And the ones that DO have something, so the two lists above are not simply everything.
foreach (var id in new[] { "status", "startType", "entryType", "signature", "triggers" })
{
- Assert.NotEmpty(new ColumnMenu(id, null, () => text, written => text = written).Values);
+ Assert.NotEmpty(new ColumnMenu(id, null, () => QueryAsTyped.Of(text), written => text = written).Values);
}
}
@@ -161,7 +161,7 @@ public void A_column_is_put_away_through_the_pickers_own_choice_and_the_last_one
var text = string.Empty;
var first = bar.Choices.First(choice => choice.IsShown);
- var menu = new ColumnMenu(first.Column.Id, first, () => text, written => text = written);
+ var menu = new ColumnMenu(first.Column.Id, first, () => QueryAsTyped.Of(text), written => text = written);
Assert.True(menu.MayHide);
@@ -177,7 +177,7 @@ public void A_column_is_put_away_through_the_pickers_own_choice_and_the_last_one
}
var alone = bar.Choices.Single(one => one.IsShown);
- var last = new ColumnMenu(alone.Column.Id, alone, () => text, written => text = written);
+ var last = new ColumnMenu(alone.Column.Id, alone, () => QueryAsTyped.Of(text), written => text = written);
Assert.False(last.MayHide);
Assert.NotEmpty(last.HideRefused);
diff --git a/tests/Bws.Gui.Tests/KeyboardTests.cs b/tests/Bws.Gui.Tests/KeyboardTests.cs
index 593731e..ae162ed 100644
--- a/tests/Bws.Gui.Tests/KeyboardTests.cs
+++ b/tests/Bws.Gui.Tests/KeyboardTests.cs
@@ -1,5 +1,6 @@
using System.Windows.Input;
using Bws.Gui.ViewModels;
+using static Bws.Gui.Tests.SearchBoxKeys;
namespace Bws.Gui.Tests;
@@ -511,51 +512,4 @@ public void Control_F_on_the_overview_screen_opens_no_list()
private static Shortcut Wanted(MainWindow window, Key key, bool inTheBox, bool inTheGrid) =>
WpfHost.On(() => window.Wanted(key, ModifierKeys.None, inTheBox, inTheGrid));
-
- ///
- /// What the box holds after somebody typed it, with the keyboard in the box.
- ///
- /// SETTLED FIRST, AND THAT LINE COST AN HOUR ON 2026-09-16. The box's Text binding
- /// attaches LAZILY - queued to the data-bind engine at DataBind priority when the window is
- /// built - and an Invoke from the test thread runs at Send, ahead of that queue. So text set
- /// into a freshly built window was overwritten a moment later by the binding attaching and
- /// transferring the model's empty query into the box: the list then held the six examples
- /// rather than the fields, and only sometimes. Found from a stack trace on TextChanged
- /// (BindingExpression.AttachToContext under DataBindEngine.Run), not by reasoning. A shown
- /// window has drained that queue long before anybody types - this host never shows one.
- ///
- ///
- /// Characters typed into the box the way a keyboard types them - through the text input event
- /// the box's own editor answers - rather than by setting its text.
- ///
- /// The difference is the order of the box's two events, and since 2026-09-25 the list cares.
- /// Setting Text reports the new text with the caret at zero and moves the caret after, which the
- /// list reads as typing followed by a caret move - and a caret move closes it. A keystroke
- /// inserts at the caret and reports the text with the caret already past what it inserted.
- ///
- private static void Keyed(MainWindow window, string characters) =>
- WpfHost.On(() =>
- {
- foreach (var character in characters)
- {
- var composition = new TextComposition(InputManager.Current, window.Search.Box, character.ToString());
-
- window.Search.Box.RaiseEvent(new TextCompositionEventArgs(Keyboard.PrimaryDevice, composition)
- {
- RoutedEvent = TextCompositionManager.TextInputEvent
- });
- }
- });
-
- private static void Typed(MainWindow window, string text, int caret)
- {
- WpfHost.Settled();
-
- WpfHost.On(() =>
- {
- window.Search.Box.Text = text;
- window.Search.Box.CaretIndex = caret;
- ((MainViewModel)window.DataContext).Suggesting.Keyboard(present: true);
- });
- }
}
diff --git a/tests/Bws.Gui.Tests/MinimisedWindowGuards.cs b/tests/Bws.Gui.Tests/MinimisedWindowGuards.cs
new file mode 100644
index 0000000..cd32004
--- /dev/null
+++ b/tests/Bws.Gui.Tests/MinimisedWindowGuards.cs
@@ -0,0 +1,128 @@
+using System.Diagnostics;
+using System.Windows;
+using Bws.Gui.ViewModels;
+
+namespace Bws.Gui.Tests;
+
+///
+/// A window nobody can see asks the machine nothing - backlog 467, G-5 of the external performance
+/// report of 2026-09-28.
+///
+/// Minimised is the case that was missing. WPF leaves IsVisible true on a minimised window,
+/// and the timer was stopped only when IsVisible changed - so a window minimised for an afternoon
+/// read the manager once a second all afternoon while a comment beside the timer said it did not.
+///
+/// Held by what the machine is asked, not by the timer. The fake machine counts every
+/// reading, which is the effect this is about, and a property handing the timer out for a test is
+/// one more thing the product carries for nobody (DeadCodeGuards).
+///
+public sealed class MinimisedWindowGuards
+{
+ ///
+ /// Minimised asks nothing for longer than the timer's own second, and coming back asks at once
+ /// rather than a second later - the list on screen is what the machine looked like when the
+ /// window went away.
+ ///
+ [Fact]
+ public async Task A_minimised_window_stops_asking_and_asks_at_once_when_it_comes_back()
+ {
+ var machine = new LiveMachine(Rows.Entry("Spooler", "Print Spooler"));
+ var model = new MainViewModel(machine, new SteppedClock());
+
+ await model.LoadAsync();
+
+ var window = WpfHost.Window(model);
+
+ WpfHost.On(() =>
+ {
+ window.WindowStyle = WindowStyle.None;
+ window.ShowInTaskbar = false;
+ window.Left = -4000;
+ window.Show();
+ });
+
+ // The first look reads the machine and starts the timer, whose tick is a cheap reading.
+ WpfHost.Until(() => machine.StatusReads > 0, "the window started asking once a second");
+
+ WpfHost.On(() => window.WindowState = WindowState.Minimized);
+
+ // A reading already out when the window went away is let finish, or it would be counted
+ // against the minimised window.
+ await Task.Delay(200);
+ WpfHost.Settled();
+
+ var minimised = Asked(machine);
+
+ await Task.Delay(1500);
+
+ Assert.Equal(minimised, Asked(machine));
+
+ WpfHost.On(() => window.WindowState = WindowState.Normal);
+
+ // "At once" is sooner than the timer's first tick, which is a second after it starts.
+ var clock = Stopwatch.StartNew();
+
+ while (Asked(machine) == minimised && clock.ElapsedMilliseconds < 600)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(
+ Asked(machine) > minimised,
+ "Coming back from minimised asked the machine nothing for 600 ms - the timer's first tick is a second away.");
+
+ WpfHost.On(window.Close);
+ }
+
+ ///
+ /// A window opened minimised - a shortcut set to "Run: Minimized" - reads the machine once for
+ /// its first look and then waits to be looked at. The first look starts the timer itself, so it
+ /// has to ask the same question the rest of this file asks.
+ ///
+ [Fact]
+ public async Task A_window_opened_minimised_reads_once_and_waits_to_be_looked_at()
+ {
+ var machine = new LiveMachine(Rows.Entry("Spooler", "Print Spooler"));
+ var model = new MainViewModel(machine, new SteppedClock());
+
+ await model.LoadAsync();
+
+ var window = WpfHost.Window(model);
+
+ WpfHost.On(() =>
+ {
+ window.WindowStyle = WindowStyle.None;
+ window.ShowInTaskbar = false;
+ window.Left = -4000;
+ window.WindowState = WindowState.Minimized;
+ window.Show();
+ });
+
+ // The model was read once before the window, and the first look reads it again.
+ WpfHost.Until(() => machine.FullReads >= 2, "the first look read the machine");
+
+ await Task.Delay(200);
+ WpfHost.Settled();
+
+ var looked = Asked(machine);
+
+ await Task.Delay(1500);
+
+ Assert.Equal(looked, Asked(machine));
+
+ WpfHost.On(() => window.WindowState = WindowState.Normal);
+
+ var clock = Stopwatch.StartNew();
+
+ while (Asked(machine) == looked && clock.ElapsedMilliseconds < 600)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(Asked(machine) > looked, "Coming back to a window opened minimised asked the machine nothing for 600 ms.");
+
+ WpfHost.On(window.Close);
+ }
+
+ private static int Asked(LiveMachine machine) => machine.StatusReads + machine.FullReads;
+}
diff --git a/tests/Bws.Gui.Tests/PlanSheetListsTests.cs b/tests/Bws.Gui.Tests/PlanSheetListsTests.cs
new file mode 100644
index 0000000..3a8a342
--- /dev/null
+++ b/tests/Bws.Gui.Tests/PlanSheetListsTests.cs
@@ -0,0 +1,53 @@
+using Bws.Core.Planning;
+using Bws.Gui.ViewModels;
+
+namespace Bws.Gui.Tests;
+
+///
+/// The lists on the plan sheet are built once for a plan, not once for every binding that reads
+/// them - G-7 of the external performance report of 2026-09-28, which counted four renderings of
+/// every command and three builds of the warnings on one showing.
+///
+public sealed class PlanSheetListsTests
+{
+ [Fact]
+ public void The_commands_and_the_warnings_are_the_same_lists_however_often_they_are_read()
+ {
+ var panel = new Planned { Elevated = true };
+
+ Assert.True(panel.Show(Over(ActionKind.Stop, "Spooler", "W32Time")));
+
+ Assert.NotEmpty(panel.Commands);
+ Assert.NotEmpty(panel.Warnings);
+ Assert.Same(panel.Commands, panel.Commands);
+ Assert.Same(panel.Warnings, panel.Warnings);
+ Assert.Equal(EquivalentCommand.For(panel.Plan!), panel.Commands);
+
+ // Kept beside the plan and never past it: a new plan is a new list.
+ var first = panel.Commands;
+
+ Assert.True(panel.Show(Over(ActionKind.Start, "Spooler")));
+ Assert.NotSame(first, panel.Commands);
+ Assert.Equal(EquivalentCommand.For(panel.Plan!), panel.Commands);
+
+ Assert.True(panel.Hide());
+ Assert.Empty(panel.Commands);
+ Assert.Empty(panel.Warnings);
+ }
+
+ private static BulkPlan Over(ActionKind kind, params string[] names) => new()
+ {
+ Action = new BulkAction(kind, names),
+ Plans =
+ [
+ .. names.Select(name => new OperationPlan
+ {
+ Action = new ServiceAction(kind, name),
+ Steps = [new PlanStep(name, name, kind == ActionKind.Start ? StepOperation.Start : StepOperation.Stop, StepReason.Requested)],
+ Warnings = [new PlanWarning(PlanWarningKind.ReturnsAfterReboot, name, [])],
+ Problems = []
+ })
+ ],
+ Problems = []
+ };
+}
diff --git a/tests/Bws.Gui.Tests/PlanViewGuards.cs b/tests/Bws.Gui.Tests/PlanViewGuards.cs
index 3f84d3c..4f51948 100644
--- a/tests/Bws.Gui.Tests/PlanViewGuards.cs
+++ b/tests/Bws.Gui.Tests/PlanViewGuards.cs
@@ -331,7 +331,11 @@ public void It_takes_no_focus_of_its_own_and_still_takes_clicks()
[Fact]
public async Task Every_word_on_the_panel_can_be_read_on_the_surface_it_is_drawn_on()
{
- var window = await Ready();
+ // WITH THE DRIVER, OR THE SENTENCE TEMPLATE IS NEVER BUILT. Since the warnings got their own
+ // template (PR 17) the plain sentence template draws only the problem list, and a plan over
+ // two services has no problems - so its colour could go and this guard stayed green, which
+ // the full gate before 0.3.0 caught as a mutation nobody killed (backlog 464).
+ var window = await Ready(withTheDriver: true);
var model = WpfHost.On(() => (MainViewModel)window.DataContext);
Assert.True(await WpfHost.On(() => window.Preview(ActionKind.Stop)));
@@ -379,6 +383,7 @@ public async Task Every_word_on_the_panel_can_be_read_on_the_surface_it_is_drawn
Assert.True(read.Count(line => !string.IsNullOrWhiteSpace(line)) >= 5, "Too few lines were found on the panel, so nothing was measured.");
Assert.Contains(WpfHost.On(() => model.Planned.Heading.Text), read);
Assert.Contains(WpfHost.On(() => model.Planned.Steps[0].Text), read);
+ Assert.Contains(WpfHost.On(() => model.Planned.Problems[0]), read);
Assert.True(
thin.Count == 0,
@@ -472,7 +477,7 @@ private static double Channel(byte value)
return part <= 0.04045 ? part / 12.92 : Math.Pow((part + 0.055) / 1.055, 2.4);
}
- private static async Task Ready()
+ private static async Task Ready(bool withTheDriver = false)
{
var machine = new LiveMachine(
Rows.Entry("Spooler", "Print Spooler"),
@@ -480,7 +485,14 @@ private static async Task Ready()
Rows.Entry("Dnscache", "DNS Client"),
Rows.Driver("amdkmdag"));
- var model = new MainViewModel(machine, new SteppedClock());
+ // THE DRIVER IS ON THE LIST ONLY WHEN ASKED FOR, because the Services list is the default and
+ // most of this file is about the panel over services. Over Everything it is picked beside the
+ // two services, and the core refuses every action on a driver (PlanBuilder.RefusedOnSight) -
+ // which is the one sure way to put a sentence into the problem section.
+ var model = new MainViewModel(machine, new SteppedClock())
+ {
+ Scope = withTheDriver ? EntryScope.Everything : EntryScope.Services
+ };
await model.LoadAsync();
@@ -495,6 +507,11 @@ private static async Task Ready()
window.Entries.ItemsSource = model.Rows;
window.Entries.SelectedItem = model.Rows.First(row => row.ServiceName == "Spooler");
window.Entries.SelectedItems.Add(model.Rows.First(row => row.ServiceName == "W32Time"));
+
+ if (withTheDriver)
+ {
+ window.Entries.SelectedItems.Add(model.Rows.First(row => row.ServiceName == "amdkmdag"));
+ }
});
WpfHost.Settled();
diff --git a/tests/Bws.Gui.Tests/QueryExampleTests.cs b/tests/Bws.Gui.Tests/QueryExampleTests.cs
index c3ac00a..bf3f155 100644
--- a/tests/Bws.Gui.Tests/QueryExampleTests.cs
+++ b/tests/Bws.Gui.Tests/QueryExampleTests.cs
@@ -84,7 +84,7 @@ public void No_example_asks_for_a_reading_the_window_does_not_take()
[Fact]
public void The_question_about_what_should_be_running_is_the_one_the_chip_asks()
{
- var chip = FilterChips.Grouped(() => string.Empty, _ => { })
+ var chip = FilterChips.Grouped(() => QueryAsTyped.Of(string.Empty), _ => { })
.SelectMany(group => group.Chips)
.Single(chip => chip.Label == Texts.Of("gui.filter.shouldRun"));
diff --git a/tests/Bws.Gui.Tests/QueryParsedOnceGuards.cs b/tests/Bws.Gui.Tests/QueryParsedOnceGuards.cs
new file mode 100644
index 0000000..0a0e761
--- /dev/null
+++ b/tests/Bws.Gui.Tests/QueryParsedOnceGuards.cs
@@ -0,0 +1,103 @@
+using System.Runtime.ExceptionServices;
+using Bws.Core.Querying;
+using Bws.Gui.ViewModels;
+
+namespace Bws.Gui.Tests;
+
+///
+/// One keystroke parses the query once - G-4 of the external performance report of 2026-09-28,
+/// which counted eighteen parses of the same text: one to narrow the list, one for the sentence
+/// about the scope and one for each of the sixteen filter chips.
+///
+/// Counted through the one thing a parse leaves behind that can be seen from outside, and
+/// without a counter written into the product for a test. A pattern with a backreference is valid
+/// and refused by the linear engine, so building it throws and catches (QueryPatterns.TryPattern) -
+/// S-10 of the same report - and every parse of the line throws the same number of times. The
+/// exceptions are counted only in this test's own flow of work (an AsyncLocal), because the event
+/// is heard by the whole process and other tests run beside this one.
+///
+public sealed class QueryParsedOnceGuards
+{
+ private const string Refused = "/(s)\\1/";
+
+ [Fact]
+ public async Task One_keystroke_parses_the_query_once_and_a_tick_does_not_parse_it_again()
+ {
+ var once = await Thrown(() =>
+ {
+ QueryAsTyped.Of(Refused);
+
+ return Task.CompletedTask;
+ });
+
+ // A GUARD SATISFIED BY ABSENCE: if the engine ever supports backreferences, nothing throws,
+ // both counts are zero and equal, and this would pass while counting nothing.
+ Assert.True(once > 0, "The pattern is no longer refused by the linear engine, so nothing here is counted - pick a construct it refuses.");
+
+ var machine = new LiveMachine(Rows.Entry("Spooler", "Print Spooler"), Rows.Entry("W32Time", "Windows Time"));
+ var model = new MainViewModel(machine, new SteppedClock());
+
+ await model.LoadAsync();
+
+ // The chips are read the way their bindings read them after the text changed.
+ var typed = await Thrown(() =>
+ {
+ model.QueryText = Refused;
+ Lit(model);
+
+ return Task.CompletedTask;
+ });
+
+ Assert.Equal(once, typed);
+
+ // A tick that finds a service moved re-runs the query over the list and asks the chips
+ // again - over the same text, so over the same parse.
+ machine.Stop("Spooler");
+
+ var ticked = await Thrown(async () =>
+ {
+ await model.RefreshAsync();
+ Lit(model);
+ });
+
+ Assert.Equal(0, ticked);
+ }
+
+ private static void Lit(MainViewModel model)
+ {
+ foreach (var chip in model.Filters)
+ {
+ _ = chip.IsOn;
+ }
+ }
+
+ ///
+ /// How many times the linear engine refused a pattern while this work ran, in this flow only.
+ ///
+ private static async Task Thrown(Func work)
+ {
+ var mine = new AsyncLocal { Value = true };
+ var count = 0;
+
+ void Seen(object? sender, FirstChanceExceptionEventArgs thrown)
+ {
+ if (mine.Value && thrown.Exception is NotSupportedException)
+ {
+ Interlocked.Increment(ref count);
+ }
+ }
+
+ AppDomain.CurrentDomain.FirstChanceException += Seen;
+
+ try
+ {
+ await work();
+ }
+ finally
+ {
+ AppDomain.CurrentDomain.FirstChanceException -= Seen;
+ }
+
+ return count;
+ }
+}
diff --git a/tests/Bws.Gui.Tests/SearchBoxKeys.cs b/tests/Bws.Gui.Tests/SearchBoxKeys.cs
new file mode 100644
index 0000000..a495971
--- /dev/null
+++ b/tests/Bws.Gui.Tests/SearchBoxKeys.cs
@@ -0,0 +1,62 @@
+using System.Windows.Input;
+using Bws.Gui.ViewModels;
+
+namespace Bws.Gui.Tests;
+
+///
+/// The two ways a test puts text into the window's search box - set, or typed key by key - shared by
+/// every file that asks the window about the list under the box.
+///
+/// Out of KeyboardTests on 2026-09-28, when a new test of the box's wiring would have pushed
+/// that file past the share of its ceiling the size ratchet allows. One copy of each, because the
+/// two long notes below are what the helpers are worth and a second copy would drift from the first.
+///
+internal static class SearchBoxKeys
+{
+ ///
+ /// Characters typed into the box the way a keyboard types them - through the text input event
+ /// the box's own editor answers - rather than by setting its text.
+ ///
+ /// The difference is the order of the box's two events, and since 2026-09-25 the list cares.
+ /// Setting Text reports the new text with the caret at zero and moves the caret after, which the
+ /// list reads as typing followed by a caret move - and a caret move closes it. A keystroke
+ /// inserts at the caret and reports the text with the caret already past what it inserted.
+ ///
+ internal static void Keyed(MainWindow window, string characters) =>
+ WpfHost.On(() =>
+ {
+ foreach (var character in characters)
+ {
+ var composition = new TextComposition(InputManager.Current, window.Search.Box, character.ToString());
+
+ window.Search.Box.RaiseEvent(new TextCompositionEventArgs(Keyboard.PrimaryDevice, composition)
+ {
+ RoutedEvent = TextCompositionManager.TextInputEvent
+ });
+ }
+ });
+
+ ///
+ /// What the box holds after somebody typed it, with the keyboard in the box.
+ ///
+ /// SETTLED FIRST, AND THAT LINE COST AN HOUR ON 2026-09-16. The box's Text binding
+ /// attaches LAZILY - queued to the data-bind engine at DataBind priority when the window is
+ /// built - and an Invoke from the test thread runs at Send, ahead of that queue. So text set
+ /// into a freshly built window was overwritten a moment later by the binding attaching and
+ /// transferring the model's empty query into the box: the list then held the six examples
+ /// rather than the fields, and only sometimes. Found from a stack trace on TextChanged
+ /// (BindingExpression.AttachToContext under DataBindEngine.Run), not by reasoning. A shown
+ /// window has drained that queue long before anybody types - this host never shows one.
+ ///
+ internal static void Typed(MainWindow window, string text, int caret)
+ {
+ WpfHost.Settled();
+
+ WpfHost.On(() =>
+ {
+ window.Search.Box.Text = text;
+ window.Search.Box.CaretIndex = caret;
+ ((MainViewModel)window.DataContext).Suggesting.Keyboard(present: true);
+ });
+ }
+}
diff --git a/tests/Bws.Gui.Tests/SecondPassSettingOutTests.cs b/tests/Bws.Gui.Tests/SecondPassSettingOutTests.cs
new file mode 100644
index 0000000..bb7511d
--- /dev/null
+++ b/tests/Bws.Gui.Tests/SecondPassSettingOutTests.cs
@@ -0,0 +1,70 @@
+using Bws.Core;
+using Bws.Gui.ViewModels;
+
+namespace Bws.Gui.Tests;
+
+///
+/// The second pass setting out - G-11 of the external performance report of 2026-09-28.
+///
+/// It says the list is still being filled and does nothing else. Until that day it said so
+/// by recutting the scope and running the query again over entries the reading just before it had
+/// narrowed and shown - a whole rethink of the window to change one sentence.
+///
+public sealed class SecondPassSettingOutTests
+{
+ private const string File = @"C:\windows\system32\spoolsv.exe";
+
+ ///
+ /// While the pass reads, the sentence under the list says the signatures are being read - the
+ /// one thing setting out has to say - and the query runs twice over the whole reading: once for
+ /// the entries, once for what the pass filled in. Never a third time in between.
+ ///
+ [Fact]
+ public async Task Setting_out_says_the_signatures_are_being_read_and_runs_the_query_only_around_the_pass()
+ {
+ MainViewModel? model = null;
+ string? saidWhileReading = null;
+
+ // Read from inside the pass, which is the moment "still reading" has to be true.
+ var inspector = new Recording(() => saidWhileReading ??= model!.Says.Notice);
+
+ model = new MainViewModel(
+ new LiveMachine(Rows.Entry("Spooler") with { BinaryFile = Reading.Present(File) }),
+ new SteppedClock(),
+ inspector);
+
+ await model.LoadAsync();
+
+ model.QueryText = "signed:no";
+
+ var rethought = 0;
+
+ model.Filters[0].PropertyChanged += (_, changed) =>
+ {
+ if (changed.PropertyName == nameof(FilterChip.IsOn))
+ {
+ rethought++;
+ }
+ };
+
+ await model.RefreshAsync();
+
+ Assert.NotNull(saidWhileReading);
+ Assert.Contains(Texts.Of("gui.query.readingSignatures"), saidWhileReading, StringComparison.Ordinal);
+ Assert.Equal(2, rethought);
+ }
+
+ private sealed class Recording(Action asked) : IBinaryInspector
+ {
+ public Reading ReadSignature(string file)
+ {
+ asked();
+
+ return Reading.Present(new BinarySignature(SignatureStatus.NotSigned, 0, "Someone"));
+ }
+
+ public Reading ReadFileVersion(string file) => Reading.Present("1.0.0.0");
+
+ public Reading ReadHash(string file) => Reading.Present(new string('a', 64));
+ }
+}
diff --git a/tests/Bws.Gui.Tests/SuggestingTests.cs b/tests/Bws.Gui.Tests/SuggestingTests.cs
index d21ef7e..d5eb56a 100644
--- a/tests/Bws.Gui.Tests/SuggestingTests.cs
+++ b/tests/Bws.Gui.Tests/SuggestingTests.cs
@@ -376,7 +376,7 @@ public void A_value_carries_the_label_of_its_chip_and_nothing_where_there_is_no_
// opposite of what writing the value would do. No such chip exists in the row today, so
// one is built here to hold the rule.
var excluding = new Suggesting(
- () => [new FilterChip("gui.filter.stopped", "type", "driver", negated: true, () => string.Empty, _ => { })],
+ () => [new FilterChip("gui.filter.stopped", "type", "driver", negated: true, () => QueryAsTyped.Of(string.Empty), _ => { })],
() => QueryExamples.All);
excluding.Keyboard(present: true);
diff --git a/tests/Bws.Gui.Tests/SuggestingWindowTests.cs b/tests/Bws.Gui.Tests/SuggestingWindowTests.cs
new file mode 100644
index 0000000..c053835
--- /dev/null
+++ b/tests/Bws.Gui.Tests/SuggestingWindowTests.cs
@@ -0,0 +1,40 @@
+using Bws.Gui.ViewModels;
+using static Bws.Gui.Tests.SearchBoxKeys;
+
+namespace Bws.Gui.Tests;
+
+///
+/// The list under the search box as the WINDOW wires it - the box's events reaching the model.
+/// Everything the list decides is asked of the model without a window (SuggestingTests,
+/// SuggestingTypingTests), and the keys that belong to it are in KeyboardTests.
+///
+public sealed class SuggestingWindowTests
+{
+ ///
+ /// The box's two events reach the list from the window - its text through typing, its caret
+ /// through a move that changes no text.
+ ///
+ /// Not through writing a row, which is where this used to be held: since PR 22 writing a
+ /// row tells the list itself (Suggesting.Wrote), so a test of it stayed green with both events
+ /// unwired - the full gate before 0.3.0 found that as two mutations nobody killed, backlog 464.
+ ///
+ [Fact]
+ public void Typing_in_the_box_opens_the_list_and_a_caret_moved_alone_closes_it()
+ {
+ var window = WpfHost.Window();
+ var model = WpfHost.On(() => (MainViewModel)window.DataContext);
+
+ Typed(window, string.Empty, 0);
+ Keyed(window, "sta");
+
+ Assert.True(model.Suggesting.IsOpen);
+ Assert.Equal(["status", "start"], model.Suggesting.Offered.Select(row => row.Word));
+
+ WpfHost.On(() => window.Search.Box.CaretIndex = 1);
+
+ Assert.Equal("sta", WpfHost.On(() => window.Search.Box.Text));
+ Assert.False(model.Suggesting.IsOpen);
+
+ WpfHost.On(window.Close);
+ }
+}
diff --git a/tests/Bws.Integration.Tests/PerformanceBudgetTests.cs b/tests/Bws.Integration.Tests/PerformanceBudgetTests.cs
index 81a239c..71d1100 100644
--- a/tests/Bws.Integration.Tests/PerformanceBudgetTests.cs
+++ b/tests/Bws.Integration.Tests/PerformanceBudgetTests.cs
@@ -125,9 +125,10 @@ public void Expensive_data_for_the_whole_machine_stays_inside_the_budget()
///
/// This test exists to answer a question the window's design rests on rather than to watch a
/// number. Building a plan asks the manager who depends on each entry, so a selection of
- /// twenty costs a question per entry plus one per cascade member - and the window works that out
- /// on the interface thread, with no background pass, no cancellation and no generation counter.
- /// That choice was made on a measurement and this is the measurement kept where it can go red.
+ /// twenty costs a question per entry plus one per cascade member. The window worked that out on
+ /// the interface thread until 2026-09-03, when it moved off it with a generation counter
+ /// (PlanBuildingGuards) - and the budget here still stands, because a preview slower than section
+ /// 8.1 allows is late whichever thread is waiting for it.
///
/// What was measured before the code, through the command line, five runs each with the first
/// discarded: a plan with a thirteen member cascade took 316-360 ms end to end and the same
@@ -192,6 +193,6 @@ public void A_plan_over_a_large_selection_is_worked_out_inside_the_window_s_budg
$"the {budget.TotalSeconds:F0} s section 8.1 gives this window for an answer. CHECK WHAT " +
"ELSE WAS RUNNING BEFORE LOOKING AT THE CODE - backlog 200 is about exactly that shape on " +
"the test above. If the run was alone, the cost has started scaling with the selection, and " +
- "the window works this out on the thread that draws it.");
+ "the window waits that long for the plan to show.");
}
}