kinjo 0.2.0

Kinjo: mDNS TUI and commands launch for local network services
Documentation
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
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
use std::{
    collections::{BTreeMap, HashMap},
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    },
    time::Duration,
};

use color_eyre::eyre::Result;
use crossterm::event::{
    self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
    MouseEventKind,
};
use ratatui::{
    DefaultTerminal,
    layout::{Position, Rect},
};

use crate::{
    discovery::{
        BrowseMode, DiscoveryEvent, DiscoverySession, Entry, EntryGroup, EntryGroupId, EntryId,
        GroupingMode, RowHost, SessionPoll, SessionState, browse_groups, browse_row_count,
    },
    plumber::{ActionOutcome, CommandConfig, MatchResult, PreparedCommand, RuleEngine},
};

use super::{
    cli::Cli,
    filter::FilterState,
    keymap::{Action, KeyBindings, Mode as KeyMode},
    layout::{Content, LayoutSnapshot},
    render,
    viewport::Window,
};

/// What a reload attempt came back with. There is no third case: a reload
/// either produces a rule set complete enough to replace the running one, or it
/// produces the reasons it does not, and the running one stays.
pub enum ReloadOutcome {
    /// The whole configured overlay compiled. Safe to install.
    Loaded(Box<dyn RuleEngine>),
    /// At least one file or directory was invalid, so no rule set was built.
    /// Each diagnostic names its source and what was wrong with it.
    Rejected(Vec<String>),
}

/// Loads a fresh rule set for a config reload (SIGHUP). Injected by the
/// composition root so the app stays decoupled from config-file I/O.
///
/// The loader validates; the app installs or does not. Deciding *outside* the
/// app whether a candidate rule set is complete is what makes the swap
/// transactional: by the time the app sees a [`ReloadOutcome::Loaded`], nothing
/// is left to go wrong half way through.
pub type ConfigLoader = Box<dyn Fn(&Cli) -> ReloadOutcome>;

/// Starts a replacement discovery session for a service-list refresh.
///
/// Takes nothing: the composition root builds it around the same validated
/// discovery options the startup session used. A refresh therefore repeats that
/// browse exactly, and the app never handles — or could re-derive — unvalidated
/// discovery inputs.
pub type DiscoveryFactory = Box<dyn Fn() -> DiscoverySession>;

/// One row of the "group by command" view: a configured command together with
/// the distinct logical services it matches.
#[derive(Debug, Clone)]
pub struct CommandGroup {
    pub command: CommandConfig,
    pub services: Vec<EntryGroup>,
}

/// What an open picker was opened from, as an identity rather than as the data
/// it listed.
///
/// Discovery keeps arriving while a picker is up. A picker that owned a copy of
/// its matches went on offering services that had already been retracted, and
/// confirming it ran a command against a hostname or address that no longer
/// existed. Remembering *what was chosen* lets the matches be rebuilt from
/// current records whenever they change, so a picker can only ever list — and
/// only ever run — what discovery still stands behind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickerAnchor {
    /// A row of the active browse projection.
    Row(EntryGroupId),
    /// A logical service listed under a command row. The command view projects
    /// rules rather than entries, so its rows are not browse rows and cannot be
    /// found among them.
    Service {
        command: String,
        service: EntryGroupId,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
    Browse,
    Search,
    TypeFilter,
    ActionPicker,
    InstancePicker,
    ServicePicker,
    Help,
}

impl AppMode {
    /// The keybinding mode this UI mode resolves keys in. The three pickers
    /// differ only in what they list, so they share one set of bindings.
    pub fn key_mode(self) -> KeyMode {
        match self {
            AppMode::Browse => KeyMode::Browse,
            AppMode::Search => KeyMode::Search,
            AppMode::TypeFilter => KeyMode::TypeFilter,
            AppMode::ActionPicker | AppMode::InstancePicker | AppMode::ServicePicker => {
                KeyMode::Picker
            }
            AppMode::Help => KeyMode::Help,
        }
    }
}

pub struct App {
    pub cli: Cli,
    pub matcher: Box<dyn RuleEngine>,
    pub keybindings: KeyBindings,
    /// The running discovery session: its events, its state, and its shutdown.
    /// One value, so the receiver and the adapter behind it cannot drift apart;
    /// dropping or replacing it stops the producer.
    pub session: DiscoverySession,
    pub records: BTreeMap<EntryId, Entry>,
    pub filter: FilterState,
    pub visible_groups: Vec<EntryGroup>,
    pub selected: usize,
    pub mode: AppMode,
    pub type_filter_index: usize,
    /// What an open picker is a view of. Set when a picker opens, cleared when
    /// it closes, and the handle every rebuild below resolves against.
    pub picker_anchor: Option<PickerAnchor>,
    /// The actions an open picker lists. Rebuilt from `picker_anchor` on every
    /// recompute, never carried across one, so it cannot outlive its records.
    pub action_matches: Vec<MatchResult>,
    pub action_index: usize,
    /// The action an open instance picker is choosing a target for. Rebuilt
    /// alongside `action_matches`.
    pub pending_action: Option<MatchResult>,
    pub instance_index: usize,
    pub status: String,
    /// Set by the quit keybindings; the event loop exits when it is true.
    pub should_quit: bool,
    /// Set from the SIGHUP handler; the event loop polls it and reloads the
    /// command configs when it flips to true.
    pub reload_requested: Arc<AtomicBool>,
    /// Reloads command configs on request; reload is unavailable when unset.
    pub config_loader: Option<ConfigLoader>,
    /// Why the most recent reload was rejected, in full: one entry per invalid
    /// source, naming it and what was wrong with it.
    ///
    /// The status line is transient — the next tick's message replaces it — but
    /// a rejected reload is something the user has to act on, so the detail is
    /// kept here until it is either superseded or printed on exit. The policy is
    /// latest-only: a reload reports on the configuration as it is *now*, so a
    /// successful reload clears this, and a later failure replaces it rather
    /// than piling up a history of edits already fixed.
    pub reload_diagnostics: Vec<String>,
    /// Starts a replacement discovery session; refresh is unavailable when unset.
    pub discovery_factory: Option<DiscoveryFactory>,
    /// Per-group action matches, parallel to `visible_groups`. Computed once
    /// per recompute so rendering and invocation share one result instead of
    /// re-running the matcher (regexes included) every frame.
    pub group_matches: Vec<Vec<MatchResult>>,
    /// How many rows each top-panel tab lists, in [`GroupingMode::TABS`] order.
    /// Recomputed with the visible rows from the same filtered records, so a
    /// tab's count always matches the list it would show.
    pub tab_counts: [usize; GroupingMode::TABS.len()],
    pub ticks: u64,
    /// Rows of the "group by command" view; populated only in that grouping mode.
    pub command_groups: Vec<CommandGroup>,
    /// Cursor within the service picker opened from a command row.
    pub service_picker_index: usize,
    /// Top line shown in the help overlay (0 = unscrolled). Help is generated
    /// from the active bindings, so it can be taller than the popup; this is how
    /// far down it the reader has moved. Clamped against the content when it
    /// changes, and again by the renderer's window, so a resize cannot strand it
    /// past the end.
    pub help_scroll: usize,
    /// Top line shown in the details pane (0 = unscrolled).
    pub details_scroll: usize,
    /// Where this frame's panels are and what bounds they impose.
    ///
    /// Computed by [`App::update_layout`] from the terminal area and the
    /// current content, before both drawing and input, so the frame the user
    /// clicked on and the geometry the click is resolved against are the same
    /// one. Rendering reads it and writes nothing back.
    pub(crate) layout: LayoutSnapshot,
}

impl App {
    pub fn new(
        cli: Cli,
        matcher: impl RuleEngine + 'static,
        keybindings: KeyBindings,
        session: DiscoverySession,
    ) -> Self {
        let status = format!(
            "domain: {} | commands: {} | waiting for services",
            cli.domain,
            matcher.command_count()
        );
        Self {
            cli,
            matcher: Box::new(matcher),
            keybindings,
            session,
            records: BTreeMap::new(),
            filter: FilterState::default(),
            visible_groups: Vec::new(),
            selected: 0,
            mode: AppMode::Browse,
            type_filter_index: 0,
            picker_anchor: None,
            action_matches: Vec::new(),
            action_index: 0,
            pending_action: None,
            instance_index: 0,
            status,
            should_quit: false,
            reload_requested: Arc::new(AtomicBool::new(false)),
            config_loader: None,
            reload_diagnostics: Vec::new(),
            discovery_factory: None,
            group_matches: Vec::new(),
            tab_counts: [0; GroupingMode::TABS.len()],
            ticks: 0,
            command_groups: Vec::new(),
            service_picker_index: 0,
            help_scroll: 0,
            details_scroll: 0,
            // Nothing has been drawn yet, and a layout claiming otherwise would
            // let a click land on a row that does not exist. The event loop
            // replaces this before the first frame.
            layout: LayoutSnapshot::default(),
        }
    }

    /// Attach a factory for replacement discovery sessions, enabling the
    /// refresh command. The running session itself comes from [`App::new`].
    pub fn with_discovery_factory(mut self, factory: DiscoveryFactory) -> Self {
        self.discovery_factory = Some(factory);
        self
    }

    /// Attach a command-config loader, enabling reload-on-SIGHUP.
    pub fn with_config_loader(mut self, loader: ConfigLoader) -> Self {
        self.config_loader = Some(loader);
        self
    }

    pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<PreparedCommand>> {
        let _mouse_capture = MouseCaptureGuard::enable();
        self.recompute_visible();

        loop {
            self.ticks = self.ticks.wrapping_add(1);
            self.poll_reload();
            self.drain_discovery();

            // Settle the terminal size before measuring against it, so a resize
            // is a new layout for this frame rather than the previous frame's
            // one applied to a screen that has already changed shape.
            terminal.autoresize()?;
            self.update_layout(terminal.get_frame().area());
            terminal.draw(|frame| render::render(frame, self))?;

            if event::poll(Duration::from_millis(120))? {
                match event::read()? {
                    Event::Key(key) => {
                        if key.kind == KeyEventKind::Release {
                            continue;
                        }
                        // The screen the frame just drawn used: a key that moves
                        // a modal window is bounded by the geometry the user is
                        // looking at, from the same snapshot render drew from.
                        if let Some(command) = self.handle_key(key, self.layout.area())? {
                            return Ok(Some(command));
                        }
                        if self.should_quit {
                            return Ok(None);
                        }
                    }
                    Event::Mouse(mouse) => self.handle_mouse(mouse),
                    _ => {}
                }
            }
        }
    }

    /// Work out where this frame's panels are, and bring the details scroll
    /// back inside them.
    ///
    /// Called once per tick before drawing and before any event is handled, so
    /// the geometry render draws with, the bounds a scroll key is clamped to,
    /// and the rectangles a click is tested against are all the same snapshot.
    /// A resize or a shorter selection therefore cannot strand the reader below
    /// the end of the content: the next snapshot pulls the scroll back up.
    pub(crate) fn update_layout(&mut self, area: Rect) {
        self.layout = LayoutSnapshot::compute(
            area,
            Content {
                list_total: self.active_count(),
                details_total: render::details_content_height(self),
            },
        );
        self.details_scroll = self.details_scroll.min(self.layout.details_max_scroll());
    }

    /// Take everything the session has produced since the last tick, and notice
    /// if it has ended. Crate-visible for the same reason as
    /// [`App::update_layout`]: a test that wants a frame the event loop could
    /// actually have drawn has to drive the loop's steps, not simulate them.
    pub(crate) fn drain_discovery(&mut self) {
        let mut changed = false;
        loop {
            let event = match self.session.poll() {
                SessionPoll::Event(event) => event,
                SessionPoll::Idle => break,
                // The producer is gone. Reported once, so this reacts to the
                // ending rather than re-applying it on every tick.
                SessionPoll::Ended(state) => {
                    changed |= self.apply_session_end(&state);
                    break;
                }
            };
            match event {
                DiscoveryEvent::Upsert(record) => {
                    let id = record.id();
                    // A real occurrence supersedes the registration's
                    // unresolved placeholder, if one is still listed.
                    if !id.is_pending() {
                        self.records
                            .remove(&EntryId::pending(record.registration()));
                    }
                    self.records.insert(id, record);
                    changed = true;
                }
                DiscoveryEvent::Remove(id) => {
                    // Exactly the named occurrence: siblings of the same
                    // registration on other interfaces stay live.
                    changed |= self.records.remove(&id).is_some();
                }
                DiscoveryEvent::RemoveRegistration(registration) => {
                    let before = self.records.len();
                    self.records
                        .retain(|id, _| *id.registration() != registration);
                    changed |= self.records.len() != before;
                }
                DiscoveryEvent::Status(status) => {
                    self.status = status;
                }
            }
        }
        if changed {
            self.recompute_visible();
        }
    }

    /// React to discovery ending, once.
    ///
    /// A real adapter's records are only worth showing while a live browse is
    /// confirming them: mDNS is edge-triggered, so once the producer is gone
    /// nothing will ever retract a service that has since died. Keeping the
    /// list up while labelling it current would invite the user to launch a
    /// command at a host that may no longer be there, so a failure clears it.
    ///
    /// A finite fake stream completing is the opposite case: it is the normal,
    /// expected ending, it never claimed to be watching the network, and its
    /// samples stay exactly as valid as they were. Returns whether the visible
    /// list needs recomputing.
    fn apply_session_end(&mut self, state: &SessionState) -> bool {
        match state {
            // Cannot be reached: the session only reports an ending once it has
            // left `Listening`.
            SessionState::Listening => false,
            SessionState::Complete => {
                self.status = format!(
                    "sample discovery complete | {} record(s)",
                    self.records.len()
                );
                false
            }
            SessionState::Failed(failure) => {
                // Pickers were computed from records that are about to go.
                self.close_pickers();
                let had_records = !self.records.is_empty();
                self.records.clear();
                // Set last: the cause must be what the user is left looking at.
                self.status = failure.message();
                had_records
            }
        }
    }

    fn recompute_visible(&mut self) {
        let records = self.records.values().cloned().collect::<Vec<_>>();
        self.filter.observe_types(&records);
        let filtered = self.filter.apply(&records);
        self.tab_counts = self.count_tabs(&filtered);

        // The command tab groups configured rules rather than projecting the
        // discovered entries, so it has its own row builder.
        let Some(browse) = self.filter.grouping.browse_mode() else {
            self.recompute_command_groups(&filtered);
            return;
        };

        self.command_groups = Vec::new();
        let previous = self
            .visible_groups
            .get(self.selected)
            .map(|group| group.id().clone());
        self.visible_groups = browse_groups(&filtered, browse);
        self.group_matches = self
            .visible_groups
            .iter()
            .map(|group| self.matcher.matches_group(group))
            .collect();
        // Structured row identity, not the list position: the cursor stays on
        // the row the user chose even when rows appear, vanish, or re-sort.
        match find_selection(&self.visible_groups, previous.clone(), |group| {
            group.id().clone()
        }) {
            Some(index) => self.selected = index,
            None => self.clamp_selection(),
        }
        self.refocus_details(previous, |app| {
            app.visible_groups.get(app.selected).map(|g| g.id().clone())
        });
        self.reconcile_action_pickers();
    }

    /// Settle the details scroll after the rows underneath the cursor have been
    /// rebuilt, given what was focused `before`.
    ///
    /// Scroll position is a place inside one row's details, so it only means
    /// anything for as long as that row is the one in focus. When the identity
    /// under the cursor changes — the row was removed, filtered away, or the
    /// clamp moved the cursor onto a neighbour — the reader is looking at a
    /// different service, and starting them part-way down its details is at best
    /// arbitrary. The same identity keeps its scroll; the next
    /// [`App::update_layout`] clamps it to whatever the new content is tall
    /// enough for.
    fn refocus_details<K: PartialEq>(
        &mut self,
        before: Option<K>,
        focused: impl Fn(&Self) -> Option<K>,
    ) {
        if focused(self) != before {
            self.details_scroll = 0;
        }
    }

    /// The rules matching an anchor, freshly matched against current records.
    ///
    /// Empty when the anchor no longer resolves — its row was filtered away, its
    /// service was retracted, or its rule stopped matching — which is exactly the
    /// signal reconciliation needs.
    fn resolve_anchor(&self, anchor: &PickerAnchor) -> Vec<MatchResult> {
        match anchor {
            PickerAnchor::Row(id) => self
                .visible_groups
                .iter()
                .position(|group| group.id() == id)
                .and_then(|index| self.group_matches.get(index))
                .cloned()
                .unwrap_or_default(),
            PickerAnchor::Service { command, service } => self
                .command_groups
                .iter()
                .find(|group| group.command.name == *command)
                .and_then(|group| group.services.iter().find(|s| s.id() == service))
                .map(|group| {
                    self.matcher
                        .matches_group(group)
                        .into_iter()
                        .filter(|result| result.command.name == *command)
                        .collect()
                })
                .unwrap_or_default(),
        }
    }

    /// What an anchor is called, for a status line the user can act on.
    fn anchor_label(&self, anchor: &PickerAnchor) -> String {
        match anchor {
            PickerAnchor::Row(id) => self
                .visible_groups
                .iter()
                .find(|group| group.id() == id)
                .map(|group| group.label().to_string())
                .unwrap_or_else(|| "the selected service".to_string()),
            PickerAnchor::Service { service, .. } => self
                .command_groups
                .iter()
                .flat_map(|group| &group.services)
                .find(|s| s.id() == service)
                .map(|group| group.label().to_string())
                .unwrap_or_else(|| "the selected service".to_string()),
        }
    }

    /// Rebuild an open action/instance picker from current records, keeping its
    /// cursor on the identity the user chose.
    ///
    /// Called after every recompute, which is the only thing that can change the
    /// records underneath. The picker therefore always lists what discovery
    /// currently says, and the key handler — which runs against the same state
    /// the frame was drawn from — cannot confirm anything else.
    fn reconcile_action_pickers(&mut self) {
        let Some(anchor) = self.picker_anchor.clone() else {
            return;
        };

        // The identities under the cursors, read from the caches before they are
        // replaced. Positions mean nothing across a rebuild; these do.
        let chosen_action = self
            .action_matches
            .get(self.action_index)
            .map(|result| result.command.name.clone());
        let pending_command = self
            .pending_action
            .as_ref()
            .map(|result| result.command.name.clone());
        // A target's identity is the command it would run — task 006's own
        // dedup key. An occurrence id would not do: address-expanded candidates
        // all share one, so the cursor would snap back to the first address.
        // This also makes "the argv changed under the cursor" indistinguishable
        // from "the target is gone", which is what it should mean here.
        let chosen_target = self.pending_action.as_ref().and_then(|result| {
            result
                .targets
                .get(self.instance_index)
                .map(|target| result.command.action.prepare(target).ok())
        });

        let matches = self.resolve_anchor(&anchor);
        if matches.is_empty() {
            let label = self.anchor_label(&anchor);
            self.abandon_picker(format!("`{label}` is no longer available"));
            return;
        }
        self.action_matches = matches;

        if self.mode == AppMode::ActionPicker {
            // An action the user was not on disappearing is not their problem;
            // the one they were on disappearing is.
            match chosen_action.and_then(|name| self.position_of_action(&name)) {
                Some(index) => self.action_index = index,
                None => self.abandon_picker("the selected action no longer matches".to_string()),
            }
            return;
        }

        // Instance picker: the rule must still match, and the occurrence the
        // cursor is on must still be one of its targets.
        let Some(name) = pending_command else {
            self.abandon_picker("the selected action no longer matches".to_string());
            return;
        };
        let Some(live) = self
            .action_matches
            .iter()
            .find(|result| result.command.name == name)
            .cloned()
        else {
            self.abandon_picker(format!("`{name}` no longer matches"));
            return;
        };
        let position = chosen_target.and_then(|chosen| {
            live.targets
                .iter()
                .position(|target| live.command.action.prepare(target).ok() == chosen)
        });
        match position {
            Some(index) => {
                self.pending_action = Some(live);
                self.instance_index = index;
            }
            None => self.abandon_picker("the selected target is gone".to_string()),
        }
    }

    fn position_of_action(&self, name: &str) -> Option<usize> {
        self.action_matches
            .iter()
            .position(|result| result.command.name == name)
    }

    /// How many rows each tab would list, in [`GroupingMode::TABS`] order: the
    /// browse tabs count the rows of their projection of `filtered`, and the
    /// command tab counts the configured rules it lists.
    fn count_tabs(&self, filtered: &[Entry]) -> [usize; GroupingMode::TABS.len()] {
        GroupingMode::TABS.map(|mode| match mode.browse_mode() {
            Some(browse) => browse_row_count(filtered, browse),
            None => self.matcher.command_count(),
        })
    }

    /// Build the command-grouped rows: each configured command paired with the
    /// distinct logical services that match at least one of its instances.
    fn recompute_command_groups(&mut self, filtered: &[Entry]) {
        let previous = self
            .command_groups
            .get(self.selected)
            .map(|group| group.command.name.clone());
        // Read before the rows go: an index into the old service list means
        // nothing once it has been rebuilt.
        let chosen_service = self
            .command_groups
            .get(self.selected)
            .and_then(|group| group.services.get(self.service_picker_index))
            .map(|service| service.id().clone());

        let service_groups = browse_groups(filtered, BrowseMode::LogicalService);
        let mut command_groups: Vec<CommandGroup> = self
            .matcher
            .commands()
            .iter()
            .map(|command| CommandGroup {
                command: command.clone(),
                services: Vec::new(),
            })
            .collect();
        let index: HashMap<String, usize> = command_groups
            .iter()
            .enumerate()
            .map(|(i, group)| (group.command.name.clone(), i))
            .collect();
        for service_group in &service_groups {
            for result in self.matcher.matches_group(service_group) {
                if let Some(&i) = index.get(&result.command.name) {
                    command_groups[i].services.push(service_group.clone());
                }
            }
        }

        self.command_groups = command_groups;
        self.visible_groups = Vec::new();
        self.group_matches = Vec::new();
        match find_selection(&self.command_groups, previous.clone(), |group| {
            group.command.name.clone()
        }) {
            Some(index) => self.selected = index,
            None => self.clamp_selection(),
        }
        self.refocus_details(previous, |app| {
            app.command_groups
                .get(app.selected)
                .map(|group| group.command.name.clone())
        });
        self.reconcile_service_picker(chosen_service);
        self.reconcile_action_pickers();
    }

    /// Keep an open service picker on the service the user chose, after the
    /// command rows underneath it have been rebuilt.
    ///
    /// The cursor was an index into a list discovery can reorder or shorten, so
    /// without this a removed service hands its position — and the user's
    /// pending Enter — to whichever service slid into it.
    fn reconcile_service_picker(&mut self, chosen: Option<EntryGroupId>) {
        if self.mode != AppMode::ServicePicker {
            return;
        }
        let Some(services) = self.command_groups.get(self.selected).map(|g| &g.services) else {
            self.abandon_picker("the selected command is gone".to_string());
            return;
        };
        match chosen.and_then(|id| services.iter().position(|service| *service.id() == id)) {
            Some(index) => self.service_picker_index = index,
            None => self.abandon_picker("the selected service is gone".to_string()),
        }
    }

    /// Number of rows in the currently active left-hand list.
    fn active_count(&self) -> usize {
        if self.filter.grouping == GroupingMode::Command {
            self.command_groups.len()
        } else {
            self.visible_groups.len()
        }
    }

    fn clamp_selection(&mut self) {
        let count = self.active_count();
        if count == 0 {
            self.selected = 0;
        } else if self.selected >= count {
            self.selected = count - 1;
        }
    }

    /// Close any open modal/picker and drop its transient state. Clearing the
    /// already-empty action state on plain closes (search/help) is harmless.
    fn return_to_browse(&mut self) {
        self.mode = AppMode::Browse;
        self.picker_anchor = None;
        self.action_matches.clear();
        self.pending_action = None;
    }

    /// Close an open picker because what it was showing is gone, saying so.
    ///
    /// Reconciliation calls this rather than quietly moving the cursor onto a
    /// neighbour: the user chose a specific service, and silently retargeting
    /// their pending Enter to a different one is the failure this guards.
    fn abandon_picker(&mut self, reason: String) {
        self.return_to_browse();
        self.status = reason;
    }

    /// Resolve the key to at most one action for the active mode, then act on
    /// it. Resolution is the keymap's job, so no handler re-checks keys and no
    /// binding can be shadowed by the order the handlers are written in.
    ///
    /// `area` is the screen the modal windows are computed against, so that a
    /// key which moves a window is bounded by the same geometry that will draw
    /// it. It is passed in rather than written back by the renderer: the app
    /// state a key changes stays something only key handling changes.
    fn handle_key(&mut self, key: KeyEvent, area: Rect) -> Result<Option<PreparedCommand>> {
        let action = self.keybindings.resolve(self.mode.key_mode(), key);
        if action == Some(Action::Quit) {
            self.should_quit = true;
            return Ok(None);
        }

        match self.mode {
            AppMode::Browse => self.handle_browse_key(action, key),
            AppMode::Search => {
                self.handle_search_key(action, key);
                Ok(None)
            }
            AppMode::TypeFilter => {
                self.handle_type_filter_key(action);
                Ok(None)
            }
            AppMode::ActionPicker => self.handle_action_picker_key(action),
            AppMode::InstancePicker => self.handle_instance_picker_key(action),
            AppMode::ServicePicker => self.handle_service_picker_key(action),
            AppMode::Help => {
                self.handle_help_key(action, area);
                Ok(None)
            }
        }
    }

    /// Help is read, not selected from, so its navigation moves the window
    /// rather than a cursor. `area` is the screen the next frame will be drawn
    /// on: the same geometry decides how far down the content a scroll can go.
    fn handle_help_key(&mut self, action: Option<Action>, area: Rect) {
        match action {
            Some(Action::HelpClose) => self.return_to_browse(),
            Some(Action::HelpDown) => self.scroll_help(1, area),
            Some(Action::HelpUp) => self.scroll_help(-1, area),
            _ => {}
        }
    }

    /// Scroll help by `delta` lines, clamped to what is left to read. Clamping
    /// here is what keeps the overlay's own keys honest: at the bottom, further
    /// presses do nothing rather than banking scroll the reader would have to
    /// undo before the content moved again.
    fn scroll_help(&mut self, delta: isize, area: Rect) {
        let total = render::help_lines(self).len();
        let max = Window::max_scroll(total, render::help_viewport(area)) as isize;
        self.help_scroll = (self.help_scroll as isize + delta).clamp(0, max) as usize;
    }

    fn handle_browse_key(
        &mut self,
        action: Option<Action>,
        key: KeyEvent,
    ) -> Result<Option<PreparedCommand>> {
        match action {
            Some(Action::BrowseQuit) => self.should_quit = true,
            Some(Action::MoveDown) => self.move_selection(1),
            Some(Action::MoveUp) => self.move_selection(-1),
            Some(Action::Invoke) => return self.invoke_selected(),
            Some(Action::OpenSearch) => self.mode = AppMode::Search,
            Some(Action::OpenTypeFilter) => {
                self.type_filter_index = 0;
                self.mode = AppMode::TypeFilter;
            }
            Some(Action::TabNext) => self.cycle_tab(1),
            Some(Action::TabPrev) => self.cycle_tab(-1),
            Some(Action::SameHost) => self.toggle_same_host_filter(),
            Some(Action::Refresh) => self.refresh_services(),
            Some(Action::DetailsDown) => self.scroll_details(1),
            Some(Action::DetailsUp) => self.scroll_details(-1),
            // Help opens where it is read from: the top.
            Some(Action::OpenHelp) => {
                self.help_scroll = 0;
                self.mode = AppMode::Help;
            }
            // Typing a character with nothing bound to it starts a search with
            // it, so the query never loses the keystroke that opened it.
            None => {
                if let Some(ch) = typed_char(key) {
                    self.filter.text_query.push(ch);
                    self.mode = AppMode::Search;
                    self.recompute_visible();
                }
            }
            _ => {}
        }
        Ok(None)
    }

    /// Search editing is append-only: the bound actions come first, and any key
    /// that is not bound falls through to the editor itself.
    fn handle_search_key(&mut self, action: Option<Action>, key: KeyEvent) {
        match action {
            // Leaving search keeps the query: it is the active filter, and
            // `clear` is the only thing that removes it.
            Some(Action::SearchClose) => self.return_to_browse(),
            Some(Action::SearchClear) => {
                self.filter.clear_text();
                self.recompute_visible();
            }
            None => match key.code {
                KeyCode::Backspace | KeyCode::Delete => {
                    self.filter.text_query.pop();
                    self.recompute_visible();
                }
                _ => {
                    if let Some(ch) = typed_char(key) {
                        self.filter.text_query.push(ch);
                        self.recompute_visible();
                    }
                }
            },
            _ => {}
        }
    }

    fn handle_type_filter_key(&mut self, action: Option<Action>) {
        let count = self.filter.discovered_types().len();
        match action {
            Some(Action::TypeFilterClose) => self.return_to_browse(),
            Some(Action::TypeFilterDown) => {
                self.type_filter_index = move_index(self.type_filter_index, count, 1);
            }
            Some(Action::TypeFilterUp) => {
                self.type_filter_index = move_index(self.type_filter_index, count, -1);
            }
            Some(Action::TypeFilterToggle) => {
                if let Some(service_type) = self
                    .filter
                    .discovered_types()
                    .get(self.type_filter_index)
                    .cloned()
                {
                    self.filter.toggle_service_type(&service_type);
                    self.recompute_visible();
                }
            }
            _ => {}
        }
    }

    /// Switch the active top-panel tab to the grouping mode at `index` within
    /// [`GroupingMode::TABS`]. Switching views resets the cursor and detail
    /// scroll so the new list starts cleanly from the top.
    fn select_tab(&mut self, index: usize) {
        let Some(&mode) = GroupingMode::TABS.get(index) else {
            return;
        };
        if self.filter.grouping == mode {
            return;
        }
        self.filter.grouping = mode;
        self.selected = 0;
        self.details_scroll = 0;
        self.recompute_visible();
    }

    /// Move to the next/previous tab, wrapping around the ends.
    fn cycle_tab(&mut self, delta: isize) {
        let len = GroupingMode::TABS.len() as isize;
        let current = GroupingMode::TABS
            .iter()
            .position(|mode| *mode == self.filter.grouping)
            .unwrap_or(0) as isize;
        let next = (current + delta).rem_euclid(len) as usize;
        self.select_tab(next);
    }

    fn handle_action_picker_key(
        &mut self,
        action: Option<Action>,
    ) -> Result<Option<PreparedCommand>> {
        let len = self.action_matches.len();
        match action {
            Some(Action::PickerClose) => self.return_to_browse(),
            Some(Action::PickerDown) => {
                self.action_index = move_index(self.action_index, len, 1);
            }
            Some(Action::PickerUp) => {
                self.action_index = move_index(self.action_index, len, -1);
            }
            Some(Action::PickerSelect) => {
                if let Some(chosen) = self.action_matches.get(self.action_index).cloned() {
                    return self.choose_action(chosen);
                }
            }
            _ => {}
        }
        Ok(None)
    }

    fn handle_instance_picker_key(
        &mut self,
        action: Option<Action>,
    ) -> Result<Option<PreparedCommand>> {
        let count = self
            .pending_action
            .as_ref()
            .map(|action| action.targets.len())
            .unwrap_or(0);
        match action {
            Some(Action::PickerClose) => self.return_to_browse(),
            Some(Action::PickerDown) => {
                self.instance_index = move_index(self.instance_index, count, 1);
            }
            Some(Action::PickerUp) => {
                self.instance_index = move_index(self.instance_index, count, -1);
            }
            Some(Action::PickerSelect) => {
                let Some(pending) = self.pending_action.clone() else {
                    self.return_to_browse();
                    return Ok(None);
                };
                let Some(record) = pending.targets.get(self.instance_index) else {
                    self.return_to_browse();
                    return Ok(None);
                };
                return self.execute_action(&pending, record);
            }
            _ => {}
        }
        Ok(None)
    }

    fn move_selection(&mut self, delta: isize) {
        self.selected = move_index(self.selected, self.active_count(), delta);
        // A different row is now in focus — start its details from the top.
        self.details_scroll = 0;
    }

    /// Scroll the details pane by half its visible height (vim/tig `u`/`d`).
    /// `direction` is +1 to scroll down, -1 to scroll up.
    fn scroll_details(&mut self, direction: isize) {
        let step = (self.layout.details_viewport() / 2).max(1) as isize;
        self.scroll_details_by(direction * step);
    }

    /// Scroll the details pane by `delta` lines, clamped to the bounds of the
    /// pane the user is looking at — the same snapshot it was drawn from, so a
    /// key cannot scroll past the end of a frame that is on screen.
    fn scroll_details_by(&mut self, delta: isize) {
        let max = self.layout.details_max_scroll() as isize;
        self.details_scroll = (self.details_scroll as isize + delta).clamp(0, max) as usize;
    }

    fn handle_mouse(&mut self, mouse: MouseEvent) {
        // Modal pickers and help stay keyboard-driven; the mouse only drives
        // the browse layer (which remains visible while searching).
        if !matches!(self.mode, AppMode::Browse | AppMode::Search) {
            return;
        }
        let position = Position::new(mouse.column, mouse.row);
        match mouse.kind {
            MouseEventKind::ScrollDown => self.mouse_scroll(position, 1),
            MouseEventKind::ScrollUp => self.mouse_scroll(position, -1),
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(index) = self.layout.list_row_at(position, self.selected) {
                    self.selected = index;
                    // A different row is in focus — start its details from the top.
                    self.details_scroll = 0;
                }
            }
            _ => {}
        }
    }

    /// A wheel event over the details pane scrolls its content; over the list
    /// it moves the selection (the list window follows the selected row).
    fn mouse_scroll(&mut self, position: Position, direction: isize) {
        if self.layout.is_over_details(position) {
            self.scroll_details_by(direction);
        } else if self.layout.list().contains(position) {
            self.move_selection(direction);
        }
    }

    fn invoke_selected(&mut self) -> Result<Option<PreparedCommand>> {
        if self.filter.grouping == GroupingMode::Command {
            return self.invoke_command();
        }
        let Some(group) = self.visible_groups.get(self.selected) else {
            self.status = "no service selected".to_string();
            return Ok(None);
        };
        let matches = self
            .group_matches
            .get(self.selected)
            .cloned()
            .unwrap_or_default();
        match matches.len() {
            0 => {
                self.status = format!("no configured actions match `{}`", group.label());
                Ok(None)
            }
            _ => {
                // Anchor to the row itself, not to its position: whatever the
                // picker goes on to show is rebuilt from this.
                self.picker_anchor = Some(PickerAnchor::Row(group.id().clone()));
                if matches.len() == 1 {
                    return self.choose_action(matches.into_iter().next().unwrap());
                }
                self.action_matches = matches;
                self.action_index = 0;
                self.mode = AppMode::ActionPicker;
                Ok(None)
            }
        }
    }

    fn invoke_command(&mut self) -> Result<Option<PreparedCommand>> {
        let Some(group) = self.command_groups.get(self.selected) else {
            self.status = "no command selected".to_string();
            return Ok(None);
        };
        match group.services.len() {
            0 => {
                self.status = format!("no services match command `{}`", group.command.name);
                Ok(None)
            }
            1 => {
                let command = group.command.clone();
                let service = group.services[0].clone();
                self.run_command_on(&command, &service)
            }
            _ => {
                self.service_picker_index = 0;
                self.mode = AppMode::ServicePicker;
                Ok(None)
            }
        }
    }

    /// Run `command` against a chosen logical service, reusing the regular
    /// action flow (which handles instance disambiguation and execution).
    fn run_command_on(
        &mut self,
        command: &CommandConfig,
        service: &EntryGroup,
    ) -> Result<Option<PreparedCommand>> {
        let Some(result) = self
            .matcher
            .matches_group(service)
            .into_iter()
            .find(|result| result.command.name == command.name)
        else {
            self.status = format!("`{}` no longer matches `{}`", command.name, service.label());
            self.return_to_browse();
            return Ok(None);
        };
        // The command view lists rules, not browse rows, so an instance picker
        // opened from here anchors to the service the user picked.
        self.picker_anchor = Some(PickerAnchor::Service {
            command: command.name.clone(),
            service: service.id().clone(),
        });
        self.choose_action(result)
    }

    fn handle_service_picker_key(
        &mut self,
        action: Option<Action>,
    ) -> Result<Option<PreparedCommand>> {
        let count = self
            .command_groups
            .get(self.selected)
            .map(|group| group.services.len())
            .unwrap_or(0);
        match action {
            Some(Action::PickerClose) => self.return_to_browse(),
            Some(Action::PickerDown) => {
                self.service_picker_index = move_index(self.service_picker_index, count, 1);
            }
            Some(Action::PickerUp) => {
                self.service_picker_index = move_index(self.service_picker_index, count, -1);
            }
            Some(Action::PickerSelect) => {
                let Some(group) = self.command_groups.get(self.selected) else {
                    self.return_to_browse();
                    return Ok(None);
                };
                let Some(service) = group.services.get(self.service_picker_index).cloned() else {
                    self.return_to_browse();
                    return Ok(None);
                };
                let command = group.command.clone();
                return self.run_command_on(&command, &service);
            }
            _ => {}
        }
        Ok(None)
    }

    fn choose_action(&mut self, action: MatchResult) -> Result<Option<PreparedCommand>> {
        // The rule decides whether there is a choice to make: it knows what its
        // candidates would actually run. Anything left to pick between here
        // genuinely differs, and one target means the alternatives were the
        // same command, not that the difference was deemed unimportant.
        if action.needs_selection() {
            self.pending_action = Some(action);
            self.instance_index = 0;
            self.mode = AppMode::InstancePicker;
            return Ok(None);
        }

        let Some(record) = action.targets.first() else {
            self.status = "selected action has no matching services".to_string();
            self.return_to_browse();
            return Ok(None);
        };
        self.execute_action(&action, record)
    }

    fn execute_action(
        &mut self,
        action: &MatchResult,
        record: &Entry,
    ) -> Result<Option<PreparedCommand>> {
        let name = &action.command.name;

        // The rule owns what running it means — checking its requirements,
        // building the argv, and honouring its mode. The UI only decides what a
        // person sees afterwards.
        match action.command.run(record) {
            Ok(ActionOutcome::Forked) => {
                self.status = format!("launched `{name}`");
                self.return_to_browse();
                Ok(None)
            }
            // The execute hand-off happens after the TUI is torn down, so the
            // caller takes ownership of the prepared command from here.
            Ok(ActionOutcome::Handoff(prepared)) => Ok(Some(prepared)),
            Err(err) => self.fail(format!("cannot run `{name}`: {err}")),
        }
    }

    /// Perform the config reload when one was requested (SIGHUP).
    fn poll_reload(&mut self) {
        if self.reload_requested.swap(false, Ordering::Relaxed) {
            self.reload_config();
        }
    }

    /// Reload command configs through the injected loader, atomically or not at
    /// all. A reload failure must not tear down the TUI — it is reported on the
    /// status line like action failures, and the rules already in force stay in
    /// force.
    ///
    /// The loader has already decided whether the configuration on disk is
    /// complete, so there is exactly one moment here where anything changes:
    /// either the whole candidate rule set replaces the whole active one, or
    /// nothing is touched. A reload the user is midway through writing leaves
    /// the session exactly as it was, still able to run every command it could a
    /// moment ago.
    fn reload_config(&mut self) {
        let Some(loader) = &self.config_loader else {
            self.status = "config reload is not available".to_string();
            return;
        };
        match loader(&self.cli) {
            ReloadOutcome::Loaded(matcher) => {
                self.matcher = matcher;
                // The reload spoke for the whole configuration, and it is valid:
                // whatever the last one complained about is either fixed or gone.
                self.reload_diagnostics.clear();
                self.close_pickers();
                self.recompute_visible();
                self.status = format!("reloaded {} command(s)", self.matcher.command_count());
            }
            ReloadOutcome::Rejected(diagnostics) => {
                // Say what is still true — the old rules are running — rather
                // than only what failed. The detail is kept for the exit report;
                // the status line cannot hold it and will not survive the tick.
                self.status = format!(
                    "config reload rejected: {} invalid config file(s); \
                     keeping the {} command(s) already loaded; details printed on exit",
                    diagnostics.len(),
                    self.matcher.command_count()
                );
                self.reload_diagnostics = diagnostics;
            }
        }
    }

    /// Restart discovery from scratch: the list empties and repopulates as the
    /// fresh browse reports services, exactly like app startup. Filters and
    /// the active view are kept; they describe what the user wants to see,
    /// not what has been seen.
    fn refresh_services(&mut self) {
        let Some(factory) = &self.discovery_factory else {
            self.status = "refresh is not available".to_string();
            return;
        };
        // Stop the old producer (cancel + join) before starting the
        // replacement, so two browsers never feed the link at once.
        self.session.shutdown();
        let replacement = factory();

        // Only now that a replacement exists do the old session and its records
        // go. The old session's receiver is dropped with it, so its events can
        // never arrive on the new list — old and new cannot mix.
        self.session = replacement;
        self.records.clear();
        self.close_pickers();
        self.selected = 0;
        self.details_scroll = 0;
        self.recompute_visible();
        self.status = "refreshing: service discovery restarted".to_string();
    }

    /// Close any open picker: its entries were computed from the pre-reload
    /// rule set or the pre-refresh service list. Search and help stay open —
    /// they do not cache matcher or record data.
    fn close_pickers(&mut self) {
        if matches!(
            self.mode,
            AppMode::ActionPicker | AppMode::InstancePicker | AppMode::ServicePicker
        ) {
            self.return_to_browse();
        }
    }

    /// Surface a user-facing failure on the status line and return to browsing.
    /// Action failures are expected (bad config, missing tools) and must never
    /// propagate out of the event loop and tear down the terminal.
    fn fail(&mut self, message: String) -> Result<Option<PreparedCommand>> {
        self.status = message;
        self.return_to_browse();
        Ok(None)
    }

    /// Narrow the list to the selected row's host, or clear an active narrowing.
    ///
    /// Only a projection whose rows have one invariant hostname can offer this:
    /// the logical-service and host views. The service-type view's rows span
    /// several hosts and the command view lists rules rather than discovered
    /// entries, so both report the filter unavailable instead of silently
    /// filtering by some child's host.
    fn toggle_same_host_filter(&mut self) {
        if self.filter.host_filter.is_some() {
            self.filter.clear_host_filter();
            self.status = "host filter cleared".to_string();
            self.recompute_visible();
            return;
        }
        // A `&'static str`, so the messages below borrow nothing from `self`.
        let view = self.filter.grouping.label();
        let unavailable =
            |reason| format!("same-host filter is unavailable in the {view} view: {reason}");
        if self.filter.grouping.browse_mode().is_none() {
            self.status = unavailable("it lists commands, not discovered services");
            return;
        }
        let Some(group) = self.visible_groups.get(self.selected) else {
            self.status = "no row selected".to_string();
            return;
        };
        match group.facts().host() {
            RowHost::Resolved(host) => {
                let host = host.to_string();
                self.status = format!("filtering by host `{host}`");
                self.filter.set_host_filter(host);
                self.recompute_visible();
            }
            RowHost::Unresolved => {
                self.status = "selected row has no resolved host yet".to_string();
            }
            RowHost::Varies => {
                self.status = unavailable("its rows span several hosts");
            }
        }
    }
}

/// Enables terminal mouse reporting for its lifetime. Dropping it — including
/// during unwinding — restores the terminal's native mouse handling (text
/// selection, scrollback) before the rest of the terminal state is torn down.
struct MouseCaptureGuard;

impl MouseCaptureGuard {
    fn enable() -> Self {
        let _ = crossterm::execute!(std::io::stdout(), event::EnableMouseCapture);
        Self
    }
}

impl Drop for MouseCaptureGuard {
    fn drop(&mut self) {
        let _ = crossterm::execute!(std::io::stdout(), event::DisableMouseCapture);
    }
}

/// Move a list cursor by `delta`, clamped to `[0, len-1]` (or 0 when empty).
fn move_index(index: usize, len: usize, delta: isize) -> usize {
    if len == 0 {
        return 0;
    }
    (index as isize + delta).clamp(0, len as isize - 1) as usize
}

/// The character an unbound key event types into the search query, if any.
///
/// A modified key is a shortcut, not text: crossterm reports Ctrl-X as
/// `Char('x')` with CONTROL set, so an unbound control chord would otherwise
/// silently type its letter. SHIFT is not excluded — it is already folded into
/// the character itself.
fn typed_char(key: KeyEvent) -> Option<char> {
    let modified = key
        .modifiers
        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
    match key.code {
        KeyCode::Char(ch) if !modified && !ch.is_control() => Some(ch),
        _ => None,
    }
}

/// Index of the item whose key equals `previous` — used to keep the cursor on
/// the same logical row when a list is rebuilt.
fn find_selection<T, K: PartialEq>(
    items: &[T],
    previous: Option<K>,
    key: impl Fn(&T) -> K,
) -> Option<usize> {
    let previous = previous?;
    items.iter().position(|item| key(item) == previous)
}

#[cfg(test)]
mod tests {
    use std::{net::IpAddr, num::NonZeroU32, sync::mpsc};

    use super::*;
    use crate::ui::keymap::KeyBindings;
    use crate::{
        discovery::{OccurrenceId, Registration, RowServiceType, UNRESOLVED_HOST_LABEL},
        plumber::Matcher,
    };

    fn test_cli() -> Cli {
        Cli {
            domain: "local".to_string(),
            config_dirs: Vec::new(),
            service_type: None,
            backend: crate::discovery::DiscoveryBackend::default(),
            command: crate::ui::cli::CliCommand::Run,
        }
    }

    #[test]
    fn resolved_service_replaces_pending_record() {
        let (tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            Matcher::default(),
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        );

        let pending = Entry::new("workstation", "_ssh._tcp", "local");
        let mut resolved = Entry::new("workstation", "_ssh._tcp", "local");
        resolved.hostname = Some("workstation.local".to_string());
        resolved.addresses = vec!["192.168.1.20".parse().unwrap()];
        resolved.port = Some(22);

        tx.send(DiscoveryEvent::Upsert(pending)).unwrap();
        tx.send(DiscoveryEvent::Upsert(resolved)).unwrap();

        app.drain_discovery();

        assert_eq!(app.records.len(), 1);
        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[0].instances().len(), 1);
        assert_eq!(
            app.visible_groups[0].facts().host(),
            RowHost::Resolved("workstation.local")
        );
    }

    #[test]
    fn command_grouping_counts_distinct_matching_services() {
        use crate::plumber::MatcherBuilder;

        let mut builder = MatcherBuilder::new();
        builder
            .add_str(
                "ssh",
                r#"
[metadata]
name = "ssh"

[match.service_type]
equals = "_ssh._tcp"

[action]
command = "ssh {hostname}"
mode = "execute"
"#,
            )
            .unwrap();
        let matcher = builder.build();

        let (tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            matcher,
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        );

        let mut alpha = Entry::new("alpha", "_ssh._tcp", "local");
        alpha.hostname = Some("alpha.local".to_string());
        alpha.addresses = vec!["192.168.1.10".parse().unwrap()];
        alpha.port = Some(22);
        let mut beta = Entry::new("beta", "_ssh._tcp", "local");
        beta.hostname = Some("beta.local".to_string());
        beta.addresses = vec!["192.168.1.11".parse().unwrap()];
        beta.port = Some(22);
        let web = Entry::new("web", "_http._tcp", "local");

        tx.send(DiscoveryEvent::Upsert(alpha)).unwrap();
        tx.send(DiscoveryEvent::Upsert(beta)).unwrap();
        tx.send(DiscoveryEvent::Upsert(web)).unwrap();
        app.drain_discovery();

        app.filter.grouping = GroupingMode::Command;
        app.recompute_visible();

        assert_eq!(app.command_groups.len(), 1);
        assert_eq!(app.command_groups[0].command.name, "ssh");
        // alpha + beta are distinct logical services; the http service is excluded.
        assert_eq!(app.command_groups[0].services.len(), 2);
    }

    #[test]
    fn multiple_resolved_addresses_collapse_onto_one_service() {
        let (tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            Matcher::default(),
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        );

        let mut service = Entry::new("workstation", "_ssh._tcp", "local");
        service.hostname = Some("workstation.local".to_string());
        service.addresses = vec![
            "192.168.1.20".parse().unwrap(),
            "192.168.1.21".parse().unwrap(),
        ];
        service.port = Some(22);

        tx.send(DiscoveryEvent::Upsert(service)).unwrap();

        app.drain_discovery();

        // One logical service that carries both of its addresses.
        assert_eq!(app.records.len(), 1);
        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[0].instances().len(), 1);
        assert_eq!(app.visible_groups[0].instances()[0].addresses.len(), 2);
    }

    // ── interaction harness ────────────────────────────────────────────────
    use crate::plumber::MatcherBuilder;
    use crate::test_support::{remove, temp_file};
    use crossterm::event::KeyModifiers;

    const SSH: &str = r#"
[metadata]
name = "ssh"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "ssh {hostname}"
mode = "execute"
"#;

    const PING: &str = r#"
[metadata]
name = "ping"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "true"
mode = "fork"
"#;

    /// Matches every instance by address and echoes it, so picking different
    /// instances yields observably different argv.
    const PING_ADDR: &str = r#"
[metadata]
name = "ping-addr"
[match.address]
regex = "^10[.]"
[action]
command = "echo {address}"
mode = "execute"
"#;

    fn matcher_from(sources: &[&str]) -> Matcher {
        let mut builder = MatcherBuilder::new();
        builder.start_layer();
        for (index, source) in sources.iter().enumerate() {
            builder.add_str(&format!("test-{index}"), source).unwrap();
        }
        builder.build()
    }

    fn app_with(matcher: Matcher, records: Vec<Entry>) -> App {
        // Inert: these tests are about what the app does with records it
        // already has, not about the session producing or ending them.
        let mut app = App::new(
            test_cli(),
            matcher,
            KeyBindings::default(),
            DiscoverySession::inert(),
        );
        for record in records {
            app.records.insert(record.id(), record);
        }
        app.recompute_visible();
        app
    }

    fn ssh(name: &str, addr: &str) -> Entry {
        let mut record = Entry::new(name, "_ssh._tcp", "local");
        record.hostname = Some(format!("{name}.local"));
        record.addresses = vec![addr.parse().unwrap()];
        record.port = Some(22);
        record
    }

    fn mouse_event(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
        MouseEvent {
            kind,
            column,
            row,
            modifiers: crossterm::event::KeyModifiers::NONE,
        }
    }

    /// The screen every mouse and detail-scroll test below is resolved against.
    /// Deliberately short: its body leaves a bordered list panel with five
    /// content rows at y = 3..=7, so a list of eight has to scroll and a click
    /// has a window to be wrong about.
    const MOUSE_SCREEN: Rect = Rect {
        x: 0,
        y: 0,
        width: 100,
        height: 10,
    };

    /// An app with `count` distinct services, laid out for [`MOUSE_SCREEN`] the
    /// way the event loop lays one out before a frame: the list panel occupies
    /// x = 0..58 and the details pane x = 58..100, both at y = 2..=9.
    ///
    /// The layout is computed rather than asserted into place, so these tests
    /// hit-test against the same geometry a real frame would be drawn with.
    fn mouse_app(count: usize) -> App {
        let names = ["a", "b", "c", "d", "e", "f", "g", "h"];
        let records = names[..count]
            .iter()
            .map(|name| ssh(name, "10.0.0.1"))
            .collect();
        let mut app = app_with(Matcher::default(), records);
        app.update_layout(MOUSE_SCREEN);
        app
    }

    #[test]
    fn wheel_over_list_moves_the_selection() {
        let mut app = mouse_app(8);
        assert_eq!(app.selected, 0);

        app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 5, 4));
        assert_eq!(app.selected, 1);

        app.handle_mouse(mouse_event(MouseEventKind::ScrollUp, 5, 4));
        app.handle_mouse(mouse_event(MouseEventKind::ScrollUp, 5, 4));
        assert_eq!(app.selected, 0, "selection clamps at the top");

        // A wheel event outside both panes does nothing.
        app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 5, 0));
        assert_eq!(app.selected, 0);
    }

    #[test]
    fn wheel_over_details_scrolls_content_line_by_line() {
        let mut app = mouse_app(8);
        // The bound the wheel is clamped to is the one the layout worked out
        // from the pane and the selected row's details, not a number this test
        // asserted into place.
        let max = app.layout.details_max_scroll();
        assert!(max > 2, "the fixture's details must overflow its pane");

        app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 70, 4));
        app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 70, 4));
        assert_eq!(app.details_scroll, 2);

        // Clamped to the content bounds on both ends.
        for _ in 0..max + 5 {
            app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 70, 4));
        }
        assert_eq!(app.details_scroll, max);
        for _ in 0..max + 5 {
            app.handle_mouse(mouse_event(MouseEventKind::ScrollUp, 70, 4));
        }
        assert_eq!(app.details_scroll, 0);

        // Scrolling the details never moves the list selection.
        assert_eq!(app.selected, 0);
    }

    #[test]
    fn click_selects_the_row_under_the_cursor() {
        let mut app = mouse_app(8);

        // Clicking the fourth visible content row (y = 6) selects index 3.
        app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 6));
        assert_eq!(app.selected, 3);

        // With the selection at the end the window shows indices 3..=7, so
        // the first visible row (y = 3) is index 3.
        app.selected = 7;
        app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 3));
        assert_eq!(app.selected, 3);
    }

    #[test]
    fn clicks_on_borders_and_empty_rows_are_ignored() {
        let mut app = mouse_app(8);
        app.selected = 2;

        // Top border, bottom border, and the details pane.
        for (x, y) in [(5, 2), (5, 8), (70, 4)] {
            app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), x, y));
            assert_eq!(app.selected, 2, "click at ({x},{y}) must not select");
        }

        // A row below the last record is not selectable.
        let mut small = mouse_app(2);
        small.selected = 1;
        small.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 6));
        assert_eq!(small.selected, 1);
    }

    #[test]
    fn mouse_is_ignored_in_modal_modes() {
        let mut app = mouse_app(8);
        app.mode = AppMode::Help;

        app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 5, 4));
        app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 6));

        assert_eq!(app.selected, 0);
    }

    /// An SSH service reachable at several addresses (load-balanced).
    fn ssh_multi(name: &str, addrs: &[&str]) -> Entry {
        let mut record = ssh(name, addrs[0]);
        record.addresses = addrs.iter().map(|a| a.parse().unwrap()).collect();
        record
    }

    fn http(name: &str) -> Entry {
        let mut record = Entry::new(name, "_http._tcp", "local");
        record.hostname = Some(format!("{name}.local"));
        record.addresses = vec!["192.168.1.50".parse().unwrap()];
        record.port = Some(80);
        record
    }

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    /// A comfortable terminal, for the tests whose subject is not the geometry.
    const SCREEN: Rect = Rect {
        x: 0,
        y: 0,
        width: 120,
        height: 40,
    };

    fn send(app: &mut App, code: KeyCode) -> Option<PreparedCommand> {
        app.handle_key(key(code), SCREEN).unwrap()
    }

    #[test]
    fn navigation_moves_and_clamps_selection_and_resets_scroll() {
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
        );
        assert_eq!(app.visible_groups.len(), 2);
        app.details_scroll = 4;

        send(&mut app, KeyCode::Down);
        assert_eq!(app.selected, 1);
        assert_eq!(
            app.details_scroll, 0,
            "moving rows resets the detail scroll"
        );

        send(&mut app, KeyCode::Down);
        assert_eq!(app.selected, 1, "down clamps at the last row");

        send(&mut app, KeyCode::Up);
        send(&mut app, KeyCode::Up);
        assert_eq!(app.selected, 0, "up clamps at the first row");
    }

    #[test]
    fn typing_in_browse_enters_search_and_filters() {
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), ssh("zulu", "10.0.0.2")],
        );

        send(&mut app, KeyCode::Char('z'));

        assert_eq!(app.mode, AppMode::Search);
        assert_eq!(app.filter.text_query, "z");
        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[0].label(), "zulu");
    }

    #[test]
    fn search_backspace_clear_and_close() {
        let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
        send(&mut app, KeyCode::Char('z'));
        send(&mut app, KeyCode::Char('u'));
        assert_eq!(app.filter.text_query, "zu");

        send(&mut app, KeyCode::Backspace);
        assert_eq!(app.filter.text_query, "z");

        app.handle_key(
            KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
            SCREEN,
        )
        .unwrap();
        assert_eq!(app.filter.text_query, "", "ctrl-u clears the query");

        send(&mut app, KeyCode::Enter);
        assert_eq!(app.mode, AppMode::Browse, "enter closes search");
    }

    /// Search is append-only, so Delete has nothing after a cursor to remove;
    /// it removes the last character exactly like Backspace, which is what the
    /// keybindings documentation promises.
    #[test]
    fn delete_removes_the_last_search_character_like_backspace() {
        let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
        send(&mut app, KeyCode::Char('z'));
        send(&mut app, KeyCode::Char('u'));
        assert_eq!(app.filter.text_query, "zu");

        send(&mut app, KeyCode::Delete);
        assert_eq!(app.filter.text_query, "z");

        send(&mut app, KeyCode::Delete);
        assert_eq!(app.filter.text_query, "");

        // Deleting past the start is a no-op rather than an error.
        send(&mut app, KeyCode::Delete);
        assert_eq!(app.filter.text_query, "");
        assert_eq!(app.mode, AppMode::Search, "editing stays open");
    }

    /// Deleting re-filters the list; a stale row set would misreport the query.
    #[test]
    fn deleting_a_character_recomputes_the_visible_rows() {
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), ssh("zulu", "10.0.0.2")],
        );

        send(&mut app, KeyCode::Char('z'));
        assert_eq!(app.visible_groups.len(), 1);

        send(&mut app, KeyCode::Delete);
        assert_eq!(app.filter.text_query, "");
        assert_eq!(app.visible_groups.len(), 2, "both rows are visible again");
    }

    /// Escape and Enter leave editing but the query is the active filter: it
    /// survives, and only the clear action removes it.
    #[test]
    fn escape_and_enter_close_search_but_keep_the_query() {
        for close in [KeyCode::Esc, KeyCode::Enter] {
            let mut app = app_with(
                Matcher::default(),
                vec![ssh("alpha", "10.0.0.1"), ssh("zulu", "10.0.0.2")],
            );
            send(&mut app, KeyCode::Char('z'));
            assert_eq!(app.mode, AppMode::Search);

            send(&mut app, close);

            assert_eq!(app.mode, AppMode::Browse, "{close:?} leaves search");
            assert_eq!(
                app.filter.text_query, "z",
                "{close:?} must keep the active query"
            );
            assert_eq!(
                app.visible_groups.len(),
                1,
                "{close:?} keeps the list filtered"
            );
        }
    }

    /// The configured clear action is the only full clear, and it leaves the
    /// user in search so they can immediately type a new query.
    #[test]
    fn clear_empties_the_query_and_restores_every_row() {
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), ssh("zulu", "10.0.0.2")],
        );
        send(&mut app, KeyCode::Char('z'));
        assert_eq!(app.visible_groups.len(), 1);

        app.handle_key(
            KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
            SCREEN,
        )
        .unwrap();

        assert_eq!(app.filter.text_query, "");
        assert_eq!(app.mode, AppMode::Search, "clearing stays in search");
        assert_eq!(app.visible_groups.len(), 2);
    }

    /// A rebound clear must work and the default `ctrl-u` must stop working,
    /// otherwise the keymap is not really in charge of the search editor.
    #[test]
    fn a_rebound_clear_replaces_the_default_clear_key() {
        let path = temp_file(
            "search-clear",
            r#"
[search]
clear = ["ctrl-k"]
"#,
        );
        let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
        app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();
        send(&mut app, KeyCode::Char('z'));

        app.handle_key(
            KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
            SCREEN,
        )
        .unwrap();
        assert_eq!(app.filter.text_query, "z", "ctrl-u no longer clears");

        app.handle_key(
            KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
            SCREEN,
        )
        .unwrap();
        assert_eq!(app.filter.text_query, "");

        remove(&path);
    }

    // ── help scrolling ─────────────────────────────────────────────────────

    /// The 60×18 terminal the midpoint review reproduced the help clipping on.
    const SHORT_SCREEN: Rect = Rect {
        x: 0,
        y: 0,
        width: 60,
        height: 18,
    };

    fn help_app() -> App {
        let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
        app.mode = AppMode::Help;
        app
    }

    fn help_max_scroll(app: &App, area: Rect) -> usize {
        Window::max_scroll(render::help_lines(app).len(), render::help_viewport(area))
    }

    #[test]
    fn help_opens_at_the_top_and_scrolls_down_a_row_at_a_time() {
        let mut app = help_app();
        app.help_scroll = 7;
        app.mode = AppMode::Browse;

        send(&mut app, KeyCode::Char('?'));
        assert_eq!(app.mode, AppMode::Help);
        assert_eq!(app.help_scroll, 0, "help must open where it is read from");

        app.handle_key(key(KeyCode::Down), SHORT_SCREEN).unwrap();
        assert_eq!(app.help_scroll, 1);
        app.handle_key(key(KeyCode::Char('j')), SHORT_SCREEN)
            .unwrap();
        assert_eq!(app.help_scroll, 2);
        app.handle_key(key(KeyCode::Up), SHORT_SCREEN).unwrap();
        assert_eq!(app.help_scroll, 1);
    }

    /// Scrolling must stop at the ends. Banking scroll past the bottom would
    /// leave the reader pressing up several times before anything moved.
    #[test]
    fn help_scrolling_stops_at_both_ends_of_the_content() {
        let mut app = help_app();
        let max = help_max_scroll(&app, SHORT_SCREEN);
        assert!(
            max > 0,
            "help must be clipped at 60x18 for this to mean much"
        );

        for _ in 0..max + 10 {
            app.handle_key(key(KeyCode::Down), SHORT_SCREEN).unwrap();
        }
        assert_eq!(app.help_scroll, max);

        for _ in 0..max + 10 {
            app.handle_key(key(KeyCode::Up), SHORT_SCREEN).unwrap();
        }
        assert_eq!(app.help_scroll, 0);
    }

    /// On a terminal tall enough to show every row there is nothing to scroll,
    /// so the scroll keys must not move a window that is already complete.
    #[test]
    fn help_does_not_scroll_when_all_of_it_fits() {
        let mut app = help_app();
        assert_eq!(help_max_scroll(&app, SCREEN), 0);

        app.handle_key(key(KeyCode::Down), SCREEN).unwrap();

        assert_eq!(app.help_scroll, 0);
    }

    /// Task 013's resolver stays the single source of truth: rebinding the
    /// scroll keys must move dispatch with the binding, and the defaults they
    /// replaced must stop working.
    #[test]
    fn rebound_help_scroll_keys_replace_the_defaults() {
        let path = temp_file(
            "help-scroll",
            r#"
[help]
down = ["ctrl-n"]
up = ["ctrl-p"]
"#,
        );
        let mut app = help_app();
        app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();

        app.handle_key(
            KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
            SHORT_SCREEN,
        )
        .unwrap();
        assert_eq!(app.help_scroll, 1);

        app.handle_key(
            KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
            SHORT_SCREEN,
        )
        .unwrap();
        assert_eq!(app.help_scroll, 0);

        // The default it replaced no longer scrolls.
        app.handle_key(key(KeyCode::Down), SHORT_SCREEN).unwrap();
        assert_eq!(app.help_scroll, 0, "`down` is no longer bound to help.down");

        remove(&path);
    }

    /// Unbinding help scrolling leaves the overlay static rather than falling
    /// back to a hard-coded key beside the resolver.
    #[test]
    fn unbound_help_scroll_keys_do_nothing() {
        let path = temp_file(
            "help-scroll-off",
            r#"
[help]
down = []
up = []
"#,
        );
        let mut app = help_app();
        app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();

        app.handle_key(key(KeyCode::Down), SHORT_SCREEN).unwrap();
        app.handle_key(key(KeyCode::Char('j')), SHORT_SCREEN)
            .unwrap();

        assert_eq!(app.help_scroll, 0);

        remove(&path);
    }

    /// A window scrolled to the bottom of a short terminal must not strand the
    /// content when the terminal grows: the offset is clamped against whatever
    /// geometry the next key is handled on.
    #[test]
    fn growing_the_terminal_pulls_a_scrolled_help_window_back_onto_its_content() {
        let mut app = help_app();
        app.help_scroll = help_max_scroll(&app, SHORT_SCREEN);
        assert!(app.help_scroll > 0);

        // The same key, now on a screen that shows all of help.
        app.handle_key(key(KeyCode::Down), SCREEN).unwrap();

        assert_eq!(app.help_scroll, 0, "nothing left to scroll to");
    }

    /// An unbound control chord is a shortcut that did nothing, not text: it
    /// must not silently type its letter into the query.
    #[test]
    fn an_unbound_control_chord_does_not_type_into_the_search_query() {
        let mut app = app_with(Matcher::default(), vec![ssh("zulu", "10.0.0.2")]);
        send(&mut app, KeyCode::Char('z'));

        app.handle_key(
            KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
            SCREEN,
        )
        .unwrap();

        assert_eq!(app.filter.text_query, "z");
    }

    /// Shift is folded into the character, so capitals must still type.
    #[test]
    fn shifted_characters_type_into_the_search_query() {
        let mut app = app_with(Matcher::default(), vec![ssh("Zulu", "10.0.0.2")]);

        app.handle_key(
            KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT),
            SCREEN,
        )
        .unwrap();

        assert_eq!(app.mode, AppMode::Search);
        assert_eq!(app.filter.text_query, "Z");
    }

    /// Rebinding one action must not disturb the others in its mode: the whole
    /// point of resolving a key to a single action.
    #[test]
    fn rebound_browse_keys_dispatch_and_defaults_stop_working() {
        let path = temp_file(
            "browse-rebind",
            r#"
[browse]
down = ["n"]
up = ["p"]
help = ["f1"]
"#,
        );
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
        );
        app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();

        send(&mut app, KeyCode::Char('n'));
        assert_eq!(app.selected, 1, "the rebound down key moves the cursor");

        send(&mut app, KeyCode::Char('p'));
        assert_eq!(app.selected, 0);

        // `j` is no longer navigation, so it falls through to typing a search.
        send(&mut app, KeyCode::Char('j'));
        assert_eq!(app.mode, AppMode::Search);
        assert_eq!(app.filter.text_query, "j");
        send(&mut app, KeyCode::Esc);

        send(&mut app, KeyCode::F(1));
        assert_eq!(app.mode, AppMode::Help, "the rebound help key opens help");

        remove(&path);
    }

    /// Unbinding an action leaves its key inert rather than falling back to a
    /// default that the configuration deliberately removed.
    #[test]
    fn an_unbound_browse_action_does_nothing() {
        let path = temp_file(
            "unbind-same-host",
            r#"
[browse]
same_host = []
"#,
        );
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        app.keybindings = KeyBindings::load(std::slice::from_ref(&path)).unwrap();

        send(&mut app, KeyCode::Char('s'));

        // `s` is unbound in browse, so it types instead of filtering by host.
        assert_eq!(app.filter.host_filter, None);
        assert_eq!(app.mode, AppMode::Search);

        remove(&path);
    }

    #[test]
    fn type_filter_toggle_hides_a_service_type() {
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), http("web")],
        );
        assert_eq!(app.visible_groups.len(), 2);

        send(&mut app, KeyCode::Char('t'));
        assert_eq!(app.mode, AppMode::TypeFilter);
        // Discovered types are sorted: _http._tcp is first.
        send(&mut app, KeyCode::Char(' '));

        assert!(
            app.visible_groups
                .iter()
                .all(|g| g.facts().service_type() == RowServiceType::Invariant("_ssh._tcp"))
        );
        assert_eq!(app.visible_groups.len(), 1);
    }

    #[test]
    fn tab_keys_cycle_active_view_and_wrap() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        assert_eq!(app.filter.grouping, GroupingMode::LogicalService);

        // TABS = [LogicalService, Host, ServiceType, Command]; two forward steps
        // land on the service-type view.
        send(&mut app, KeyCode::Tab);
        assert_eq!(app.filter.grouping, GroupingMode::Host);
        send(&mut app, KeyCode::Tab);
        assert_eq!(app.filter.grouping, GroupingMode::ServiceType);
        assert_eq!(app.mode, AppMode::Browse);

        // Stepping back past the first tab wraps to the last (command) tab.
        send(&mut app, KeyCode::BackTab);
        send(&mut app, KeyCode::BackTab);
        send(&mut app, KeyCode::BackTab);
        assert_eq!(app.filter.grouping, GroupingMode::Command);
    }

    #[test]
    fn switching_tabs_resets_selection_and_scroll() {
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
        );
        send(&mut app, KeyCode::Down);
        app.details_scroll = 3;
        assert_eq!(app.selected, 1);

        send(&mut app, KeyCode::Tab);
        assert_eq!(app.selected, 0, "switching views resets the cursor");
        assert_eq!(
            app.details_scroll, 0,
            "switching views resets detail scroll"
        );
    }

    #[test]
    fn invoke_single_matching_action_returns_prepared_execute_command() {
        let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")]);

        let command = send(&mut app, KeyCode::Enter).expect("execute action returns a command");

        assert_eq!(command.argv, vec!["ssh", "alpha.local"]);
    }

    #[test]
    fn invoke_with_multiple_actions_opens_picker_then_runs_selection() {
        let mut app = app_with(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")]);

        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ActionPicker);
        assert_eq!(app.action_matches.len(), 2);

        // action_index 0 is `ssh` (insertion order); selecting it runs that action.
        let command = send(&mut app, KeyCode::Enter).expect("picked action runs");
        assert_eq!(command.argv, vec!["ssh", "alpha.local"]);
    }

    #[test]
    fn invoke_without_a_matching_command_reports_status() {
        let mut app = app_with(matcher_from(&[SSH]), vec![http("web")]);

        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert!(app.status.contains("no configured actions match"));
    }

    #[test]
    fn fork_action_launches_and_returns_to_browse() {
        let mut app = app_with(matcher_from(&[PING]), vec![ssh("alpha", "10.0.0.1")]);

        assert!(
            send(&mut app, KeyCode::Enter).is_none(),
            "fork does not exec"
        );
        assert_eq!(app.mode, AppMode::Browse);
        assert!(app.status.contains("launched `ping`"));
    }

    /// A rule whose mandatory dependency is missing fails for the whole rule,
    /// not for one candidate. It must say so rather than quietly try the next
    /// service, which would run a command against something the user did not
    /// choose.
    #[test]
    fn a_missing_requirement_reports_failure_without_trying_another_target() {
        const NEEDS_ABSENT: &str = r#"
[metadata]
name = "needs-absent"
requirements = ["definitely-absent-xyz"]
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "ssh {hostname}"
mode = "execute"
"#;
        let mut app = app_with(
            matcher_from(&[NEEDS_ABSENT]),
            vec![
                service_on("alpha", "_ssh._tcp", "alpha.local", 22),
                service_on("beta", "_ssh._tcp", "beta.local", 22),
            ],
        );
        app.filter.grouping = GroupingMode::ServiceType;
        app.recompute_visible();

        // The row's two hosts differ, so the action is offered for selection.
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::InstancePicker);

        // Choosing one reports the rule's failure; nothing else runs.
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert!(
            app.status.contains("definitely-absent-xyz"),
            "status should name the missing dependency, was: {}",
            app.status
        );
    }

    #[test]
    fn instance_picker_disambiguates_then_executes_chosen_address() {
        // One logical service reachable at two addresses; an address-specific
        // command expands them into per-address candidates to pick between.
        let mut app = app_with(
            matcher_from(&[PING_ADDR]),
            vec![ssh_multi("alpha", &["10.0.0.1", "10.0.0.2"])],
        );
        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[0].instances().len(), 1);
        assert_eq!(app.visible_groups[0].instances()[0].addresses.len(), 2);

        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::InstancePicker);

        // Candidates follow address order: index 1 is 10.0.0.2.
        send(&mut app, KeyCode::Down);
        let command = send(&mut app, KeyCode::Enter).expect("instance chosen");
        assert_eq!(command.argv, vec!["echo", "10.0.0.2"]);
    }

    // ── mode-aware aggregate views ─────────────────────────────────────────

    /// A service on `host` with its own type and port.
    fn service_on(name: &str, service_type: &str, host: &str, port: u16) -> Entry {
        let mut record = Entry::new(name, service_type, "local");
        record.hostname = Some(host.to_string());
        record.addresses = vec!["10.0.0.1".parse().unwrap()];
        record.port = Some(port);
        record
    }

    /// The tab count for `mode`, by its position in the tab bar.
    fn tab_count(app: &App, mode: GroupingMode) -> usize {
        let index = GroupingMode::TABS
            .iter()
            .position(|tab| *tab == mode)
            .expect("a tab for the mode");
        app.tab_counts[index]
    }

    #[test]
    fn tab_counts_follow_their_exact_definitions() {
        let app = app_with(
            matcher_from(&[SSH, PING]),
            vec![
                // Two services on one host, one on another, one unresolved.
                service_on("shell", "_ssh._tcp", "nas.local", 22),
                service_on("site", "_http._tcp", "nas.local", 80),
                service_on("shell", "_ssh._tcp", "pi.local", 22),
                Entry::new("ghost", "_ipp._tcp", "local"),
            ],
        );

        // Logical-service rows, not the occurrences behind them.
        assert_eq!(tab_count(&app, GroupingMode::LogicalService), 4);
        // Resolved host rows plus the single unresolved row.
        assert_eq!(tab_count(&app, GroupingMode::Host), 3);
        // Distinct service types.
        assert_eq!(tab_count(&app, GroupingMode::ServiceType), 3);
        // Configured rules, whatever was discovered.
        assert_eq!(tab_count(&app, GroupingMode::Command), 2);
    }

    #[test]
    fn every_tab_count_matches_the_rows_that_tab_lists() {
        let mut app = app_with(
            matcher_from(&[SSH]),
            vec![
                service_on("shell", "_ssh._tcp", "nas.local", 22),
                service_on("site", "_http._tcp", "nas.local", 80),
                service_on("shell", "_ssh._tcp", "pi.local", 22),
                Entry::new("ghost", "_ipp._tcp", "local"),
            ],
        );

        for mode in GroupingMode::TABS {
            app.filter.grouping = mode;
            app.recompute_visible();
            let rows = if mode == GroupingMode::Command {
                app.command_groups.len()
            } else {
                app.visible_groups.len()
            };
            assert_eq!(tab_count(&app, mode), rows, "{mode:?} count vs its rows");
        }
    }

    #[test]
    fn a_host_row_offers_its_services_without_borrowing_one_childs_metadata() {
        // One host offering SSH and HTTP on different ports.
        let mut app = app_with(
            matcher_from(&[SSH]),
            vec![
                service_on("shell", "_ssh._tcp", "nas.local", 22),
                service_on("site", "_http._tcp", "nas.local", 80),
            ],
        );
        app.filter.grouping = GroupingMode::Host;
        app.recompute_visible();

        assert_eq!(app.visible_groups.len(), 1);
        let host = &app.visible_groups[0];
        assert_eq!(host.label(), "nas.local");
        // The row states the host; the differing types stay on the children.
        assert_eq!(host.facts().host(), RowHost::Resolved("nas.local"));
        assert_eq!(host.facts().service_type(), RowServiceType::Varies);
        assert_eq!(host.logical_service_count(), 2);

        // Invoking the aggregate runs the command against the concrete child
        // that matches it, not against the row.
        let command = send(&mut app, KeyCode::Enter).expect("the ssh child runs");
        assert_eq!(command.argv, vec!["ssh", "nas.local"]);
    }

    #[test]
    fn a_service_type_row_targets_the_concrete_child_the_user_picks() {
        // One type offered by two hosts with different addresses and ports.
        let mut alpha = service_on("alpha", "_ssh._tcp", "alpha.local", 22);
        alpha.addresses = vec!["10.0.0.1".parse().unwrap()];
        let mut beta = service_on("beta", "_ssh._tcp", "beta.local", 2222);
        beta.addresses = vec!["10.0.0.2".parse().unwrap()];

        // `ssh {hostname}` names no address or port, so nothing about the rule
        // looks instance-specific — which is exactly why an aggregate row used
        // to run its first child without asking.
        let mut app = app_with(matcher_from(&[SSH]), vec![alpha, beta]);
        app.filter.grouping = GroupingMode::ServiceType;
        app.recompute_visible();

        assert_eq!(app.visible_groups.len(), 1);
        let by_type = &app.visible_groups[0];
        assert_eq!(by_type.label(), "_ssh._tcp");
        // No host is type-wide, so the row names none.
        assert_eq!(by_type.facts().host(), RowHost::Varies);
        assert_eq!(by_type.resolved_host_count(), 2);

        // The two children would ssh to two different hosts, so the aggregate
        // offers them up rather than answering for them.
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::InstancePicker);
        send(&mut app, KeyCode::Down);
        let command = send(&mut app, KeyCode::Enter).expect("the chosen child runs");
        assert_eq!(command.argv, vec!["ssh", "beta.local"]);
    }

    #[test]
    fn a_command_row_runs_against_a_concrete_service() {
        let mut app = app_with(
            matcher_from(&[SSH]),
            vec![
                service_on("alpha", "_ssh._tcp", "alpha.local", 22),
                service_on("beta", "_ssh._tcp", "beta.local", 22),
            ],
        );
        app.filter.grouping = GroupingMode::Command;
        app.recompute_visible();

        assert_eq!(app.command_groups[0].services.len(), 2);
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ServicePicker);
        let command = send(&mut app, KeyCode::Enter).expect("the picked service runs");
        assert_eq!(command.argv, vec!["ssh", "alpha.local"]);
    }

    #[test]
    fn same_host_filter_is_offered_only_by_invariant_host_projections() {
        let mut app = app_with(
            matcher_from(&[SSH]),
            vec![
                service_on("shell", "_ssh._tcp", "nas.local", 22),
                service_on("site", "_http._tcp", "pi.local", 80),
            ],
        );

        // A host row has one hostname by construction: the filter applies.
        app.filter.grouping = GroupingMode::Host;
        app.recompute_visible();
        send(&mut app, KeyCode::Char('s'));
        assert_eq!(app.filter.host_filter.as_deref(), Some("nas.local"));
        send(&mut app, KeyCode::Char('s'));
        assert!(app.filter.host_filter.is_none());

        // A service-type row spans hosts: the filter must say so rather than
        // silently filter by whichever child happens to sort first.
        app.filter.grouping = GroupingMode::ServiceType;
        app.recompute_visible();
        send(&mut app, KeyCode::Char('s'));
        assert!(app.filter.host_filter.is_none(), "no host was filtered by");
        assert!(app.status.contains("unavailable"), "status: {}", app.status);
        assert!(app.status.contains("span several hosts"));

        // The command view lists rules, not discovered services.
        app.filter.grouping = GroupingMode::Command;
        app.recompute_visible();
        send(&mut app, KeyCode::Char('s'));
        assert!(app.filter.host_filter.is_none());
        assert!(app.status.contains("unavailable"));
    }

    #[test]
    fn an_active_host_filter_can_be_cleared_from_any_view() {
        let mut app = app_with(
            matcher_from(&[SSH]),
            vec![service_on("shell", "_ssh._tcp", "nas.local", 22)],
        );
        send(&mut app, KeyCode::Char('s'));
        assert_eq!(app.filter.host_filter.as_deref(), Some("nas.local"));

        // Clearing describes the filter, not the row under the cursor, so the
        // views that cannot set one can still lift it.
        app.filter.grouping = GroupingMode::Command;
        app.recompute_visible();
        send(&mut app, KeyCode::Char('s'));

        assert!(app.filter.host_filter.is_none());
        assert!(app.status.contains("host filter cleared"));
    }

    #[test]
    fn an_unresolved_host_row_never_collides_with_the_sentinel_hostname() {
        let mut impostor = Entry::new("impostor", "_ssh._tcp", "local");
        impostor.hostname = Some(UNRESOLVED_HOST_LABEL.to_string());
        impostor.port = Some(22);

        let mut app = app_with(
            matcher_from(&[SSH]),
            vec![impostor, Entry::new("ghost", "_ipp._tcp", "local")],
        );
        app.filter.grouping = GroupingMode::Host;
        app.recompute_visible();

        // Two rows reading alike: the impostor's resolved host, and the row of
        // registrations that have resolved none.
        assert_eq!(app.visible_groups.len(), 2);
        assert!(
            app.visible_groups
                .iter()
                .all(|group| group.label() == UNRESOLVED_HOST_LABEL)
        );

        // The impostor is a real host, so it can be filtered by.
        send(&mut app, KeyCode::Char('s'));
        assert_eq!(
            app.filter.host_filter.as_deref(),
            Some(UNRESOLVED_HOST_LABEL)
        );
        assert_eq!(app.visible_groups.len(), 1);
        send(&mut app, KeyCode::Char('s'));

        // The unresolved row is not a host and has nothing to filter by.
        app.selected = 1;
        send(&mut app, KeyCode::Char('s'));
        assert!(app.filter.host_filter.is_none());
        assert!(
            app.status.contains("no resolved host"),
            "status: {}",
            app.status
        );
    }

    #[test]
    fn selection_survives_recomputation_by_structured_row_identity() {
        let mut app = app_with(
            matcher_from(&[SSH]),
            vec![
                service_on("shell", "_ssh._tcp", "nas.local", 22),
                service_on("shell", "_ssh._tcp", "pi.local", 22),
            ],
        );
        // Two rows labelled `shell`; the cursor sits on the second.
        send(&mut app, KeyCode::Down);
        let chosen = app.visible_groups[app.selected].id().clone();

        // A new row sorts in ahead of the selection.
        let earlier = service_on("alpha", "_ssh._tcp", "alpha.local", 22);
        app.records.insert(earlier.id(), earlier);
        app.recompute_visible();

        assert_eq!(app.selected, 2, "the cursor followed its row");
        assert_eq!(*app.visible_groups[app.selected].id(), chosen);
    }

    #[test]
    fn same_host_filter_toggles_on_and_off() {
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
        );
        // Groups sort by label, so the cursor starts on `alpha`.
        send(&mut app, KeyCode::Char('s'));
        assert_eq!(app.filter.host_filter.as_deref(), Some("alpha.local"));
        assert_eq!(app.visible_groups.len(), 1);

        send(&mut app, KeyCode::Char('s'));
        assert!(app.filter.host_filter.is_none());
        assert_eq!(app.visible_groups.len(), 2);
    }

    /// Two occurrences of one registration — the same service announced on two
    /// interfaces — identical but for the occurrence name and address.
    fn on_interface(name: &str, addr: &str, index: u32) -> Entry {
        let mut record = ssh(name, addr);
        record.addresses = vec![addr.parse().unwrap()];
        record.with_occurrence(Some(OccurrenceId(NonZeroU32::new(index).unwrap())))
    }

    #[test]
    fn occurrences_of_one_registration_coexist_with_their_own_addresses() {
        let mut app = app_with(
            Matcher::default(),
            vec![
                on_interface("alpha", "10.0.0.1", 1),
                on_interface("alpha", "10.0.0.2", 2),
            ],
        );
        app.recompute_visible();

        // Same name/type/domain/host/port: only the interface and address
        // differ, so neither occurrence may overwrite the other.
        assert_eq!(app.records.len(), 2);
        let addresses: Vec<_> = app
            .records
            .values()
            .flat_map(|record| record.addresses.clone())
            .collect();
        assert!(addresses.contains(&"10.0.0.1".parse().unwrap()));
        assert!(addresses.contains(&"10.0.0.2".parse().unwrap()));

        // They still read as one logical service.
        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[0].instances().len(), 2);
    }

    #[test]
    fn removing_one_occurrence_preserves_its_sibling() {
        let (tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            Matcher::default(),
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        );

        tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.1", 1)))
            .unwrap();
        tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.2", 2)))
            .unwrap();
        app.drain_discovery();
        assert_eq!(app.records.len(), 2);

        tx.send(DiscoveryEvent::Remove(
            on_interface("alpha", "10.0.0.1", 1).id(),
        ))
        .unwrap();
        app.drain_discovery();

        // The logical service survives with the live occurrence's address.
        assert_eq!(app.records.len(), 1);
        assert_eq!(app.visible_groups.len(), 1);
        let survivor = app.records.values().next().expect("surviving occurrence");
        assert_eq!(
            survivor.addresses,
            vec!["10.0.0.2".parse::<IpAddr>().unwrap()]
        );
    }

    #[test]
    fn registration_removal_clears_every_occurrence() {
        let (tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            Matcher::default(),
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        );

        tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.1", 1)))
            .unwrap();
        tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.2", 2)))
            .unwrap();
        // An occurrence with no adapter name at all, as a zeroconf upsert has.
        tx.send(DiscoveryEvent::Upsert(ssh("alpha", "10.0.0.3")))
            .unwrap();
        tx.send(DiscoveryEvent::Upsert(ssh("beta", "10.0.0.4")))
            .unwrap();
        app.drain_discovery();
        // Three coexisting occurrences of `alpha`, plus `beta`.
        assert_eq!(app.records.len(), 4);

        // The fallback an adapter uses when it cannot name what it lost.
        tx.send(DiscoveryEvent::RemoveRegistration(Registration::new(
            "alpha",
            "_ssh._tcp",
            "local",
        )))
        .unwrap();
        app.drain_discovery();

        assert_eq!(app.records.len(), 1);
        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[0].label(), "beta");
    }

    #[test]
    fn removing_an_unknown_occurrence_leaves_the_registration_alone() {
        let (tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            Matcher::default(),
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        );

        tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.1", 1)))
            .unwrap();
        app.drain_discovery();

        // A removal naming an occurrence that was never listed must not be
        // widened into "remove the registration".
        tx.send(DiscoveryEvent::Remove(
            on_interface("alpha", "10.0.0.9", 7).id(),
        ))
        .unwrap();
        app.drain_discovery();

        assert_eq!(app.records.len(), 1);
    }

    #[test]
    fn upserting_an_occurrence_replaces_it_across_endpoint_and_txt_changes() {
        let (tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            Matcher::default(),
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        );

        tx.send(DiscoveryEvent::Upsert(on_interface("alpha", "10.0.0.1", 1)))
            .unwrap();

        // The same occurrence re-resolved: new address, port, and TXT data.
        let mut moved = on_interface("alpha", "10.0.0.9", 1);
        moved.port = Some(2222);
        moved.txt.insert("path".to_string(), "/admin".to_string());
        tx.send(DiscoveryEvent::Upsert(moved)).unwrap();
        app.drain_discovery();

        // An adapter-named occurrence keeps its identity when its endpoint
        // moves, so this replaced the record instead of forking a duplicate.
        assert_eq!(app.records.len(), 1);
        let record = app.records.values().next().expect("record");
        assert_eq!(
            record.addresses,
            vec!["10.0.0.9".parse::<IpAddr>().unwrap()]
        );
        assert_eq!(record.port, Some(2222));
        assert_eq!(record.txt.get("path").map(String::as_str), Some("/admin"));
    }

    #[test]
    fn command_view_runs_single_service_and_picks_among_many() {
        let mut single = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")]);
        single.filter.grouping = GroupingMode::Command;
        single.recompute_visible();
        assert_eq!(single.command_groups.len(), 1);
        assert_eq!(single.command_groups[0].services.len(), 1);

        let command = send(&mut single, KeyCode::Enter).expect("single service runs");
        assert_eq!(command.argv, vec!["ssh", "alpha.local"]);

        let mut many = app_with(
            matcher_from(&[SSH]),
            vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
        );
        many.filter.grouping = GroupingMode::Command;
        many.recompute_visible();
        assert_eq!(many.command_groups[0].services.len(), 2);

        assert!(send(&mut many, KeyCode::Enter).is_none());
        assert_eq!(many.mode, AppMode::ServicePicker);
        // Services sort by label; index 1 is `beta`.
        send(&mut many, KeyCode::Down);
        let command = send(&mut many, KeyCode::Enter).expect("picked service runs");
        assert_eq!(command.argv, vec!["ssh", "beta.local"]);
    }

    // ── refresh & config reload ─────────────────────────────────────────────
    use std::sync::Mutex;
    use std::sync::atomic::AtomicUsize;

    /// A factory whose spawned sessions' senders are captured, so a test can
    /// feed events through whichever session a refresh started — and prove that
    /// events sent to a superseded one never arrive.
    fn channel_factory() -> (
        DiscoveryFactory,
        Arc<Mutex<Vec<mpsc::Sender<DiscoveryEvent>>>>,
    ) {
        let spawned: Arc<Mutex<Vec<mpsc::Sender<DiscoveryEvent>>>> =
            Arc::new(Mutex::new(Vec::new()));
        let factory = {
            let spawned = spawned.clone();
            Box::new(move || {
                let (tx, rx) = mpsc::channel();
                spawned.lock().unwrap().push(tx);
                DiscoverySession::detached(rx)
            })
        };
        (factory, spawned)
    }

    #[test]
    fn refresh_restarts_discovery_and_repopulates_like_startup() {
        let (factory, spawned) = channel_factory();
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
        )
        .with_discovery_factory(factory);
        assert_eq!(app.visible_groups.len(), 2);

        send(&mut app, KeyCode::Char('r'));

        // The list is empty and a new session runs.
        assert!(app.records.is_empty());
        assert!(app.visible_groups.is_empty());
        assert!(app.status.contains("refresh"));
        assert_eq!(spawned.lock().unwrap().len(), 1);

        // Events from the replacement session repopulate the list.
        spawned.lock().unwrap()[0]
            .send(DiscoveryEvent::Upsert(ssh("gamma", "10.0.0.3")))
            .unwrap();
        app.drain_discovery();
        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[0].label(), "gamma");
    }

    #[test]
    fn refresh_resets_cursor_and_scroll_but_keeps_filters() {
        let (factory, _spawned) = channel_factory();
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), ssh("beta", "10.0.0.2")],
        )
        .with_discovery_factory(factory);
        send(&mut app, KeyCode::Down);
        app.details_scroll = 3;
        app.filter.text_query = "bet".to_string();

        app.refresh_services();

        assert_eq!(app.selected, 0);
        assert_eq!(app.details_scroll, 0);
        assert_eq!(
            app.filter.text_query, "bet",
            "refresh restarts discovery; it does not discard what the user asked to see"
        );
    }

    #[test]
    fn refresh_without_discovery_control_reports_status_and_keeps_records() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);

        send(&mut app, KeyCode::Char('r'));

        assert!(app.status.contains("refresh is not available"));
        assert_eq!(app.records.len(), 1, "records must not be lost");
    }

    // ── discovery session lifecycle ─────────────────────────────────────────

    /// An app whose session is driven by the returned sender. Dropping the
    /// sender is a producer going away, exactly as a real adapter's does.
    fn app_with_session(
        matcher: Matcher,
        records: Vec<Entry>,
    ) -> (App, mpsc::Sender<DiscoveryEvent>) {
        let (tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            matcher,
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        );
        for record in records {
            app.records.insert(record.id(), record);
        }
        app.recompute_visible();
        (app, tx)
    }

    /// The core of this task: a producer going away must not read as "no new
    /// events". mDNS is edge-triggered, so a dead browse can never retract a
    /// service that has since gone; keeping the list would invite the user to
    /// launch a command at a host that may not be there.
    #[test]
    fn a_real_disconnect_clears_records_and_reports_a_persistent_failure() {
        let (mut app, tx) = app_with_session(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        assert_eq!(app.visible_groups.len(), 1);

        drop(tx);
        app.drain_discovery();

        assert!(app.records.is_empty(), "unverifiable records must not stay");
        assert!(app.visible_groups.is_empty());
        assert!(matches!(app.session.state(), SessionState::Failed(_)));
        assert!(
            !app.session.state().is_listening(),
            "the app must stop implying it is listening"
        );
        assert!(app.status.contains("discovery stopped"));
    }

    /// The same verdict with nothing discovered yet: the empty list must be
    /// explained as a failure rather than left looking like a quiet network.
    #[test]
    fn a_real_disconnect_with_no_records_still_reports_a_failure() {
        let (mut app, tx) = app_with_session(Matcher::default(), Vec::new());

        drop(tx);
        app.drain_discovery();

        assert!(matches!(app.session.state(), SessionState::Failed(_)));
        assert!(app.status.contains("discovery stopped"));
    }

    /// A startup error's cause must survive: it is carried by the failure, not
    /// left on a status line for the next event to erase.
    #[test]
    fn a_startup_failure_keeps_its_cause_text_across_later_drains() {
        let (mut app, tx) = app_with_session(Matcher::default(), Vec::new());
        tx.send(DiscoveryEvent::Status(
            "mDNS discovery unavailable (no such device); try --backend fake in a build with the fake feature for sample records, or refresh to retry"
                .to_string(),
        ))
        .unwrap();
        drop(tx);

        app.drain_discovery();
        let reported = app.status.clone();

        // Ticking on does not decay the verdict back into silence.
        for _ in 0..5 {
            app.drain_discovery();
        }
        assert_eq!(app.status, reported, "the failure must be persistent");
        assert!(app.status.contains("discovery stopped"));
        assert!(matches!(app.session.state(), SessionState::Failed(_)));
    }

    /// A picker's entries were computed from records the failure just
    /// invalidated; leaving it open would let the user act on them.
    #[test]
    fn a_real_disconnect_closes_a_derived_picker() {
        let (mut app, tx) =
            app_with_session(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")]);
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ActionPicker);

        drop(tx);
        app.drain_discovery();

        assert_eq!(app.mode, AppMode::Browse);
        assert!(app.records.is_empty());
    }

    // ── pickers against live discovery ──────────────────────────────────────

    /// The heart of this task: a picker listed a service, discovery retracted
    /// it, and confirming the picker ran a command at a host that had gone.
    #[test]
    fn removing_the_selected_service_closes_an_open_action_picker() {
        let alpha = ssh("alpha", "10.0.0.1");
        let (mut app, tx) = app_with_session(matcher_from(&[SSH, PING]), vec![alpha.clone()]);
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ActionPicker);

        tx.send(DiscoveryEvent::Remove(alpha.id())).unwrap();
        app.drain_discovery();

        assert_eq!(app.mode, AppMode::Browse, "the picker must not survive");
        assert!(
            send(&mut app, KeyCode::Enter).is_none(),
            "confirming must not run the retracted service"
        );
    }

    /// The instance picker's targets are occurrences. Losing the one under the
    /// cursor must not hand the user's pending Enter to a sibling.
    #[test]
    fn removing_the_selected_target_closes_an_open_instance_picker() {
        let alpha = service_on("alpha", "_ssh._tcp", "alpha.local", 22);
        let beta = service_on("beta", "_ssh._tcp", "beta.local", 22);
        let (mut app, tx) =
            app_with_session(matcher_from(&[SSH]), vec![alpha.clone(), beta.clone()]);
        app.filter.grouping = GroupingMode::ServiceType;
        app.recompute_visible();

        // Two hosts prepare two different commands, so the picker opens.
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::InstancePicker);
        assert_eq!(app.pending_action.as_ref().unwrap().targets.len(), 2);

        // Move to beta, then have discovery retract exactly beta.
        send(&mut app, KeyCode::Down);
        tx.send(DiscoveryEvent::Remove(beta.id())).unwrap();
        app.drain_discovery();

        assert_eq!(app.mode, AppMode::Browse);
        assert!(app.status.contains("gone"), "status was: {}", app.status);
    }

    /// The stale-argv case. A service keeps its identity — the registration and
    /// endpoint are untouched — while an address it advertises is replaced. A
    /// picker holding cloned candidates would still list, and run, the address
    /// that has gone.
    #[test]
    fn updating_the_selected_address_cannot_execute_the_stale_argv() {
        let original = ssh_multi("alpha", &["10.0.0.1", "10.0.0.2"]);
        let (mut app, tx) = app_with_session(matcher_from(&[PING_ADDR]), vec![original.clone()]);

        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::InstancePicker);
        // Address order gives index 1 = 10.0.0.2.
        send(&mut app, KeyCode::Down);

        // The same occurrence renumbers: .2 is gone, .9 is new. Addresses are
        // not part of an occurrence's identity, so this is an update.
        let mut renumbered = original.clone();
        renumbered.addresses = ["10.0.0.1", "10.0.0.9"]
            .iter()
            .map(|a| a.parse().unwrap())
            .collect();
        assert_eq!(
            renumbered.id(),
            original.id(),
            "the occurrence must keep its identity"
        );
        tx.send(DiscoveryEvent::Upsert(renumbered)).unwrap();
        app.drain_discovery();

        assert_eq!(
            app.mode,
            AppMode::Browse,
            "the chosen address is gone, so the picker must not stand"
        );
        assert!(
            send(&mut app, KeyCode::Enter).is_none(),
            "`echo 10.0.0.2` must be unrunnable once .2 is retracted"
        );
    }

    /// The inverse: an address the user was not on changing leaves their choice
    /// alone, still selected and still runnable.
    #[test]
    fn updating_an_unselected_address_keeps_the_instance_picker() {
        let original = ssh_multi("alpha", &["10.0.0.1", "10.0.0.2"]);
        let (mut app, tx) = app_with_session(matcher_from(&[PING_ADDR]), vec![original.clone()]);

        assert!(send(&mut app, KeyCode::Enter).is_none());
        send(&mut app, KeyCode::Down); // 10.0.0.2

        // Replace the *other* address.
        let mut renumbered = original.clone();
        renumbered.addresses = ["10.0.0.7", "10.0.0.2"]
            .iter()
            .map(|a| a.parse().unwrap())
            .collect();
        tx.send(DiscoveryEvent::Upsert(renumbered)).unwrap();
        app.drain_discovery();

        assert_eq!(app.mode, AppMode::InstancePicker, "the picker must survive");
        let command = send(&mut app, KeyCode::Enter).expect("the chosen address runs");
        assert_eq!(
            command.argv,
            vec!["echo", "10.0.0.2"],
            "the cursor must still be on the address the user chose"
        );
    }

    /// A service the user was not on disappearing is not a reason to throw away
    /// their picker.
    #[test]
    fn an_unrelated_removal_keeps_the_picker_and_its_selection() {
        let alpha = ssh("alpha", "10.0.0.1");
        let unrelated = http("web");
        let (mut app, tx) = app_with_session(
            matcher_from(&[SSH, PING]),
            vec![alpha.clone(), unrelated.clone()],
        );
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ActionPicker);
        // Move onto `ping`, so the retained selection is observable.
        send(&mut app, KeyCode::Down);
        let chosen = app.action_matches[app.action_index].command.name.clone();

        tx.send(DiscoveryEvent::Remove(unrelated.id())).unwrap();
        app.drain_discovery();

        assert_eq!(app.mode, AppMode::ActionPicker, "the picker must survive");
        assert_eq!(
            app.action_matches[app.action_index].command.name, chosen,
            "the cursor must stay on the action the user chose"
        );
    }

    /// The sharpest form of the defect: the cursor is an index into a list
    /// discovery can shorten. Removing a service *above* the chosen one slides
    /// a different service into its position, so a pending Enter would run a
    /// service the user never selected.
    #[test]
    fn removing_a_service_above_the_cursor_does_not_retarget_the_service_picker() {
        let alpha = service_on("alpha", "_ssh._tcp", "alpha.local", 22);
        let (mut app, tx) = app_with_session(
            matcher_from(&[SSH]),
            vec![
                alpha.clone(),
                service_on("beta", "_ssh._tcp", "beta.local", 22),
                service_on("gamma", "_ssh._tcp", "gamma.local", 22),
            ],
        );
        app.filter.grouping = GroupingMode::Command;
        app.recompute_visible();

        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ServicePicker);
        assert_eq!(app.command_groups[0].services.len(), 3);

        // Index 1 of [alpha, beta, gamma] is beta.
        send(&mut app, KeyCode::Down);
        assert_eq!(app.service_picker_index, 1);

        // alpha goes; beta is now index 0 and gamma inherits index 1.
        tx.send(DiscoveryEvent::Remove(alpha.id())).unwrap();
        app.drain_discovery();

        assert_eq!(app.mode, AppMode::ServicePicker, "the picker must survive");
        assert_eq!(
            app.service_picker_index, 0,
            "the cursor must follow beta, not stay on the index gamma now holds"
        );
        let command = send(&mut app, KeyCode::Enter).expect("the chosen service runs");
        assert_eq!(
            command.argv,
            vec!["ssh", "beta.local"],
            "the service the user chose must run, never the one that inherited its index"
        );
    }

    /// A rule can stop matching without its service going anywhere: a TXT value
    /// the predicate depends on simply changes. The action under the cursor then
    /// refers to something that no longer exists, and must not be confirmable.
    #[test]
    fn a_txt_update_that_unmatches_the_rule_closes_the_action_picker() {
        const TXT_RULE: &str = r#"
[metadata]
name = "open-admin"
[match.txt.role]
equals = "admin"
[action]
command = "open {hostname}"
mode = "execute"
"#;
        const ANY_HTTP: &str = r#"
[metadata]
name = "curl"
[match.service_type]
equals = "_http._tcp"
[action]
command = "curl {hostname}"
mode = "execute"
"#;
        let mut record = Entry::new("nas", "_http._tcp", "local");
        record.hostname = Some("nas.local".to_string());
        record.txt.insert("role".to_string(), "admin".to_string());
        // Two matching rules, so invoking opens the action picker.
        let (mut app, tx) =
            app_with_session(matcher_from(&[TXT_RULE, ANY_HTTP]), vec![record.clone()]);

        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ActionPicker);
        assert_eq!(
            app.action_matches[app.action_index].command.name,
            "open-admin"
        );

        // The role changes; `open-admin` stops matching, `curl` still does.
        let mut demoted = record.clone();
        demoted.txt.insert("role".to_string(), "guest".to_string());
        assert_eq!(demoted.id(), record.id(), "the service itself is unchanged");
        tx.send(DiscoveryEvent::Upsert(demoted)).unwrap();
        app.drain_discovery();

        assert_eq!(
            app.mode,
            AppMode::Browse,
            "the action under the cursor no longer matches, so the picker must go"
        );
        assert!(
            app.status.contains("no longer matches"),
            "status was: {}",
            app.status
        );
        // And it must not have quietly slid onto `curl`.
        assert!(app.action_matches.is_empty());
    }

    /// Explicit fake discovery: running out of samples is the normal ending of
    /// a finite stream, so the samples stay and nothing is reported as broken.
    #[cfg(feature = "fake")]
    #[test]
    fn finite_fake_completion_keeps_its_samples_and_reports_completion() {
        let mut cli = test_cli();
        cli.backend = crate::discovery::DiscoveryBackend::Fake;
        // Filtering to one type keeps the stream short.
        cli.service_type = Some("_ssh._tcp".to_string());
        let session = crate::discovery::start(
            &cli.discovery_options()
                .expect("valid test discovery options"),
        );
        let mut app = App::new(cli, Matcher::default(), KeyBindings::default(), session);

        // Drain until the stream ends, as the event loop would.
        while app.session.state().is_listening() {
            app.drain_discovery();
            std::thread::yield_now();
        }

        assert_eq!(*app.session.state(), SessionState::Complete);
        assert_eq!(
            app.records.len(),
            2,
            "a finished sample stream keeps its records"
        );
        assert!(app.status.contains("complete"));
        assert!(
            !app.status.contains("failed") && !app.status.contains("stopped"),
            "finishing a finite stream is not a failure: {}",
            app.status
        );
    }

    /// Fake discovery is the smoke-test surface, so it has to be able to show
    /// the behavior the app actually has. Task 006 makes an aggregate row ask
    /// which host to act on when its children would run different commands —
    /// which the sample set could not produce while it advertised a single SSH
    /// service. This pins the sample set against that behavior, so `--backend
    /// fake` stays enough to exercise the picker by hand.
    #[cfg(feature = "fake")]
    #[test]
    fn fake_samples_offer_host_selection_on_the_service_type_row() {
        let mut cli = test_cli();
        cli.backend = crate::discovery::DiscoveryBackend::Fake;
        cli.service_type = Some("_ssh._tcp".to_string());
        let session = crate::discovery::start(
            &cli.discovery_options()
                .expect("valid test discovery options"),
        );
        let mut app = App::new(cli, matcher_from(&[SSH]), KeyBindings::default(), session);
        while app.session.state().is_listening() {
            app.drain_discovery();
            std::thread::yield_now();
        }

        app.filter.grouping = GroupingMode::ServiceType;
        app.recompute_visible();

        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[0].label(), "_ssh._tcp");
        assert_eq!(
            app.visible_groups[0].resolved_host_count(),
            2,
            "the sample set must put SSH on two hosts"
        );

        // `ssh {hostname}` over two hosts is two different commands.
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(
            app.mode,
            AppMode::InstancePicker,
            "the row must ask which host rather than run one"
        );

        // Occurrences sort by registration name: raspberry-pi, then workstation.
        send(&mut app, KeyCode::Down);
        let command = send(&mut app, KeyCode::Enter).expect("the chosen host runs");
        assert_eq!(command.argv, vec!["ssh", "workstation.local"]);
    }

    /// Refresh is the recovery action: it must work *from* a failed session.
    #[test]
    fn refresh_recovers_from_a_failed_session() {
        let (factory, spawned) = channel_factory();
        let (tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            Matcher::default(),
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        )
        .with_discovery_factory(factory);

        drop(tx);
        app.drain_discovery();
        assert!(matches!(app.session.state(), SessionState::Failed(_)));

        send(&mut app, KeyCode::Char('r'));

        assert!(
            app.session.state().is_listening(),
            "refresh must recover a failed session"
        );
        spawned.lock().unwrap()[0]
            .send(DiscoveryEvent::Upsert(ssh("gamma", "10.0.0.3")))
            .unwrap();
        app.drain_discovery();
        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[0].label(), "gamma");
    }

    /// The other ending: a finished sample stream is not a failure, but it is
    /// still over, and refreshing it must start a live browse rather than
    /// leave the user on a session that can never produce anything again.
    #[test]
    fn refresh_restarts_a_completed_session() {
        let (factory, spawned) = channel_factory();
        let mut app = App::new(
            test_cli(),
            Matcher::default(),
            KeyBindings::default(),
            DiscoverySession::ended(SessionState::Complete),
        )
        .with_discovery_factory(factory);
        assert!(!app.session.state().is_listening());

        send(&mut app, KeyCode::Char('r'));

        assert!(
            app.session.state().is_listening(),
            "refresh must restart a completed session"
        );
        spawned.lock().unwrap()[0]
            .send(DiscoveryEvent::Upsert(ssh("gamma", "10.0.0.3")))
            .unwrap();
        app.drain_discovery();
        assert_eq!(app.visible_groups.len(), 1);
    }

    /// The session owns its receiver, so a replaced session's events cannot
    /// reach the new list — old and new can never mix.
    #[test]
    fn events_from_a_replaced_session_never_reach_the_new_list() {
        let (factory, spawned) = channel_factory();
        let (old_tx, rx) = mpsc::channel();
        let mut app = App::new(
            test_cli(),
            Matcher::default(),
            KeyBindings::default(),
            DiscoverySession::detached(rx),
        )
        .with_discovery_factory(factory);

        send(&mut app, KeyCode::Char('r'));

        // The superseded producer tries to keep feeding the list. It cannot:
        // the old session was dropped and took its receiver with it, so the
        // send has nowhere to land. The guarantee is structural, not a rule
        // the drain loop has to remember.
        assert!(
            old_tx
                .send(DiscoveryEvent::Upsert(ssh("stale", "10.0.0.9")))
                .is_err(),
            "a replaced session's receiver must be gone with it"
        );
        spawned.lock().unwrap()[0]
            .send(DiscoveryEvent::Upsert(ssh("fresh", "10.0.0.1")))
            .unwrap();
        app.drain_discovery();

        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(
            app.visible_groups[0].label(),
            "fresh",
            "only the current session's events may populate the list"
        );
    }

    /// A refresh whose new session then fails must not leave the pre-refresh
    /// records on screen labelled as current.
    #[test]
    fn a_refresh_that_fails_does_not_retain_the_old_records() {
        let (factory, spawned) = channel_factory();
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")])
            .with_discovery_factory(factory);
        assert_eq!(app.visible_groups.len(), 1);

        send(&mut app, KeyCode::Char('r'));
        // The replacement session's producer dies immediately.
        spawned.lock().unwrap().clear();
        app.drain_discovery();

        assert!(app.records.is_empty());
        assert!(app.visible_groups.is_empty());
        assert!(matches!(app.session.state(), SessionState::Failed(_)));
    }

    /// A loader that hands back a complete rule set built from `sources`.
    fn loads(sources: &'static [&'static str]) -> ConfigLoader {
        Box::new(move |_cli| ReloadOutcome::Loaded(Box::new(matcher_from(sources))))
    }

    /// A loader that rejects the configuration with `diagnostics`, as a reload
    /// does for any invalid file in the overlay.
    fn rejects(diagnostics: &'static [&'static str]) -> ConfigLoader {
        Box::new(move |_cli| {
            ReloadOutcome::Rejected(diagnostics.iter().map(|d| d.to_string()).collect())
        })
    }

    #[test]
    fn requested_reload_swaps_commands_and_recomputes_matches() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader(loads(&[SSH]));
        assert_eq!(app.matcher.command_count(), 0);
        assert_eq!(app.group_matches[0].len(), 0);

        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        assert_eq!(app.matcher.command_count(), 1);
        assert_eq!(
            app.group_matches[0].len(),
            1,
            "matches are recomputed against the reloaded rules"
        );
        assert!(app.status.contains("reloaded 1 command"));
        assert!(
            !app.reload_requested.load(Ordering::Relaxed),
            "the request is consumed"
        );
    }

    #[test]
    fn poll_without_a_reload_request_does_nothing() {
        let calls = Arc::new(AtomicUsize::new(0));
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader({
                let calls = calls.clone();
                Box::new(move |_cli| {
                    calls.fetch_add(1, Ordering::Relaxed);
                    ReloadOutcome::Loaded(Box::new(Matcher::default()))
                })
            });
        let status = app.status.clone();

        app.poll_reload();

        assert_eq!(calls.load(Ordering::Relaxed), 0);
        assert_eq!(app.status, status);
    }

    /// The sole command file is being edited and is momentarily malformed. A
    /// reload that skipped it would leave a session that can no longer do
    /// anything; the rules already in force must simply stay, action and all.
    #[test]
    fn rejected_reload_keeps_the_working_rules_runnable() {
        let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader(rejects(&["/cfg/ssh.toml: unterminated quote"]));

        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        assert_eq!(app.matcher.command_count(), 1, "old rules stay in force");
        assert_eq!(
            app.group_matches[0].len(),
            1,
            "and still match the discovered services"
        );
        let command = send(&mut app, KeyCode::Enter).expect("the working action still runs");
        assert_eq!(command.argv, vec!["ssh", "alpha.local"]);
    }

    /// The one the old lenient reload got wrong: a valid file next to an invalid
    /// one used to install *half* the configuration over a complete rule set,
    /// silently dropping whatever the broken file defined.
    #[test]
    fn a_mixed_valid_and_invalid_overlay_does_not_partially_swap() {
        let mut app = app_with(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader(rejects(&["/cfg/ping.toml: unknown placeholder `{bogus}`"]));

        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        assert_eq!(
            app.matcher.command_count(),
            2,
            "a rule set is installed whole or not at all"
        );
        assert_eq!(app.group_matches[0].len(), 2);
        assert!(
            app.status
                .contains("keeping the 2 command(s) already loaded")
        );
    }

    /// Diagnostics outlive the status line that announced them: the next tick
    /// overwrites the message, and the detail is what the user needs on exit.
    #[test]
    fn rejected_reload_retains_full_diagnostics_across_status_updates() {
        let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader(rejects(&[
                "/etc/kinjo/commands/a.toml: unterminated quote",
                "/home/u/.config/kinjo/commands/b.toml: unsupported match field `typ`",
            ]));

        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();
        assert!(app.status.contains("2 invalid config file(s)"));

        // Anything at all happening in the UI replaces the status line.
        app.fail("something else entirely".to_string()).unwrap();
        assert!(!app.status.contains("invalid config file"));

        assert_eq!(
            app.reload_diagnostics,
            [
                "/etc/kinjo/commands/a.toml: unterminated quote",
                "/home/u/.config/kinjo/commands/b.toml: unsupported match field `typ`",
            ],
            "full source paths and messages survive for the exit report"
        );
    }

    /// Latest-only: a reload reports on the configuration as it is now, so a
    /// fix leaves nothing behind to print about edits already corrected.
    #[test]
    fn a_successful_reload_clears_the_previous_failures_diagnostics() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader(rejects(&["/cfg/ssh.toml: unterminated quote"]));

        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();
        assert!(!app.reload_diagnostics.is_empty());

        // The user fixes the file and signals again.
        app.config_loader = Some(loads(&[SSH]));
        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        assert!(app.reload_diagnostics.is_empty(), "latest reload only");
        assert_eq!(app.matcher.command_count(), 1);
        assert!(app.status.contains("reloaded 1 command"));
    }

    /// A later failure replaces the earlier one rather than accumulating.
    #[test]
    fn a_second_rejected_reload_replaces_the_earlier_diagnostics() {
        let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader(rejects(&["/cfg/first.toml: unterminated quote"]));
        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        app.config_loader = Some(rejects(&["/cfg/second.toml: invalid action mode `frok`"]));
        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        assert_eq!(
            app.reload_diagnostics,
            ["/cfg/second.toml: invalid action mode `frok`"]
        );
    }

    /// The command view projects rules, so an atomic swap has to be visible in
    /// its rows — not just in the matcher's count.
    #[test]
    fn a_valid_reload_swaps_every_rule_together_and_rebuilds_command_groups() {
        let mut app = app_with(matcher_from(&[SSH]), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader(loads(&[SSH, PING]));
        app.filter.grouping = GroupingMode::Command;
        app.recompute_visible();
        assert_eq!(app.command_groups.len(), 1);

        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        assert_eq!(
            app.command_groups
                .iter()
                .map(|group| group.command.name.as_str())
                .collect::<Vec<_>>(),
            ["ssh", "ping"],
            "both new rules arrive at once, projected against current records"
        );
        assert!(app.reload_diagnostics.is_empty());
    }

    #[test]
    fn reload_closes_a_picker_built_from_the_old_rules() {
        let mut app = app_with(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader(Box::new(|_cli| {
                ReloadOutcome::Loaded(Box::new(Matcher::default()))
            }));
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ActionPicker);

        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        assert_eq!(app.mode, AppMode::Browse);
        assert!(app.action_matches.is_empty());
    }

    /// The mirror of the test above: a picker is stale only because the rules
    /// under it changed. A rejected reload changes nothing, so the choice the
    /// user is in the middle of making is still valid and must not be dropped.
    #[test]
    fn a_rejected_reload_leaves_an_open_picker_alone() {
        let mut app = app_with(matcher_from(&[SSH, PING]), vec![ssh("alpha", "10.0.0.1")])
            .with_config_loader(rejects(&["/cfg/ssh.toml: unterminated quote"]));
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ActionPicker);

        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        assert_eq!(app.mode, AppMode::ActionPicker);
        assert_eq!(app.action_matches.len(), 2);
    }

    #[test]
    fn reload_without_a_loader_reports_status() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);

        app.reload_requested.store(true, Ordering::Relaxed);
        app.poll_reload();

        assert!(app.status.contains("config reload is not available"));
    }

    #[test]
    fn quit_keys_request_quit() {
        let mut common = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        assert!(
            common
                .handle_key(
                    KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
                    SCREEN
                )
                .unwrap()
                .is_none()
        );
        assert!(common.should_quit);

        let mut browse = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        send(&mut browse, KeyCode::Char('q'));
        assert!(browse.should_quit);
    }

    #[test]
    fn ctrl_c_quits_immediately_from_a_modal() {
        // Regression: the quit request used to be a `status == "quit"` sentinel
        // honored only in Browse mode, so Ctrl-C inside a modal did nothing
        // visible but poisoned the status, quitting on the next unrelated key.
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        send(&mut app, KeyCode::Char('?'));
        assert_eq!(app.mode, AppMode::Help);

        app.handle_key(
            KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
            SCREEN,
        )
        .unwrap();

        assert!(app.should_quit, "ctrl-c must quit while a modal is open");
    }

    #[test]
    fn help_modal_opens_and_closes() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);

        send(&mut app, KeyCode::Char('?'));
        assert_eq!(app.mode, AppMode::Help);

        send(&mut app, KeyCode::Esc);
        assert_eq!(app.mode, AppMode::Browse);
    }

    #[test]
    fn scroll_details_steps_by_half_viewport_and_clamps() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        // A pane too short for the details, so half a page is a real step and
        // the far end is reachable. Both come from the layout, not from bounds
        // pushed into the app by hand.
        app.update_layout(MOUSE_SCREEN);
        let max = app.layout.details_max_scroll();
        let half = app.layout.details_viewport() / 2;
        assert!(half > 0 && max > half, "max={max} half={half}");

        send(&mut app, KeyCode::Char('d'));
        assert_eq!(app.details_scroll, half);

        for _ in 0..max {
            send(&mut app, KeyCode::Char('d'));
        }
        assert_eq!(app.details_scroll, max, "cannot scroll past the maximum");

        for _ in 0..max {
            send(&mut app, KeyCode::Char('u'));
        }
        assert_eq!(app.details_scroll, 0, "scrolling up returns to the top");
    }

    /// The bug the layout snapshot exists to make impossible: the details
    /// bounds used to be written back by the renderer, so a scroll key handled
    /// before the first frame — or against a terminal that had since been
    /// resized — was clamped to whatever the last frame happened to leave
    /// behind. The snapshot is computed before both, so there is no "last
    /// frame" to be stale.
    #[test]
    fn details_scroll_is_bounded_before_anything_has_been_drawn() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);

        // No layout has been computed: nothing has any room, so nothing scrolls.
        send(&mut app, KeyCode::Char('d'));
        assert_eq!(app.details_scroll, 0);
        app.handle_mouse(mouse_event(MouseEventKind::ScrollDown, 70, 4));
        assert_eq!(app.details_scroll, 0);
        // And no click can land on a row of a list that was never drawn.
        app.handle_mouse(mouse_event(MouseEventKind::Down(MouseButton::Left), 5, 3));
        assert_eq!(app.selected, 0);
    }

    /// Scrolling to the bottom and then growing the terminal must not strand
    /// the reader below the content: the next snapshot is what bounds them.
    #[test]
    fn a_resize_reclamps_the_details_scroll_before_the_next_input() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        app.update_layout(MOUSE_SCREEN);
        let short_max = app.layout.details_max_scroll();
        app.details_scroll = short_max;
        assert!(short_max > 0);

        // A terminal tall enough to show the whole thing has nothing to scroll.
        app.update_layout(SCREEN);
        assert_eq!(app.layout.details_max_scroll(), 0);
        assert_eq!(app.details_scroll, 0, "the resize pulled the scroll back");

        send(&mut app, KeyCode::Char('d'));
        assert_eq!(app.details_scroll, 0, "there is nothing below to scroll to");
    }

    // ── selection identity and detail scroll ───────────────────────────────

    /// Scroll position is a place inside one row's details. When the row the
    /// user was reading is retracted, the cursor lands on a neighbour — and
    /// dropping the reader part-way down a *different* service's details is
    /// nonsense, so the replacement starts from the top.
    #[test]
    fn removing_the_selected_row_focuses_a_replacement_at_the_top_of_its_details() {
        let mut app = app_with(
            Matcher::default(),
            vec![
                ssh("alpha", "10.0.0.1"),
                ssh("beta", "10.0.0.2"),
                ssh("gamma", "10.0.0.3"),
            ],
        );
        app.update_layout(MOUSE_SCREEN);
        app.selected = 1;
        app.details_scroll = 2;
        assert_eq!(app.visible_groups[app.selected].label(), "beta");

        app.records.remove(&ssh("beta", "10.0.0.2").id());
        app.recompute_visible();

        // Deterministic: the row that took beta's place, not beta's old index
        // pointing at whatever slid into it.
        assert_eq!(app.visible_groups[app.selected].label(), "gamma");
        assert_eq!(app.details_scroll, 0);
    }

    /// Filtering a row away is the same event as it being retracted, as far as
    /// the reader is concerned: what they were reading is no longer on screen.
    #[test]
    fn filtering_out_the_selected_row_resets_the_details_scroll() {
        let mut app = app_with(
            Matcher::default(),
            vec![ssh("alpha", "10.0.0.1"), http("beta")],
        );
        app.update_layout(MOUSE_SCREEN);
        app.selected = app
            .visible_groups
            .iter()
            .position(|group| group.label() == "beta")
            .expect("beta is listed");
        app.details_scroll = 2;

        app.filter.toggle_service_type("_http._tcp");
        app.recompute_visible();

        assert_eq!(app.visible_groups.len(), 1);
        assert_eq!(app.visible_groups[app.selected].label(), "alpha");
        assert_eq!(app.details_scroll, 0);
    }

    /// The other half of the rule: an update to the row being read is not a
    /// change of subject. Discovery re-reports a service constantly, and
    /// snapping the reader back to the top on every refresh would make the
    /// details unreadable.
    #[test]
    fn updating_the_selected_row_keeps_its_place_in_the_details() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        app.update_layout(MOUSE_SCREEN);
        app.details_scroll = 2;

        // The same registration, re-reported with another address.
        let mut updated = ssh("alpha", "10.0.0.1");
        updated.port = Some(2222);
        app.records.insert(updated.id(), updated);
        app.recompute_visible();

        assert_eq!(app.visible_groups[app.selected].label(), "alpha");
        assert_eq!(app.details_scroll, 2, "the reader stayed where they were");
    }

    /// Kept scroll is only kept as far as the new content reaches: an update
    /// that shortens the details must not leave the reader below the end of
    /// them. Identity survives a TXT change, so this is the "same row, less to
    /// say" case rather than a change of subject.
    #[test]
    fn a_kept_scroll_is_clamped_to_the_updated_content() {
        let mut verbose = ssh("alpha", "10.0.0.1");
        for i in 0..12 {
            verbose.txt.insert(format!("key{i:02}"), i.to_string());
        }
        let mut app = app_with(Matcher::default(), vec![verbose.clone()]);
        app.update_layout(MOUSE_SCREEN);

        let tall = app.layout.details_max_scroll();
        app.details_scroll = tall;

        // The same occurrence, re-reported with its TXT data gone.
        let terse = ssh("alpha", "10.0.0.1");
        assert_eq!(terse.id(), verbose.id(), "the row's identity is unchanged");
        app.records.insert(terse.id(), terse);
        app.recompute_visible();
        app.update_layout(MOUSE_SCREEN);

        let short = app.layout.details_max_scroll();
        assert!(
            short < tall,
            "the details must have got shorter: {tall} → {short}"
        );
        assert_eq!(
            app.details_scroll, short,
            "the reader was pulled back to the new end, not stranded past it"
        );
    }

    /// An emptied list has no row to focus and nothing to read.
    #[test]
    fn losing_every_row_leaves_the_details_at_the_top() {
        let mut app = app_with(Matcher::default(), vec![ssh("alpha", "10.0.0.1")]);
        app.update_layout(MOUSE_SCREEN);
        app.details_scroll = 2;

        app.records.clear();
        app.recompute_visible();

        assert!(app.visible_groups.is_empty());
        assert_eq!(app.selected, 0);
        assert_eq!(app.details_scroll, 0);
    }

    #[test]
    fn move_index_clamps_and_handles_empty_lists() {
        assert_eq!(move_index(0, 0, 1), 0);
        assert_eq!(move_index(0, 3, -1), 0);
        assert_eq!(move_index(2, 3, 1), 2);
        assert_eq!(move_index(1, 3, 1), 2);
    }

    // ── command-execution error handling ───────────────────────────────────
    const NEEDS_TOOL: &str = r#"
[metadata]
name = "needs-tool"
requirements = ["kinjo-absent-tool-xyz"]
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "echo hi"
mode = "execute"
"#;

    const FORK_MISSING_BINARY: &str = r#"
[metadata]
name = "ghost"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "kinjo-absent-binary-xyz --flag"
mode = "fork"
"#;

    const OPTIONAL_REQ: &str = r#"
[metadata]
name = "with-optional"
requirements = ["kinjo-absent-tool-xyz, optional"]
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "echo hi"
mode = "execute"
"#;

    #[test]
    fn unsatisfied_requirement_reports_status_without_executing() {
        let mut app = app_with(matcher_from(&[NEEDS_TOOL]), vec![ssh("alpha", "10.0.0.1")]);

        assert!(
            send(&mut app, KeyCode::Enter).is_none(),
            "a missing requirement must not execute"
        );
        assert!(app.status.contains("kinjo-absent-tool-xyz"));
        assert_eq!(app.mode, AppMode::Browse);
    }

    #[test]
    fn optional_requirement_does_not_block_execution() {
        let mut app = app_with(
            matcher_from(&[OPTIONAL_REQ]),
            vec![ssh("alpha", "10.0.0.1")],
        );

        let command = send(&mut app, KeyCode::Enter).expect("optional requirement is skipped");
        assert_eq!(command.argv, vec!["echo", "hi"]);
    }

    // A template naming an unknown field can no longer reach the app at all:
    // loading rejects it, so there is nothing here to fail at invocation time.
    // `plumber::template` covers that rejection.

    #[test]
    fn fork_failure_reports_status_and_stays_in_browse() {
        let mut app = app_with(
            matcher_from(&[FORK_MISSING_BINARY]),
            vec![ssh("alpha", "10.0.0.1")],
        );

        assert!(send(&mut app, KeyCode::Enter).is_none());
        // The message names both the action and the missing binary.
        assert!(app.status.contains("cannot run `ghost`"));
        assert!(
            app.status
                .contains("command `kinjo-absent-binary-xyz` not found")
        );
        assert_eq!(app.mode, AppMode::Browse);
    }

    #[test]
    fn failed_action_closes_an_open_picker() {
        // Two actions match, opening the picker; selecting the broken one must
        // both report the error and drop the picker, not leave it dangling.
        let mut app = app_with(
            matcher_from(&[SSH, FORK_MISSING_BINARY]),
            vec![ssh("alpha", "10.0.0.1")],
        );

        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::ActionPicker);
        assert_eq!(app.action_matches.len(), 2);

        // action_index 1 is the missing-binary command (insertion order).
        send(&mut app, KeyCode::Down);
        assert!(send(&mut app, KeyCode::Enter).is_none());
        assert_eq!(app.mode, AppMode::Browse);
        assert!(app.action_matches.is_empty());
        assert!(app.status.contains("cannot run `ghost`"));
    }
}