1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
|
# Exclude from search Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a third right-click search operation, "Exclude from search", which narrows the current query by everything that is not the value under the cursor.
**Architecture:** `SearchTerm` gains an `exclude()` beside `extend()` and a `SearchMode` enum. The `bool extend` carried from the two menu surfaces to `MainWindow` widens to that enum across four signatures. `MainWindow` pushes a `hasQuery` bool down into `MessageView` so both menus can grey the new entry when there is nothing to exclude from.
**Tech Stack:** C++17, Qt 6.11, QtTest. Build with CMake + Ninja.
**Spec:** `docs/superpowers/specs/2026-08-14-exclude-from-search-design.md`. Backlog item 86.
---
## Before you start
Build and test commands, from the repo root:
```bash
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build --output-on-failure
```
**Never run a test binary without `QT_QPA_PLATFORM=offscreen`**, and never
launch `./build/src/qtmaildir`. `tests/CMakeLists.txt` sets that variable for
ctest only, so a binary invoked directly inherits the desktop's Wayland setting
and throws real windows onto the user's screen. Running the application is a
hand test and belongs to the user.
Commits are GPG-signed (`git commit -S`). Work directly on `master`.
## File Structure
| File | Responsibility | Change |
|---|---|---|
| `src/searchterm.h` | Query grammar declarations, `SearchMode` | Modify: add enum + `exclude()` |
| `src/searchterm.cpp` | Query grammar | Modify: add `exclude()` |
| `tests/test_searchterm.cpp` | Grammar tests, no widgets | Modify: 3 new cases |
| `src/messageview.h/.cpp` | Message pane, header + body menus | Modify: signal type, `setHasQuery`, third entry |
| `src/messagedetailsdialog.h/.cpp` | Per-row detail menus | Modify: signal type, ctor arg, third entry |
| `tests/test_messagedetailsdialog.cpp` | Dialog tests | Modify: update calls, add guard test |
| `src/mainwindow.h/.cpp` | Owns the query bar, runs the query | Modify: mode switch, push `hasQuery` |
Task order matters: the grammar first, then the enum that every signature
depends on, then the two surfaces, then the guard.
---
### Task 1: `SearchTerm::exclude()`
The grammar, with no enum and no UI yet. Pure functions, no widget.
**Files:**
- Modify: `src/searchterm.h` (after the `extend()` declaration, ~line 77)
- Modify: `src/searchterm.cpp` (after `extend()`, ~line 91)
- Test: `tests/test_searchterm.cpp`
- [ ] **Step 1: Write the failing tests**
Add three slot declarations to the `private slots:` block in
`tests/test_searchterm.cpp`, after `extendOntoAnEmptyQueryIsAReplace();`:
```cpp
void excludeParenthesisesBothSides();
void excludeFromAnEmptyQueryIsEmpty();
void excludeWithNothingToExcludeLeavesTheQuery();
```
Add the three implementations at the end of the file, before the
`QTEST_MAIN` / `#include "test_searchterm.moc"` lines:
```cpp
void TestSearchTerm::excludeParenthesisesBothSides()
{
// The query bar can hold a hand-written disjunction. Unparenthesised,
// `a or b AND NOT c` binds as `a or (b AND NOT c)`: the exclusion covers
// only the second term and every message matching `a` stays on screen,
// including the ones the user asked to be rid of. notmuch reports no
// error for either form, so this assertion is the only thing that fails.
QCOMPARE(SearchTerm::exclude(QStringLiteral("tag:inbox or tag:flagged"),
QStringLiteral("from:\"someone\"")),
QStringLiteral("(tag:inbox or tag:flagged) AND NOT "
"(from:\"someone\")"));
}
void TestSearchTerm::excludeFromAnEmptyQueryIsEmpty()
{
// Deliberately NOT extend()'s behaviour. extend() returns the addition
// alone, because narrowing nothing by x sensibly means x. Excluding from
// nothing would mean the whole Maildir minus one value, which is a
// legitimate query and an implausible thing to have meant by right
// clicking a value in a fresh window. The UI greys the entry; this is the
// second layer, against a caller that forgets the guard.
QCOMPARE(SearchTerm::exclude(QString(), QStringLiteral("tag:inbox")),
QString());
QCOMPARE(SearchTerm::exclude(QStringLiteral(" "),
QStringLiteral("tag:inbox")),
QString());
}
void TestSearchTerm::excludeWithNothingToExcludeLeavesTheQuery()
{
QCOMPARE(SearchTerm::exclude(QStringLiteral("tag:inbox"), QString()),
QStringLiteral("tag:inbox"));
QCOMPARE(SearchTerm::exclude(QString(), QString()), QString());
}
```
- [ ] **Step 2: Run the tests to verify they fail**
```bash
cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_searchterm
```
Expected: a COMPILE failure, `'exclude' is not a member of 'SearchTerm'`. That
is the correct failure at this stage.
- [ ] **Step 3: Declare it**
In `src/searchterm.h`, immediately after the `extend()` declaration:
```cpp
/// Narrows `existing` by everything that is NOT `addition`, as
/// `(existing) AND NOT (addition)`.
///
/// **Both sides are parenthesised, for the same load-bearing reason as
/// extend().** The query bar may hold a hand-written disjunction, and
/// `a or b AND NOT c` binds as `a or (b AND NOT c)`: the exclusion would
/// cover only the second term, leaving on screen exactly the mail the user
/// asked to be rid of, with no error reported anywhere.
///
/// **An empty `existing` yields an EMPTY STRING, unlike extend().** Excluding
/// from nothing would mean the entire Maildir minus one value: a legitimate
/// query, and an implausible thing to have meant by right-clicking a value in
/// a fresh window. The menus grey the entry out when the query bar is empty;
/// this is the second layer, against a caller that forgets the guard.
///
/// An empty `addition` leaves `existing` untouched.
QString exclude(const QString &existing, const QString &addition);
```
- [ ] **Step 4: Implement it**
In `src/searchterm.cpp`, immediately after `extend()`:
```cpp
QString exclude(const QString &existing, const QString &addition)
{
const QString left = existing.trimmed();
const QString right = addition.trimmed();
if (right.isEmpty())
return left;
// NOT a replace, unlike extend(): see the header. An empty left would make
// this "everything except", which no right-click asked for.
if (left.isEmpty())
return QString();
return QStringLiteral("(%1) AND NOT (%2)").arg(left, right);
}
```
- [ ] **Step 5: Run the tests to verify they pass**
```bash
cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_searchterm
```
Expected: PASS, all cases.
- [ ] **Step 6: Commit**
```bash
git add src/searchterm.h src/searchterm.cpp tests/test_searchterm.cpp
git commit -S -m "feat(search): add SearchTerm::exclude
Parenthesises both sides, as extend() does: unparenthesised, a
disjunction in the query bar binds so the exclusion covers only its
last term and leaves the excluded mail on screen.
An empty existing query returns empty rather than the addition alone,
which is where this deliberately differs from extend(). Excluding from
nothing means the whole Maildir minus one value; the menus grey the
entry out and this is the second layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 2: The `SearchMode` enum
Introduce the type on its own, before anything uses it. No behaviour change.
**Files:**
- Modify: `src/searchterm.h`
- [ ] **Step 1: Add the enum**
In `src/searchterm.h`, inside `namespace SearchTerm`, immediately BEFORE the
`quote()` declaration (so it reads before the functions it selects between):
```cpp
/// Which of the three search operations a menu entry asked for.
///
/// Replaces the `bool extend` that carried two operations, which had no room
/// for a third. Plain enum class, NOT registered as a metatype: every
/// connection carrying it is direct, within the UI thread. The `Q_ENUM` trap
/// recorded in CLAUDE.md belongs to the queued signals into NotmuchWorker and
/// does not apply here.
enum class SearchMode {
/// Replace the query bar outright.
Replace,
/// Narrow what is there, via extend().
Narrow,
/// Narrow by everything that is not this value, via exclude().
Exclude,
};
```
- [ ] **Step 2: Verify it compiles**
```bash
cmake --build build
```
Expected: builds clean. Nothing uses the type yet.
- [ ] **Step 3: Commit**
```bash
git add src/searchterm.h
git commit -S -m "refactor(search): add SearchMode, the type replacing bool extend
Introduced alone, ahead of the four signatures that change to it, so
that change is a mechanical one commit later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 3: Widen the four signatures
Mechanical: `bool extend` becomes `SearchMode` end to end, with the two
existing operations mapping to `Replace` and `Narrow`. No new menu entry yet,
so behaviour is unchanged and the suite must stay green.
**Files:**
- Modify: `src/messageview.h` (signal, ~line 193), `src/messageview.cpp` (~lines 566-580, ~648)
- Modify: `src/messagedetailsdialog.h` (~lines 75, 80), `src/messagedetailsdialog.cpp` (~lines 88-96, 148)
- Modify: `src/mainwindow.h` (~line 381), `src/mainwindow.cpp` (~line 1654)
- Modify: `tests/test_messagedetailsdialog.cpp` (~lines 135-136, 177)
- [ ] **Step 1: Update the existing test calls first**
`tests/test_messagedetailsdialog.cpp` already calls `requestSearch` with a
bool, so it stops compiling the moment the signature changes. Update it now so
the build error you see next is only ever the one you intend.
At `tests/test_messagedetailsdialog.cpp:135-136`, replace:
```cpp
dialog.requestSearch(*from, false);
dialog.requestSearch(*from, true);
```
with:
```cpp
dialog.requestSearch(*from, SearchTerm::SearchMode::Replace);
dialog.requestSearch(*from, SearchTerm::SearchMode::Narrow);
```
At `tests/test_messagedetailsdialog.cpp:177`, replace:
```cpp
dialog.requestSearch(*id, false);
```
with:
```cpp
dialog.requestSearch(*id, SearchTerm::SearchMode::Replace);
```
Both spies assert on `searchRequested`'s arguments. Where a spy compares the
second argument against a bool, compare against the enum instead, e.g.
`QCOMPARE(spy.at(0).at(1).value<SearchTerm::SearchMode>(), SearchTerm::SearchMode::Replace);`.
Read the surrounding assertions and adjust each to match; do not guess at
their shape.
- [ ] **Step 2: Change `MessageView`**
In `src/messageview.h`, replace the `searchRequested` signal and the tail of
its doc comment:
```cpp
/// `mode` says whether to replace the query bar, narrow it, or narrow it
/// by everything that is not this value. The view does not know what the
/// query bar holds and must not: the window owns that field and does the
/// combining.
///
/// Separate from queryRequested(), which carries a gate against a link in
/// a rendered document driving the thread list. These menus are chrome
/// built by our own code from values we extracted, so they need no gate,
/// and widening the existing signal would change what that gate protects.
void searchRequested(const QString &query, SearchTerm::SearchMode mode);
```
In `src/messageview.cpp:566`, `addSearchEntries` becomes:
```cpp
void MessageView::addSearchEntries(QMenu *menu, const QList<SearchOffer> &offers)
{
for (const SearchOffer &entry : offers) {
auto *sub = menu->addMenu(tr("Search for %1").arg(entry.label));
auto *replace = sub->addAction(tr("Search for this"));
connect(replace, &QAction::triggered, this, [this, entry]() {
emit searchRequested(entry.query, SearchTerm::SearchMode::Replace);
});
auto *narrow = sub->addAction(tr("Add to search"));
connect(narrow, &QAction::triggered, this, [this, entry]() {
emit searchRequested(entry.query, SearchTerm::SearchMode::Narrow);
});
}
}
```
In `src/messageview.cpp:~648`, the dialog connection. Keep the comment above it
untouched, it documents the accept()-before-emit ordering:
```cpp
connect(&dialog, &MessageDetailsDialog::searchRequested, this,
[this, &dialog](const QString &query, SearchTerm::SearchMode mode) {
dialog.accept();
emit searchRequested(query, mode);
});
```
- [ ] **Step 3: Change `MessageDetailsDialog`**
In `src/messagedetailsdialog.h`:
```cpp
/// Emits searchRequested for `row`, or nothing when the row carries no
/// searchable query. The menu entries call this; a test can too, without
/// popping a menu.
void requestSearch(const HeaderRow &row, SearchTerm::SearchMode mode);
signals:
/// The user chose a search from a row's menu. `mode` says whether to
/// replace the query, narrow it, or narrow it by everything that is not
/// this value.
void searchRequested(const QString &query, SearchTerm::SearchMode mode);
```
In `src/messagedetailsdialog.cpp:148`:
```cpp
void MessageDetailsDialog::requestSearch(const HeaderRow &row,
SearchTerm::SearchMode mode)
{
if (row.query.isEmpty())
return;
emit searchRequested(row.query, mode);
}
```
In `src/messagedetailsdialog.cpp:~88`, the per-row menu lambda:
```cpp
QMenu menu(this);
auto *replace = menu.addAction(tr("Search for this"));
connect(replace, &QAction::triggered, this,
[this, row]() {
requestSearch(
row, SearchTerm::SearchMode::Replace);
});
auto *narrow = menu.addAction(tr("Add to search"));
connect(narrow, &QAction::triggered, this,
[this, row]() {
requestSearch(
row, SearchTerm::SearchMode::Narrow);
});
menu.exec(value->mapToGlobal(pos));
```
- [ ] **Step 4: Change `MainWindow`**
In `src/mainwindow.h:~381`, update the declaration and its comment:
```cpp
/// `mode` says whether to replace the query bar, narrow it, or narrow it
/// by everything that is not this value. The panes do not read the query
/// bar; this is where the combining happens.
void runSearchFromPane(const QString &query, SearchTerm::SearchMode mode);
```
In `src/mainwindow.cpp:1654`:
```cpp
void MainWindow::runSearchFromPane(const QString &query,
SearchTerm::SearchMode mode)
{
if (query.isEmpty())
return;
QString next;
switch (mode) {
case SearchTerm::SearchMode::Replace:
next = query;
break;
case SearchTerm::SearchMode::Narrow:
next = SearchTerm::extend(m_queryEdit->text(), query);
break;
case SearchTerm::SearchMode::Exclude:
next = SearchTerm::exclude(m_queryEdit->text(), query);
break;
}
// exclude() returns empty when there is nothing to exclude from, which the
// greyed menu entry should already have prevented. Running it would clear
// the query bar and show the whole Maildir, so refuse instead.
if (next.isEmpty())
return;
// Through the query bar and the existing runner, so the account scope, the
// generation counter and the flat-mode reset all behave exactly as they do
// for a typed query. Nothing here builds a second query path.
m_queryEdit->setText(next);
runCurrentQuery();
}
```
- [ ] **Step 5: Build and run the full suite**
```bash
cmake --build build && ctest --test-dir build --output-on-failure
```
Expected: builds clean, all tests pass. This task changes no behaviour, so a
failure here is a mistake in the mechanical change, not a real finding.
- [ ] **Step 6: Commit**
```bash
git add src/messageview.h src/messageview.cpp src/messagedetailsdialog.h \
src/messagedetailsdialog.cpp src/mainwindow.h src/mainwindow.cpp \
tests/test_messagedetailsdialog.cpp
git commit -S -m "refactor(search): carry SearchMode instead of bool extend
Four signatures, no behaviour change: the two shipped operations map to
Replace and Narrow. runSearchFromPane becomes a switch and gains the
Exclude arm, which nothing can reach until the menu entry exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 4: The `hasQuery` guard
`MainWindow` pushes down whether the query bar holds anything, so the menus can
grey the new entry. Still no third entry, so still no behaviour change.
**Files:**
- Modify: `src/messageview.h`, `src/messageview.cpp`
- Modify: `src/messagedetailsdialog.h`, `src/messagedetailsdialog.cpp`
- Modify: `src/mainwindow.cpp:~370`
- [ ] **Step 1: Add the setter to `MessageView`**
In `src/messageview.h`, in the public section near the other small accessors:
```cpp
/// Tells the pane whether the query bar currently holds anything.
///
/// The menus need it to grey out "Exclude from search": excluding from an
/// empty query would mean the whole Maildir minus one value. The pane
/// cannot read the query bar and must not, so the window pushes the fact
/// down as it changes. Passed on to the details dialog at construction,
/// which is built fresh per invocation and so cannot go stale.
void setHasQuery(bool hasQuery) { m_hasQuery = hasQuery; }
```
And in the private members, beside `m_headerOffers`:
```cpp
bool m_hasQuery = false;
```
- [ ] **Step 2: Take it in the dialog's constructor**
In `src/messagedetailsdialog.h`:
```cpp
explicit MessageDetailsDialog(const QList<ThreadRenderItem> &items,
bool hasQuery = false,
QWidget *parent = nullptr);
```
and in the private members:
```cpp
bool m_hasQuery = false;
```
In `src/messagedetailsdialog.cpp`, update the constructor definition to match
the new parameter list and store it (`m_hasQuery(hasQuery)` in the init list,
keeping the existing `QDialog(parent)` base and the existing body unchanged).
The default argument keeps every existing test call compiling; a test that
cares passes it explicitly.
- [ ] **Step 3: Pass it at the construction site**
In `src/messageview.cpp`, where the dialog is constructed inside
`showDetailsDialog()` (just above the connect from Task 3, ~line 640), add the
argument:
```cpp
MessageDetailsDialog dialog(m_items, m_hasQuery, this);
```
Read the existing line first and preserve its exact variable name and the
`this` parent; only the middle argument is new.
- [ ] **Step 4: Push it from `MainWindow`**
In `src/mainwindow.cpp`, the `updateSaveState` lambda at ~line 370 already
runs on every `textChanged`. Widen it, and rename it to say what it now does:
```cpp
auto updateQueryState = [this, save]() {
const bool hasQuery = !m_queryEdit->text().trimmed().isEmpty();
save->setEnabled(hasQuery);
// The message pane greys "Exclude from search" without it: there
// is nothing to exclude from.
m_messageView->setHasQuery(hasQuery);
};
connect(m_queryEdit, &QLineEdit::textChanged, this, updateQueryState);
updateQueryState();
```
**Ordering, verified 2026-08-14, no guard needed.** The line numbers look
alarming: this lambda is at ~370 and `m_messageView` is constructed at
`src/mainwindow.cpp:668`. But this block lives in `registerActions()`, called
from the constructor at line 344, while both `m_queryEdit` (495) and
`m_messageView` (668) are constructed inside `buildUi()`, called one line
earlier at 343. Both pointers are live by the time the lambda is defined or
invoked. Do not add an `if (m_messageView)` guard: it would be dead code that
implies a hazard which does not exist.
- [ ] **Step 5: Build and run the full suite**
```bash
cmake --build build && ctest --test-dir build --output-on-failure
```
Expected: builds clean, all tests pass, no behaviour change.
- [ ] **Step 6: Commit**
```bash
git add src/messageview.h src/messageview.cpp src/messagedetailsdialog.h \
src/messagedetailsdialog.cpp src/mainwindow.cpp
git commit -S -m "feat(search): push the query bar's emptiness into the panes
The menus cannot read the query bar and must not. MainWindow already
watched textChanged for the Save button; the same lambda now tells
MessageView, which passes it to the details dialog at construction.
Nothing consumes it yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 5: The third menu entry
**Files:**
- Modify: `src/messageview.cpp:566`
- Modify: `src/messagedetailsdialog.cpp:~88`
- Test: `tests/test_messagedetailsdialog.cpp`
- [ ] **Step 1: Write the failing test**
Add to the `private slots:` block in `tests/test_messagedetailsdialog.cpp`:
```cpp
void excludeIsOfferedOnlyWithAQueryToExcludeFrom();
```
And the implementation, modelled on the existing
`offersASearchForEachValue()`. Read that test first and reuse its fixture
construction verbatim, including how it builds `items` and finds a row:
```cpp
void TestMessageDetailsDialog::excludeIsOfferedOnlyWithAQueryToExcludeFrom()
{
// Build `items` exactly as offersASearchForEachValue() does.
const QList<ThreadRenderItem> items = /* same fixture as that test */;
// The menu is built per row in a customContextMenuRequested lambda and
// cannot be popped without a real context-menu event, so assert on the
// property the entry's enabled state is derived from: the dialog was told
// whether a query exists.
MessageDetailsDialog withQuery(items, true);
MessageDetailsDialog withoutQuery(items, false);
QVERIFY(withQuery.canExcludeFromSearch());
QVERIFY(!withoutQuery.canExcludeFromSearch());
// And the emit itself refuses when there is nothing to exclude from, so
// the guard does not rest on the menu alone.
const auto rows = withoutQuery.rows();
const auto *from = std::find_if(
rows.cbegin(), rows.cend(),
[](const HeaderRow &row) { return !row.query.isEmpty(); });
QVERIFY(from != rows.cend());
QSignalSpy spy(&withoutQuery, &MessageDetailsDialog::searchRequested);
withoutQuery.requestSearch(*from, SearchTerm::SearchMode::Exclude);
QCOMPARE(spy.count(), 0);
}
```
This requires one small accessor, added in the same step, in
`src/messagedetailsdialog.h`:
```cpp
/// Whether "Exclude from search" is offered. False with an empty query
/// bar: there would be nothing to exclude from. Exposed for testing
/// without popping a context menu.
bool canExcludeFromSearch() const { return m_hasQuery; }
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_messagedetailsdialog
```
Expected: FAIL on the `spy.count()` assertion, because `requestSearch` does not
yet refuse an Exclude with no query. (`canExcludeFromSearch()` passes as soon
as the accessor exists, which is fine; the emit guard is the real subject.)
- [ ] **Step 3: Guard the emit**
In `src/messagedetailsdialog.cpp`, `requestSearch`:
```cpp
void MessageDetailsDialog::requestSearch(const HeaderRow &row,
SearchTerm::SearchMode mode)
{
if (row.query.isEmpty())
return;
// Nothing to exclude from: the entry is greyed, and this is the second
// layer in case it is reached another way.
if (mode == SearchTerm::SearchMode::Exclude && !m_hasQuery)
return;
emit searchRequested(row.query, mode);
}
```
- [ ] **Step 4: Add the entry to the dialog's menu**
In `src/messagedetailsdialog.cpp`, after the `narrow` action in the per-row
lambda and before `menu.exec(...)`:
```cpp
auto *exclude =
menu.addAction(tr("Exclude from search"));
// Visible but greyed rather than hidden: someone
// exploring a fresh window is exactly who should see
// that the feature exists.
exclude->setEnabled(m_hasQuery);
connect(exclude, &QAction::triggered, this,
[this, row]() {
requestSearch(
row, SearchTerm::SearchMode::Exclude);
});
```
- [ ] **Step 5: Add the entry to the message pane's menus**
In `src/messageview.cpp`, `addSearchEntries`, after the `narrow` action:
```cpp
auto *exclude = sub->addAction(tr("Exclude from search"));
// Visible but greyed rather than hidden, as in the details dialog.
exclude->setEnabled(m_hasQuery);
connect(exclude, &QAction::triggered, this, [this, entry]() {
emit searchRequested(entry.query, SearchTerm::SearchMode::Exclude);
});
```
- [ ] **Step 6: Run the full suite**
```bash
cmake --build build && ctest --test-dir build --output-on-failure
```
Expected: all tests pass, including the new one.
- [ ] **Step 7: Mutation check**
Verify the new test can fail. Temporarily change the guard in `requestSearch`
to `if (false && mode == ...)`, rebuild, and confirm
`test_messagedetailsdialog` FAILS. Then revert the change and rebuild.
**Do not restore from a backup file.** A backup taken before a fix and restored
afterwards silently reverted a fix in the item 85 session, leaving the test and
its comment orphaned. Edit the line back by hand and re-read it.
**If the binary hangs rather than failing**, that is item 85's ordering trap:
without `accept()` before the emit, nothing leaves `exec()`. Kill the binary
and rebuild before concluding anything, since a later `ctest` will otherwise
re-run a stale one.
- [ ] **Step 8: Commit**
```bash
git add src/messageview.cpp src/messagedetailsdialog.h \
src/messagedetailsdialog.cpp tests/test_messagedetailsdialog.cpp
git commit -S -m "feat(search): offer Exclude from search in both menus
Third entry in the message pane's submenus and in each details row,
greyed rather than hidden when the query bar is empty, so the feature
stays visible to someone exploring a fresh window.
requestSearch refuses an Exclude with no query as well, so the guard
does not rest on the menu's enabled state alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 6: Close the item
**Files:**
- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md`
- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md`
- Modify: `CHANGELOG.md`
- [ ] **Step 1: Run the full suite one more time**
```bash
cmake --build build && ctest --test-dir build --output-on-failure
```
Record the actual pass count for the commit message. Do not write a number you
did not read.
- [ ] **Step 2: Hand it to the user**
Do NOT launch the application. Tell the user what to look at: right-click a
header value, a body selection and a details row, with the query bar empty and
then with a query in it, and confirm the third entry is greyed in the first
case and narrows correctly in the second. Wait for their verdict before the
remaining steps.
- [ ] **Step 3: Move the backlog section**
Set item 86's status cell to `**done** 2026-08-14` with the spec reference, and
move its whole `## 86.` section from the backlog to
`2026-08-03-post-0.1.0-usability-closed.md`, keeping the number. The backlog
keeps the table row only. Move it on this commit, not in a later cleanup pass.
- [ ] **Step 4: Add the changelog entry**
Under `## [Unreleased]`, in `### Added`:
```markdown
- A third right-click search action, **Exclude from search**, which narrows the
current query by everything that is not the value under the cursor. Offered
everywhere the other two are, and greyed out when the query bar is empty,
since there would be nothing to exclude from.
```
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md \
docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md \
CHANGELOG.md
git commit -S -m "docs: close item 86, excluding a value from a search
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
## Self-review notes
Spec coverage, section by section: the grammar and its empty-case table are
Task 1; the `SearchMode` enum is Task 2 and its four signatures Task 3; the
`setHasQuery` guard is Task 4; the menus are Task 5; the testing section is
split across Tasks 1 and 5, including the mutation check and both traps it
inherits. "Out of scope" needs no task, the thread list is untouched throughout.
Type consistency: `SearchTerm::SearchMode` is spelled with its namespace at
every use outside `searchterm.h`. `m_hasQuery` is the member in both
`MessageView` and `MessageDetailsDialog`; `setHasQuery` is the setter on the
view and a constructor argument on the dialog, deliberately different because
the dialog is rebuilt per invocation.
Two steps ask the implementer to read surrounding code rather than trusting the
plan: the spy assertions in Task 3 Step 1, and the dialog fixture in Task 5
Step 1. Both are existing test code whose exact shape is not reproduced here;
the plan says so explicitly rather than inventing it.
|