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
|
//! The TUI application loop (plan §2/§9): wires the libpanto pull `Stream`
//! into component state and drives the differential render engine.
//!
//! This module is the NEW app/chat loop that `main.zig` shrinks to wiring
//! around. It owns:
//! - a `Terminal` (raw mode + bracketed paste + SIGWINCH/restore),
//! - a `tui_engine.Engine` driving a LIST of components,
//! - the transcript (heap-allocated user/assistant/status components that
//! persist for the engine to borrow),
//! - a pinned `InputBox` (focused) and `Footer`,
//! - the libpanto stream pump that routes each `Event` to component state.
//!
//! ## No "active component" invariant (plan §6)
//!
//! Streaming state is keyed by libpanto BLOCK INDEX (and tool call identity),
//! never a single mutable "current component" pointer. `TurnRouter` holds a
//! `block_index -> *transcript entry` map, so when parallel tool calls or
//! interleaved blocks arrive later (P2), each delta lands on the right
//! component without restructuring. P1 only spawns the minimal component set
//! (user/assistant/input/footer + minimal status lines), but the routing
//! structure is already parallel-safe.
//!
//! ## Streaming -> component state (plan §8)
//!
//! There is no per-delta render method. The pump consumes the pull `Stream`
//! and, for each event, MUTATES component state and calls
//! `scheduler.requestRender()`. The engine's append fast path
//! (`firstLineChanged` near the tail via the render cache + the line-diff
//! backstop) repaints only the dirty tail. stdout is never written directly.
//!
//! ## Thinking / tool / compaction display (P2)
//!
//! The full built-in component set is wired here:
//! - a Thinking block streams its deltas into a dedicated dim `Thinking`
//! component,
//! - a ToolUse block drives a `ToolUse` component (one per call) through its
//! `tool (?)…` -> `tool (<name>) <input json>` -> `+ <output>` progression;
//! the component is collapsible via a GLOBAL ctrl+o toggle (default
//! collapsed, showing the last 5 output lines),
//! - a CompactionSummary block (or a compaction provider-retry) renders a
//! `CompactionSummary` component.
//!
//! ## Tool-result correlation (no "active component")
//!
//! ToolResult blocks do NOT arrive via `block_start`/`block_complete`; the
//! agent assembles them and delivers them together in the
//! `tool_dispatch_complete` event's user `Message`. Each `ToolResultBlock`
//! carries a `tool_use_id` linking back to its `ToolUseBlock.id`. The router
//! therefore keeps a SECOND map, tool-call id -> *ToolUse component, populated
//! when the tool name/id resolve; on `tool_dispatch_complete` we walk the
//! result blocks and feed each one's text to the matching component by id.
//! Nothing is keyed by a single "current" component (plan §6 invariant).
const std = @import("std");
const posix = std.posix;
const panto = @import("panto");
const terminal_mod = @import("tui_terminal.zig");
const engine_mod = @import("tui_engine.zig");
const components = @import("tui_components.zig");
const input_mod = @import("tui_input.zig");
const theme = @import("tui_theme.zig");
const component = @import("tui_component.zig");
const ui_event = @import("tui_event.zig");
const command = @import("command.zig");
const selectors_mod = @import("tui_selectors.zig");
const config_file = @import("config_file.zig");
const auth_manager = @import("auth_manager.zig");
const models_toml = @import("models_toml.zig");
const pricing_format = @import("pricing_format.zig");
const tui_key = @import("tui_key.zig");
const Terminal = terminal_mod.Terminal;
const Engine = engine_mod.Engine;
const Scheduler = engine_mod.Scheduler;
const Clock = engine_mod.Clock;
const AssistantText = components.AssistantText;
const UserText = components.UserText;
const InputBox = components.InputBox;
const Footer = components.Footer;
const Welcome = components.Welcome;
const Thinking = components.Thinking;
const CompactionSummary = components.CompactionSummary;
const ToolUse = components.ToolUse;
const Component = component.Component;
const Selector = components.Selector;
const SelectorItem = components.SelectorItem;
const EventBus = ui_event.EventBus;
const UIEvent = ui_event.Event;
const Payload = ui_event.Payload;
const Event = panto.Event;
// ===========================================================================
// IoClock — the real monotonic clock for the engine's scheduler
// ===========================================================================
/// Wraps `std.Io`'s monotonic (`.awake`) clock as an engine `Clock`. The
/// engine stays Io-agnostic; this is the app-side adapter that supplies real
/// time. Store one by value and pass `clock()` into the engine/`App`.
pub const IoClock = struct {
io: std.Io,
pub fn init(io: std.Io) IoClock {
return .{ .io = io };
}
fn nowFn(ptr: *anyopaque) i128 {
const self: *IoClock = @ptrCast(@alignCast(ptr));
return @intCast(std.Io.Clock.now(.awake, self.io).nanoseconds);
}
pub fn clock(self: *IoClock) Clock {
return .{ .ptr = self, .nowFn = nowFn };
}
};
// ===========================================================================
// Transcript
// ===========================================================================
/// The concrete built-in component a transcript entry owns. This is panto's
/// DEFAULT component for that boundary; deltas are always driven into this
/// typed box regardless of whether an extension handler replaced what the
/// engine renders (see `Entry.override`).
///
/// `StatusText` reuses `AssistantText` but is styled by the caller via a
/// leading style escape baked into the text (we keep it as a plain
/// AssistantText and prefix a dim/style run in the seeded text).
const EntryKind = union(enum) {
user: *UserText,
/// Assistant message body (streaming text block).
assistant: *AssistantText,
/// A dim status/retry line (provider retries, command output, errors).
status: *AssistantText,
/// Session-start banner.
welcome: *Welcome,
/// Streaming thinking block (dim).
thinking: *Thinking,
/// A tool call + result (collapsible).
tool: *ToolUse,
/// A compaction summary.
compaction: *CompactionSummary,
/// The default component for this kind (panto's built-in render).
fn defaultComp(self: EntryKind) Component {
return switch (self) {
.user => |p| p.comp(),
.assistant => |p| p.comp(),
.status => |p| p.comp(),
.welcome => |p| p.comp(),
.thinking => |p| p.comp(),
.tool => |p| p.comp(),
.compaction => |p| p.comp(),
};
}
fn deinit(self: EntryKind, alloc: std.mem.Allocator) void {
switch (self) {
inline else => |p| {
p.deinit();
alloc.destroy(p);
},
}
}
};
/// The distinct lifecycle events a single transcript entry can see, used as a
/// per-entry FIRE-ONCE guard set. A given lifecycle event must fire at most
/// once per slot even when the underlying libpanto boundary could be hit twice
/// (e.g. `tool` is fired at block-start, but `tool_call_complete` and the
/// fallback also resolve the name — each named event fires exactly once).
///
/// `*_delta` events are intentionally ABSENT: deltas fire repeatedly by design
/// (once per streaming chunk), so they are never guarded.
/// Which streaming text-block kind a text-lifecycle helper targets. Named (not
/// an anonymous enum) so the two helpers that take it share one type.
const TextKind = enum { assistant, thinking };
const Lifecycle = enum {
session_start,
user_message,
thinking,
thinking_complete,
assistant_text,
assistant_text_complete,
tool,
tool_details,
tool_call_complete,
tool_result,
compaction,
};
/// A heap-allocated transcript entry. The engine borrows each entry's
/// `comp()`; the entry must outlive its time in the engine's list, so the
/// transcript owns the boxes on the heap and frees them on `deinit`.
///
/// ## Drive-by-default-box / render-by-override split
///
/// `kind` is panto's built-in DEFAULT component for the boundary, and the
/// typed box panto always DRIVES (deltas/details/result mutate `kind.<box>`,
/// and `TurnRouter` holds the same typed pointer). `override`, when set, is the
/// component an extension handler chose for one of this entry's lifecycle
/// events (§7): the ENGINE RENDERS the override instead of the default, while
/// panto KEEPS DRIVING the default typed box. The override is expected to WRAP
/// the default and render through it; a swapped-in component that ignores the
/// default simply renders its own content while the default keeps accumulating
/// (harmless). With no handler registered, `override` is null and rendering is
/// byte-identical to the pre-event-system behavior.
///
/// ## Ownership boundary (read before touching `override`)
///
/// The App/transcript owns ONLY the `kind` default boxes (heap-allocated here,
/// freed on `deinit`). It does NOT own `override` components: an override is
/// owned by whoever created it — the registering extension or, in the next
/// sub-phase, the Lua bridge. Therefore the App MUST NOT free an override.
///
/// When an override is REPLACED by a newer override (a second handler swap on
/// the same slot), the previously-overriding component is no longer referenced
/// by this entry and its owner needs to release it. In THIS sub-phase all
/// overrides are native test/extension components with their own lifetime, so
/// the App simply drops the old reference. The release POINT is `setOverride`
/// below: when the Lua bridge lands, it registers a release callback there so
/// a superseded bridged component's Lua ref/cache is dropped (no per-call
/// leak). See `App.override_release` and the TODO at `setOverride`.
const Entry = struct {
kind: EntryKind,
/// Extension-chosen render component for this entry, or null for the
/// built-in default. The transcript does NOT own this component's storage
/// (the registering extension / Lua bridge does); it owns only the `kind`
/// boxes. See the ownership note above.
override: ?Component = null,
/// Per-entry fire-once guard: which lifecycle events have already fired for
/// this slot. `*_delta` events are not tracked (they fire repeatedly).
fired: std.EnumSet(Lifecycle) = std.EnumSet(Lifecycle).initEmpty(),
/// The component the ENGINE renders: the extension override if present,
/// else panto's built-in default.
fn comp(self: Entry) Component {
return self.override orelse self.kind.defaultComp();
}
fn deinit(self: Entry, alloc: std.mem.Allocator) void {
self.kind.deinit(alloc);
}
};
/// A staged `tool_result` output override: the replacement text a handler
/// assigned to `ev.output`, keyed by the call it belongs to. Both slices
/// owned by the App's allocator.
const PendingOutputOverride = struct {
id: []u8,
text: []u8,
};
// ===========================================================================
// App
// ===========================================================================
pub const App = struct {
alloc: std.mem.Allocator,
engine: *Engine,
scheduler: Scheduler,
clock: Clock,
/// Owned transcript entries (boxes the engine borrows). Top-to-bottom.
transcript: std.ArrayList(Entry) = .empty,
/// Pinned, persistent components. Owned here (by value); the engine
/// borrows their `comp()`.
input_box: *InputBox,
footer: *Footer,
/// Per-turn block routing. Cleared at each turn boundary.
router: TurnRouter,
/// The extension UI event bus (plan §7). Built-in events are fired through
/// this at each component-creation boundary BEFORE first paint, so a
/// registered handler can replace/wrap the chosen component. With no
/// handlers registered it is a pure pass-through: every boundary keeps its
/// built-in default component and rendering is unchanged. The Lua bridge
/// (later sub-phase) registers handlers into this same bus.
bus: EventBus,
/// Global tool-use collapse state (ctrl+o). Applies to EVERY tool-use
/// component at once (plan: collapse is a global toggle). Default true:
/// tool output starts collapsed to its last few lines.
tools_collapsed: bool = true,
/// Staged writable-event-field overrides (taken off the bus right after
/// the corresponding fire), waiting for the turn driver — which owns the
/// agent — to apply them to the conversation:
/// - input overrides: tool_use_id → replacement input JSON, staged at
/// `tool_call_complete` (mid-stream), applied at `tool_dispatch_start`
/// once the assistant message is committed but before dispatch;
/// - output overrides: staged at `tool_result`, applied right after
/// `tool_dispatch_complete` routing, before the next provider call.
/// Keys and values owned by `alloc`. Cleared as applied (and defensively
/// at turn start).
pending_input_overrides: std.StringHashMapUnmanaged([]u8) = .empty,
pending_output_overrides: std.ArrayListUnmanaged(PendingOutputOverride) = .empty,
/// Optional override-release hook. When a slot's `override` is REPLACED by
/// a newer override (a second handler swap on the same slot), the old
/// override is no longer referenced by panto and its OWNER must release it.
/// The App never owns overrides (see `Entry`'s ownership note), so it
/// cannot free them itself. Instead, whoever creates overrides (the Lua
/// bridge, in the next sub-phase) installs this callback; the App invokes
/// it with the superseded component so the owner can drop its ref/cache.
/// Null in this sub-phase (native overrides manage their own lifetime), so
/// the swap simply drops the old reference — see `setOverride`.
override_release_ctx: ?*anyopaque = null,
override_release_fn: ?*const fn (ctx: *anyopaque, old: Component) void = null,
/// Optional sink flusher. The real terminal's engine writer is a buffered
/// file writer that must be flushed after each frame for output to reach
/// the tty; tests inject an in-memory writer and leave this null.
flush_ctx: ?*anyopaque = null,
flush_fn: ?*const fn (ctx: *anyopaque) void = null,
/// Optional runtime model/reasoning selector controller (installed during
/// real-terminal bring-up; tests leave it null). Owns the live config and
/// the picker overlays.
selectors: ?*SelectorController = null,
/// Optional hook the App invokes on every `message_complete` carrying
/// a `Usage`. The hook (installed by the `SelectorController`) updates
/// session-running totals keyed by the current `(provider, model)` and
/// pushes the latest values into the footer. With no hook installed
/// (e.g. tests) the App still updates the per-turn context-window
/// tokens but does NOT accumulate session totals.
usage_record_ctx: ?*anyopaque = null,
usage_record_fn: ?*const fn (ctx: *anyopaque, usage: panto.Usage) void = null,
/// Whether the input box currently participates in the engine list. It is
/// removed during an in-flight turn (so streaming output appends below the
/// transcript) and re-added when the turn completes. P1 keeps it simple:
/// input + footer are always present and pinned at the bottom.
pub fn init(
alloc: std.mem.Allocator,
engine: *Engine,
clock: Clock,
input_box: *InputBox,
footer: *Footer,
) App {
return .{
.alloc = alloc,
.engine = engine,
.scheduler = Scheduler.init(8 * std.time.ns_per_ms),
.clock = clock,
.input_box = input_box,
.footer = footer,
.router = TurnRouter.init(alloc),
.bus = EventBus.init(alloc),
.tools_collapsed = true,
};
}
pub fn deinit(self: *App) void {
for (self.transcript.items) |e| e.deinit(self.alloc);
self.transcript.deinit(self.alloc);
self.router.deinit();
self.bus.deinit();
self.clearPendingOverrides();
self.pending_input_overrides.deinit(self.alloc);
self.pending_output_overrides.deinit(self.alloc);
}
/// Drop all staged (unapplied) writable-field overrides.
fn clearPendingOverrides(self: *App) void {
var it = self.pending_input_overrides.iterator();
while (it.next()) |entry| {
self.alloc.free(entry.key_ptr.*);
self.alloc.free(entry.value_ptr.*);
}
self.pending_input_overrides.clearRetainingCapacity();
for (self.pending_output_overrides.items) |po| {
self.alloc.free(po.id);
self.alloc.free(po.text);
}
self.pending_output_overrides.clearRetainingCapacity();
}
/// Access the event bus so the embedder (and, later, the Lua bridge) can
/// register handlers for built-in or custom events (plan §7).
pub fn eventBus(self: *App) *EventBus {
return &self.bus;
}
/// Install the override-release hook (see `App.override_release_fn`). The
/// owner of override components (the Lua bridge) calls this so that, when a
/// slot's override is replaced by a newer one, the superseded component is
/// handed back for release. The App never frees overrides itself.
pub fn setOverrideRelease(
self: *App,
ctx: *anyopaque,
f: *const fn (ctx: *anyopaque, old: Component) void,
) void {
self.override_release_ctx = ctx;
self.override_release_fn = f;
}
/// Install a sink flusher (the buffered terminal file writer). Called once
/// during real-terminal bring-up; tests leave it unset.
pub fn setFlusher(self: *App, ctx: *anyopaque, f: *const fn (ctx: *anyopaque) void) void {
self.flush_ctx = ctx;
self.flush_fn = f;
}
/// Install the per-turn usage record hook (the `SelectorController`
/// calls this). On every `message_complete` the App invokes the
/// hook with the just-reported `Usage`; the hook keys the usage by
/// the current `(provider, model)` (its own concern) and pushes the
/// new session totals back into the footer. Tests omit this; the
/// per-turn context-window tokens still get updated.
pub fn setUsageRecorder(
self: *App,
ctx: *anyopaque,
f: *const fn (ctx: *anyopaque, usage: panto.Usage) void,
) void {
self.usage_record_ctx = ctx;
self.usage_record_fn = f;
}
fn flushSink(self: *App) void {
if (self.flush_fn) |f| f(self.flush_ctx.?);
}
// -- transcript spawning ------------------------------------------------
/// Append a fresh transcript entry and register it with the engine,
/// keeping the pinned input box + footer at the very bottom.
fn pushEntry(self: *App, entry: Entry) !void {
try self.transcript.append(self.alloc, entry);
try self.rebuildEngineList();
}
/// Fire the creation-boundary event for a freshly-created component, then
/// append the entry using whatever component the handler chain chose. This
/// is the CREATION special-case of the general `fireForEntry` lifecycle
/// fire: the entry does not exist yet, so we seed the event with the typed
/// default, run handlers, and push the entry with the chosen override (if
/// any) in one step.
///
/// With no handlers registered, `emit` returns the seeded default and the
/// override stays null — rendering is byte-identical to the
/// pre-event-system behavior.
///
/// `kind` is the typed default box (deltas always drive it). `name` +
/// `payload` describe the event. The default component seeded into the
/// event is `kind.defaultComp()`; the chosen component becomes the entry's
/// render override iff a handler replaced it. `lc` is the fire-once tag
/// recorded on the new entry.
fn pushEntryFired(self: *App, kind: EntryKind, lc: Lifecycle, name: []const u8, payload: Payload) !void {
const default = kind.defaultComp();
var ev = UIEvent.init(name, default, payload);
const chosen = self.bus.emit(&ev);
// Only record an override when a handler actually swapped the
// component; equal ptr means the default survived (pass-through).
const override: ?Component = blk: {
if (chosen) |c| {
if (c.ptr != default.ptr) break :blk c;
}
break :blk null;
};
var entry: Entry = .{ .kind = kind, .override = override };
entry.fired.insert(lc);
try self.pushEntry(entry);
}
/// Fire a lifecycle event for an EXISTING transcript entry (the general
/// case; creation is the `pushEntryFired` special-case above).
///
/// Per §7.2, the event is seeded with the slot's CURRENT rendered component
/// (`entry.comp()` — a prior override if one was set, else the default), so
/// `getComponent()` returns "whatever is current", not a frozen default. The
/// handler chain runs; if the chosen component differs from the current
/// one, we SWAP it in via `setOverride` (which records the new override,
/// hands the old one back for release, and forces a full-takeover repaint).
///
/// `lc`, when non-null, is a fire-once guard: the event fires at most once
/// per slot for that tag. Pass null for repeatable events (`*_delta`).
/// Returns true if the event actually fired (false when guarded-out).
fn fireForEntry(self: *App, entry: *Entry, lc: ?Lifecycle, name: []const u8, payload: Payload) !bool {
if (lc) |tag| {
if (entry.fired.contains(tag)) return false;
entry.fired.insert(tag);
}
const current = entry.comp();
var ev = UIEvent.init(name, current, payload);
const chosen = self.bus.emit(&ev);
if (chosen) |c| {
if (c.ptr != current.ptr) try self.setOverride(entry, c);
}
return true;
}
/// Swap a slot's rendered component to `new` mid-stream (no "active
/// component": same entry, same key; only WHICH component the entry renders
/// changes). Three responsibilities (plan §7.4 revised):
///
/// 1. RELEASE the outgoing override (if any). The App never owns
/// overrides; their creator does. If an `override_release_fn` is
/// installed (by the Lua bridge), hand the superseded override back so
/// its owner drops the ref/cache — the leak-prevention point. With no
/// hook installed (this sub-phase: native overrides with their own
/// lifetime), we just drop the reference. The outgoing DEFAULT `kind`
/// box is never released here — the entry still owns it and panto keeps
/// driving it.
/// TODO(lua-bridge): the bridge installs `setOverrideRelease` so this
/// call site releases a superseded bridged component.
/// 2. Record `new` as the entry's override.
/// 3. Force the incoming component to FULLY TAKE OVER the rendered region
/// (repaint from line 0, clearing orphaned lines from a taller
/// predecessor). `rebuildEngineList` re-adds every component, which the
/// engine treats as a layout change — it forces a full redraw, so the
/// incoming component renders from scratch and stale rows are cleared.
/// Native components are also dirty-from-0 on first render via
/// `RenderCache`, so the incoming component reports `firstLineChanged
/// = 0` regardless.
fn setOverride(self: *App, entry: *Entry, new: Component) !void {
if (entry.override) |old| {
if (old.ptr != new.ptr) {
if (self.override_release_fn) |f| f(self.override_release_ctx.?, old);
}
}
entry.override = new;
// Layout change => full redraw => full takeover + orphan clearing.
try self.rebuildEngineList();
}
/// Rebuild the engine's component list: all transcript entries top-to-
/// bottom, then the pinned input box, then the footer. Called whenever the
/// transcript layout changes (a layout change forces a full redraw inside
/// the engine, which is correct here).
fn rebuildEngineList(self: *App) !void {
// Build the desired component list (transcript entries top-to-bottom,
// then the pinned input box and footer) and hand it to the engine to
// reconcile IN PLACE. `syncComponents` preserves the baseline of every
// unchanged slot, so the dominant case — appending a new transcript
// entry — stays on the differential path instead of forcing a full
// (and, for scrolled content, scrollback-clearing) redraw. See
// `Engine.syncComponents`.
var comps: std.ArrayList(Component) = .empty;
defer comps.deinit(self.alloc);
try comps.ensureTotalCapacity(self.alloc, self.transcript.items.len + 3);
for (self.transcript.items) |e| comps.appendAssumeCapacity(e.comp());
comps.appendAssumeCapacity(self.input_box.comp());
// An open selector overlay renders between the input box and the
// footer (a modal picker pinned just above the footer).
if (self.activeSelector()) |sel| comps.appendAssumeCapacity(sel.comp());
comps.appendAssumeCapacity(self.footer.comp());
try self.engine.syncComponents(comps.items);
}
/// Spawn a new assistant-text entry for the given block index and return
/// it. Keyed by index in the router so deltas route without an "active
/// component" pointer.
fn spawnAssistant(self: *App, index: usize) !*AssistantText {
const box = try self.alloc.create(AssistantText);
box.* = AssistantText.init(self.alloc);
try self.pushEntryFired(
.{ .assistant = box },
.assistant_text,
"assistant_text",
.{ .assistant_text = .{ .index = index } },
);
return box;
}
/// Spawn a dim status line seeded with `text`. Used for thinking blocks,
/// tool-call status, retry notices, command output, and errors. Returns
/// the box so streaming callers (thinking) can append more.
fn spawnStatus(self: *App, text: []const u8) !*AssistantText {
const box = try self.alloc.create(AssistantText);
box.* = AssistantText.init(self.alloc);
// Seed with a dim run so the status reads as chrome, not assistant
// prose. The component renders plain assistant style, so we bake the
// dim escape into the text itself (a documented P1 minimal stand-in
// for a real status component).
const dim = theme.default.fg(.dim);
const seeded = try std.fmt.allocPrint(self.alloc, "{s}{s}{s}", .{ dim.open(), text, dim.close() });
defer self.alloc.free(seeded);
try box.setText(seeded);
// Status lines are internal chrome (provider retries, command output,
// errors) — NOT one of the §8 built-in events — so no event is fired.
try self.pushEntry(.{ .kind = .{ .status = box } });
return box;
}
/// Spawn a user-message entry seeded with `text`. Fires `user_message`.
fn spawnUser(self: *App, text: []const u8) !void {
const box = try self.alloc.create(UserText);
box.* = UserText.init(self.alloc);
try box.setText(text);
try self.pushEntryFired(
.{ .user = box },
.user_message,
"user_message",
.{ .user_message = .{ .text = text } },
);
}
/// Spawn the session-start welcome banner. Fires `session_start`. Returns
/// it so the caller can set its fields (version / cwd / model) afterward.
fn spawnWelcome(self: *App, payload: Payload.SessionStart) !*Welcome {
const box = try self.alloc.create(Welcome);
box.* = Welcome.init(self.alloc);
try self.pushEntryFired(
.{ .welcome = box },
.session_start,
"session_start",
.{ .session_start = payload },
);
return box;
}
/// Spawn a streaming thinking entry. Keyed by block index in the router.
/// Fires `thinking`.
fn spawnThinking(self: *App, index: usize) !*Thinking {
const box = try self.alloc.create(Thinking);
box.* = Thinking.init(self.alloc);
try self.pushEntryFired(
.{ .thinking = box },
.thinking,
"thinking",
.{ .thinking = .{ .index = index } },
);
return box;
}
/// Spawn a tool-use entry at the ToolUse block-start boundary and FIRE the
/// `tool` event immediately (name UNKNOWN; the component shows `tool (?)`).
/// This is the creation boundary for the tool lifecycle: a handler that
/// wants to claim a call regardless of name (or set up wrapping early) can
/// `setComponent` here, before any content paints. Name-based claiming
/// happens at the later `tool_details` event (§7.5), which can swap again.
fn spawnTool(self: *App, index: usize) !*ToolUse {
const box = try self.alloc.create(ToolUse);
box.* = ToolUse.init(self.alloc);
box.setCollapsed(self.tools_collapsed);
// Fire `tool` at the creation boundary (name unknown => `tool (?)`).
try self.pushEntryFired(
.{ .tool = box },
.tool,
"tool",
.{ .tool = .{ .index = index, .collapsed = self.tools_collapsed } },
);
return box;
}
/// Locate the transcript entry whose tool component is `box`, or null.
fn findToolEntry(self: *App, box: *ToolUse) ?*Entry {
for (self.transcript.items) |*e| {
switch (e.kind) {
.tool => |p| if (p == box) return e,
else => {},
}
}
return null;
}
/// Fire a tool-lifecycle event (`tool_details` / `tool_delta` /
/// `tool_call_complete` / `tool_result`) for the entry backing `box`,
/// driving the mid-stream swap path. `lc` is the fire-once tag (null for
/// the repeatable `tool_delta`). A no-op when the box has no entry.
fn fireToolLifecycle(
self: *App,
box: *ToolUse,
lc: ?Lifecycle,
name: []const u8,
payload: Payload,
) !void {
const entry = self.findToolEntry(box) orelse return;
var enriched = payload;
if (enriched == .tool) {
enriched.tool.collapsed = box.collapsed;
if (enriched.tool.id.len == 0) {
if (box.id) |id| enriched.tool.id = id.items;
}
}
_ = try self.fireForEntry(entry, lc, name, enriched);
// Writable-field overrides: take what a handler wrote during this
// fire and stage it by call id for the turn driver (which owns the
// agent) to apply at the effective boundary. An override without a
// resolved call id has nothing to attach to and is dropped.
const is_result = std.mem.eql(u8, name, "tool_result");
const is_call_complete = std.mem.eql(u8, name, "tool_call_complete");
if (!is_result and !is_call_complete) return;
const value = self.bus.takeOverride() orelse return;
const id = enriched.tool.id;
if (id.len == 0) {
self.alloc.free(value);
return;
}
if (is_result) {
const id_copy = self.alloc.dupe(u8, id) catch |e| {
self.alloc.free(value);
return e;
};
try self.pending_output_overrides.append(self.alloc, .{ .id = id_copy, .text = value });
} else {
const gop = self.pending_input_overrides.getOrPut(self.alloc, id) catch |e| {
self.alloc.free(value);
return e;
};
if (gop.found_existing) {
self.alloc.free(gop.value_ptr.*);
} else {
gop.key_ptr.* = self.alloc.dupe(u8, id) catch |e| {
_ = self.pending_input_overrides.remove(id);
self.alloc.free(value);
return e;
};
}
gop.value_ptr.* = value;
}
}
/// Fire a thinking/assistant lifecycle event for the entry backing a
/// streaming text block, by block index. `which` selects which `EntryKind`
/// variant to match. A no-op when no matching entry exists.
fn fireTextLifecycle(
self: *App,
index: usize,
comptime which: TextKind,
lc: ?Lifecycle,
name: []const u8,
payload: Payload,
) !void {
const entry = self.findTextEntry(index, which) orelse return;
_ = try self.fireForEntry(entry, lc, name, payload);
}
/// Locate the transcript entry for a streaming text block at `index`.
fn findTextEntry(self: *App, index: usize, comptime which: TextKind) ?*Entry {
const ref = self.router.get(index) orelse return null;
switch (which) {
.assistant => {
const target = switch (ref) {
.assistant => |p| p,
else => return null,
};
for (self.transcript.items) |*e| {
if (e.kind == .assistant and e.kind.assistant == target) return e;
}
},
.thinking => {
const target = switch (ref) {
.thinking => |p| p,
else => return null,
};
for (self.transcript.items) |*e| {
if (e.kind == .thinking and e.kind.thinking == target) return e;
}
},
}
return null;
}
/// Spawn a compaction-summary entry seeded with `summary`. Fires
/// `compaction`.
fn spawnCompaction(self: *App, summary: []const u8) !*CompactionSummary {
const box = try self.alloc.create(CompactionSummary);
box.* = CompactionSummary.init(self.alloc);
try box.setSummary(summary);
try self.pushEntryFired(
.{ .compaction = box },
.compaction,
"compaction",
.{ .compaction = .{ .summary = summary } },
);
return box;
}
/// Seed the transcript from a resumed `Conversation` (the `--resume` path).
/// Walks every stored message top-to-bottom and spawns the matching
/// transcript entry with its content set wholesale — the same components
/// the live event stream would have produced, but materialized in one shot
/// from history rather than streamed.
///
/// Tool results correlate back to their `ToolUse` component by
/// `tool_use_id` via the router's id map (populated by `spawnTool` +
/// `putToolId`), exactly like the live `tool_dispatch_complete` path. A
/// user-role message that carries ONLY `ToolResult` blocks is the
/// tool-output carrier, not a user bubble, so it feeds the matching tool
/// boxes instead of rendering as user text. `System` blocks are not part of
/// the visible transcript and are skipped.
///
/// Call once at startup, BEFORE the welcome banner / first paint, so the
/// engine's first frame already contains the full restored history.
pub fn seedFromConversation(self: *App, conv: *const panto.Conversation) !void {
for (conv.messages.items) |msg| {
for (msg.content.items) |block| {
switch (block) {
.Text => |tb| {
if (tb.items.len == 0) continue;
switch (msg.role) {
.assistant => {
const box = try self.spawnAssistant(0);
try box.setText(tb.items);
},
.user => try self.spawnUser(tb.items),
// System text never renders in the transcript.
.system => {},
}
},
.Thinking => |th| {
if (th.text.items.len == 0) continue;
const box = try self.spawnThinking(0);
try box.setText(th.text.items);
},
.ToolUse => |tu| {
const box = try self.spawnTool(0);
try box.setName(tu.name);
try box.setId(tu.id);
try box.setInput(tu.input.items);
try self.router.putToolId(tu.id, box);
},
.ToolResult => |tr| {
const box = self.router.getToolById(tr.tool_use_id) orelse continue;
var text: std.ArrayList(u8) = .empty;
defer text.deinit(self.alloc);
try tr.appendTextInto(self.alloc, &text);
try box.setOutput(text.items);
box.setResultOk(!tr.is_error);
},
.CompactionSummary => |cs| {
_ = try self.spawnCompaction(cs.text.items);
},
.System => {},
}
}
}
// The id map was a replay scratchpad; clear it so the first live turn
// starts clean (mirrors `beginTurn`).
self.router.reset();
}
/// Toggle the global tool-use collapse state (ctrl+o) and apply it to every
/// tool-use component in the transcript. No "active component": we iterate
/// the whole list and flip each one. Requests a render.
pub fn toggleToolCollapse(self: *App) void {
self.tools_collapsed = !self.tools_collapsed;
for (self.transcript.items) |*e| {
if (e.kind == .tool) {
const box = e.kind.tool;
box.setCollapsed(self.tools_collapsed);
_ = self.fireForEntry(e, null, "tool_collapse", .{ .tool = .{
.tool_name = if (box.name) |n| n.items else "",
.id = if (box.id) |id| id.items else "",
.input = box.input.items,
.output = if (box.output) |out| out.items else "",
.collapsed = self.tools_collapsed,
} }) catch {};
}
}
self.scheduler.requestRender();
}
/// The currently open selector overlay (model/reasoning picker), or null.
pub fn activeSelector(self: *App) ?*Selector {
const ctrl = self.selectors orelse return null;
return ctrl.active;
}
/// Install the runtime selector controller. Called once during real-
/// terminal bring-up; tests that don't exercise selectors leave it unset.
pub fn setSelectors(self: *App, ctrl: *SelectorController) void {
self.selectors = ctrl;
}
// -- the render pump ----------------------------------------------------
/// Render a frame if one is pending. Returns true if a frame was drawn.
pub fn maybeRender(self: *App) !bool {
const now = self.clock.now();
if (!self.scheduler.shouldRenderNow(now)) return false;
try self.engine.render();
self.flushSink();
self.scheduler.noteRendered(self.clock.now());
return true;
}
/// Force a render now (e.g. after a turn boundary or resize), bypassing
/// the coalescing window.
pub fn renderNow(self: *App) !void {
self.scheduler.requestRender();
try self.engine.render();
self.flushSink();
self.scheduler.noteRendered(self.clock.now());
}
// -- event routing ------------------------------------------------------
/// Route one libpanto `Event` to component state (plan §8). NEVER writes
/// to stdout; mutates components and requests a render. Keyed by block
/// index via `router` so there is no "active component" pointer.
pub fn routeEvent(self: *App, ev: Event) !void {
switch (ev) {
.message_start => {},
.block_start => |b| {
switch (b.block_type) {
.Text => {
const box = try self.spawnAssistant(b.index);
try self.router.put(b.index, .{ .assistant = box });
},
.Thinking => {
const box = try self.spawnThinking(b.index);
try self.router.put(b.index, .{ .thinking = box });
},
.ToolUse => {
// The name is unknown at start (streamed); the component
// renders `tool (?)…` until `tool_details` resolves it.
// The `tool` event fires NOW (creation boundary, name
// unknown); name-based claiming happens at the later
// `tool_details` event, which can swap again (§7.5).
const box = try self.spawnTool(b.index);
try self.router.put(b.index, .{ .tool = box });
},
.ToolResult => {},
}
self.scheduler.requestRender();
},
.tool_details => |d| {
if (self.router.get(d.index)) |ref| switch (ref) {
.tool => |box| {
// Set the name first, then fire `tool_details` (§7.5:
// the name-based claim point). A handler swap here takes
// over before the real content paints further.
try box.setName(d.name);
try box.setId(d.id);
try self.fireToolLifecycle(box, .tool_details, "tool_details", .{ .tool = .{
.index = d.index,
.tool_name = d.name,
.id = d.id,
} });
// Register the id -> component mapping so a later
// ToolResult (out-of-band, keyed by tool_use_id) finds
// this exact component.
try self.router.putToolId(d.id, box);
self.scheduler.requestRender();
},
else => {},
};
},
.content_delta => |d| {
if (self.router.get(d.index)) |ref| switch (ref) {
.assistant => |box| {
try box.appendDelta(d.delta);
// Fire `assistant_text_delta` at the SAME boundary the
// component re-renders (no new render cadence).
try self.fireTextLifecycle(d.index, .assistant, null, "assistant_text_delta", .{ .assistant_text = .{
.index = d.index,
.delta = d.delta,
.text = box.buffer.items,
} });
self.scheduler.requestRender();
},
.thinking => |box| {
try box.appendDelta(d.delta);
try self.fireTextLifecycle(d.index, .thinking, null, "thinking_delta", .{ .thinking = .{
.index = d.index,
.delta = d.delta,
.text = box.buffer.items,
} });
self.scheduler.requestRender();
},
.tool => |box| {
// Tool args stream as deltas — they ARE the verbatim
// JSON input. Accumulate them into the component, then
// fire `tool_delta` (repeatable; no fire-once guard).
try box.appendInput(d.delta);
try self.fireToolLifecycle(box, null, "tool_delta", .{ .tool = .{
.index = d.index,
.tool_name = if (box.name) |n| n.items else "",
.delta = d.delta,
.input = box.input.items,
} });
self.scheduler.requestRender();
},
};
},
.block_complete => |b| {
switch (b.block) {
.Text => {
if (self.router.get(b.index)) |ref| switch (ref) {
.assistant => |box| box.finishStream(),
else => {},
};
try self.fireTextLifecycle(b.index, .assistant, .assistant_text_complete, "assistant_text_complete", .{ .assistant_text = .{
.index = b.index,
.text = if (self.router.get(b.index)) |r| (if (r == .assistant) r.assistant.buffer.items else "") else "",
} });
self.scheduler.requestRender();
},
.Thinking => {
try self.fireTextLifecycle(b.index, .thinking, .thinking_complete, "thinking_complete", .{ .thinking = .{
.index = b.index,
.text = if (self.router.get(b.index)) |r| (if (r == .thinking) r.thinking.buffer.items else "") else "",
} });
self.scheduler.requestRender();
},
.ToolUse => |tu| {
if (self.router.get(b.index)) |ref| switch (ref) {
.tool => |box| {
// Final authoritative name + input from the
// completed block (covers the case where
// tool_details never fired and replaces any
// partial streamed args with the final bytes).
try box.setName(tu.name);
try box.setId(tu.id);
try box.setInput(tu.input.items);
try self.router.putToolId(tu.id, box);
// Fire `tool_call_complete` (end of the CALL;
// the result arrives later as `tool_result`).
try self.fireToolLifecycle(box, .tool_call_complete, "tool_call_complete", .{ .tool = .{
.index = b.index,
.tool_name = tu.name,
.id = tu.id,
.input = tu.input.items,
} });
self.scheduler.requestRender();
},
else => {},
};
},
.CompactionSummary => |cs| {
_ = try self.spawnCompaction(cs.text.items);
self.scheduler.requestRender();
},
else => {},
}
},
.message_complete => |mc| {
// Update the footer's context-window token count with the
// LATEST usage (plan §6): input + cache_read + cache_write
// (output/reasoning excluded — not "in the window"). Latest
// value wins; not accumulated.
if (mc.usage) |u| {
const ctx = u.input + u.cache_read + u.cache_write;
self.footer.setContextTokens(ctx);
// Hand the raw usage to the recorder (the
// `SelectorController` is the canonical recorder; it
// knows which `(provider, model)` produced this turn
// and accumulates per-model token + cost totals).
if (self.usage_record_fn) |f| f(self.usage_record_ctx.?, u);
self.scheduler.requestRender();
}
},
.provider_retry => |info| {
if (info.compaction) {
_ = try self.spawnStatus("context overflow: compacting and retrying");
} else {
const secs = @as(f64, @floatFromInt(info.delay_ms)) / 1000.0;
// Prefer the provider's own diagnostic (e.g. an Anthropic
// `overloaded_error: ...`) over the bare error name, so
// the user sees *why* the turn stalled.
const reason = info.message orelse @errorName(info.err);
const msg = try std.fmt.allocPrint(
self.alloc,
"provider unavailable ({s}): retrying in {d:.1}s (attempt {d}/{d})",
.{ reason, secs, info.attempt + 1, info.max_attempts },
);
defer self.alloc.free(msg);
_ = try self.spawnStatus(msg);
}
self.scheduler.requestRender();
},
.tool_dispatch_result => |info| {
// Eager per-tool result carrier. Correlate by tool_use_id just
// like the aggregate completion event.
try self.routeToolResults(info.message);
},
.tool_dispatch_complete => |info| {
// ToolResult blocks are delivered together here as the content
// of the appended user message. Correlate each back to its
// ToolUse component by tool_use_id and feed it the result text.
try self.routeToolResults(info.message);
},
.tool_dispatch_start, .turn_complete => {},
}
}
/// Walk a tool-dispatch-complete user message and feed each `ToolResult`
/// block's text to the `ToolUse` component that issued the matching call
/// (looked up by `tool_use_id`). Honors the no-active-component invariant:
/// the correlation is purely by id.
fn routeToolResults(self: *App, message: panto.Message) !void {
var any = false;
for (message.content.items) |block| {
switch (block) {
.ToolResult => |tr| {
const box = self.router.getToolById(tr.tool_use_id) orelse continue;
// Concatenate the textual parts of the result.
var text: std.ArrayList(u8) = .empty;
defer text.deinit(self.alloc);
try tr.appendTextInto(self.alloc, &text);
try box.setOutput(text.items);
box.setResultOk(!tr.is_error);
// Fire `tool_result` — the atomic result landed. This is the
// terminal tool-lifecycle event (after `tool_call_complete`).
try self.fireToolLifecycle(box, .tool_result, "tool_result", .{ .tool = .{
.tool_name = if (box.name) |n| n.items else "",
.id = tr.tool_use_id,
.input = box.input.items,
.output = text.items,
} });
any = true;
},
else => {},
}
}
if (any) self.scheduler.requestRender();
}
/// Reset per-turn routing state. The transcript entries persist (they are
/// the chat history); only the block-index map is cleared.
pub fn beginTurn(self: *App) void {
self.router.reset();
// Defensive: an interrupted turn may have staged overrides it never
// applied; they must not leak into this turn's calls.
self.clearPendingOverrides();
}
/// Surface a turn error as a dim status line in the transcript.
pub fn routeError(self: *App, err: anyerror) !void {
const msg = try std.fmt.allocPrint(self.alloc, "[error: {s}]", .{@errorName(err)});
defer self.alloc.free(msg);
_ = try self.spawnStatus(msg);
self.scheduler.requestRender();
}
};
// ===========================================================================
// SelectorController — runtime model / reasoning pickers
// ===========================================================================
/// Owns the runtime model/reasoning picker overlays and the LIVE provider
/// config. A pick rebuilds a `panto.ProviderConfig`, stamps it into the
/// owned `panto.Config`, and pushes it to the agent via `setConfig` (effective
/// next turn). Nothing is persisted to `config.toml`.
///
/// Lifetime: borrows the long-lived `config_file.Config`, `models.toml`
/// `ModelRegistry`, and `panto.Agent` (all owned by `main`). The model labels
/// and provider/model wire strings the rebuilt config borrows therefore stay
/// valid for the whole session.
const ModelPickerRow = struct {
def_index: usize,
item: SelectorItem,
};
fn modelPickerRowLessThan(_: void, a: ModelPickerRow, b: ModelPickerRow) bool {
const by_label = std.mem.order(u8, a.item.label, b.item.label);
if (by_label != .eq) return by_label == .lt;
return std.mem.lessThan(u8, a.item.detail, b.item.detail);
}
fn addTokenBucket(
alloc: std.mem.Allocator,
buckets: *std.StringHashMapUnmanaged(u64),
key: []const u8,
amount: u64,
) void {
const gop = buckets.getOrPut(alloc, key) catch return;
if (!gop.found_existing) {
gop.key_ptr.* = alloc.dupe(u8, key) catch return;
gop.value_ptr.* = 0;
}
gop.value_ptr.* +%= amount;
}
pub const SelectorController = struct {
alloc: std.mem.Allocator,
app: *App,
agent: *panto.Agent,
/// The app config (providers + defaults). Borrowed; supplies provider
/// transport/auth and per-alias knobs lookups via `buildProviderConfig`.
file_cfg: *const config_file.Config,
defs: *const models_toml.ModelRegistry,
/// Per-(provider, wire-model) pricing table for the session. Borrowed
/// (the merged `models_toml.Models` outlives the controller). The
/// model-picker detail line and the footer session cost both look up
/// here; the controller does NOT mutate it.
pricing: *const panto.PricingRegistry,
/// The live agent config snapshot, owned here. `agent` holds a pointer to
/// it; we mutate `provider` in place and re-`setConfig` so the change is
/// observed at the next turn.
live: *panto.Config,
/// Built selector item list for the model picker (label = "provider:alias",
/// detail = "<wire> <knobs>"). Owned: labels/details are allocated here.
model_items: []SelectorItem,
/// Parallel to `model_items`: source registry index for each SORTED row.
model_entry_indices: []usize = &.{},
/// Backing storage for the model item label/detail strings.
model_strings: std.ArrayList([]u8) = .empty,
/// Reasoning picker items for the CURRENTLY OPEN reasoning picker, rebuilt
/// from the active provider's option list each time it opens. The labels/
/// details are static (borrowed from `ReasoningOption`), so this slice only
/// owns the `SelectorItem` headers. Empty when no reasoning picker is open.
reasoning_items: []SelectorItem = &.{},
/// The provider-style option list backing `reasoning_items` (parallel to
/// it; consulted on accept to apply the chosen option).
reasoning_opts: []const selectors_mod.ReasoningOption = &.{},
/// The currently open picker, if any (owned; freed on close).
active: ?*Selector = null,
/// Which picker is open (so `apply` knows how to interpret the pick).
kind: enum { none, model, reasoning } = .none,
/// The current model label ("provider:alias"), for the footer/preselect.
model_label: []u8,
/// Session-running token totals, keyed by the `(provider, model)`
/// pair that produced them. A model switch in the middle of a
/// session just appends a new bucket; the SUM is what the footer
/// displays. Each entry is owned here and is the integer sum of
/// `input + output + cache_read + cache_write` for the turns
/// produced by that model.
///
/// WHY PER-MODEL: the user can switch models mid-session; a
/// flat u64 would conflate two models' tokens. Per-model lets us
/// re-pricing: if the user pastes a corrected `models.toml` mid-
/// session we just rebuild the cost (the next addCost call picks
/// up the new pricing), and the token sum is the *same* flat
/// total regardless of pricing changes.
session_token_buckets: std.StringHashMapUnmanaged(u64) = .empty,
/// Session-running cost total in micro-cents. `null` means "at
/// least one priced component of at least one turn was unknown"
/// (the `addCost` poison rule). Set to 0 for the first
/// ALL-ZERO turn and stays known thereafter; any later
/// `costMicroCents == null` re-poisons.
session_cost: ?u64 = 0,
pub fn init(
alloc: std.mem.Allocator,
app: *App,
agent: *panto.Agent,
file_cfg: *const config_file.Config,
defs: *const models_toml.ModelRegistry,
pricing: *const panto.PricingRegistry,
live: *panto.Config,
initial_label: []const u8,
) !*SelectorController {
const self = try alloc.create(SelectorController);
errdefer alloc.destroy(self);
self.* = .{
.alloc = alloc,
.app = app,
.agent = agent,
.file_cfg = file_cfg,
.defs = defs,
.pricing = pricing,
.live = live,
.model_items = &.{},
.model_label = try alloc.dupe(u8, initial_label),
};
try self.buildModelItems();
try self.refreshFooter();
// Install ourselves as the App's per-turn usage recorder so every
// `message_complete` lands in our session-running buckets and the
// footer session cost/token total is updated.
app.setUsageRecorder(self, recordUsageThunk);
return self;
}
/// Recompose the footer label as "provider:alias (reasoning)" from the
/// live model label and the active reasoning option.
fn refreshFooter(self: *SelectorController) !void {
const reasoning = selectors_mod.ReasoningOption.activeLabel(self.live.provider);
const label = if (reasoning.len != 0)
try std.fmt.allocPrint(self.alloc, "{s} ({s})", .{ self.model_label, reasoning })
else
try self.alloc.dupe(u8, self.model_label);
defer self.alloc.free(label);
try self.app.footer.setModel(label);
}
pub fn deinit(self: *SelectorController) void {
if (self.active) |sel| {
sel.deinit();
self.alloc.destroy(sel);
}
for (self.model_strings.items) |s| self.alloc.free(s);
self.model_strings.deinit(self.alloc);
self.alloc.free(self.model_items);
self.alloc.free(self.model_entry_indices);
self.alloc.free(self.reasoning_items);
self.alloc.free(self.model_label);
// The session token buckets own the (provider, model) key strings.
var it = self.session_token_buckets.iterator();
while (it.next()) |entry| self.alloc.free(entry.key_ptr.*);
self.session_token_buckets.deinit(self.alloc);
self.alloc.destroy(self);
}
/// Build the model picker items from every `models.toml` definition.
fn buildModelItems(self: *SelectorController) !void {
const a = self.alloc;
var rows: std.ArrayList(ModelPickerRow) = .empty;
defer rows.deinit(a);
for (self.defs.entries.items, 0..) |d, def_index| {
const label = try std.fmt.allocPrint(a, "{s}:{s}", .{ d.provider, d.alias });
try self.model_strings.append(a, label);
const detail = try formatModelDetail(a, d, self.pricing);
try self.model_strings.append(a, detail);
try rows.append(a, .{ .def_index = def_index, .item = .{ .label = label, .detail = detail } });
}
std.mem.sort(ModelPickerRow, rows.items, {}, modelPickerRowLessThan);
self.model_items = try a.alloc(SelectorItem, rows.items.len);
self.model_entry_indices = try a.alloc(usize, rows.items.len);
for (rows.items, 0..) |row, i| {
self.model_items[i] = row.item;
self.model_entry_indices[i] = row.def_index;
}
}
/// Open the model picker, preselecting the current model.
pub fn openModel(self: *SelectorController) !void {
try self.open(.model, "select model", self.model_items, self.model_label);
}
/// Open the reasoning picker, built from the ACTIVE provider's option list
/// (so every level it supports is reachable), preselecting the live value.
pub fn openReasoning(self: *SelectorController) !void {
const opts = selectors_mod.ReasoningOption.forStyle(self.live.provider.style());
// Rebuild the parallel SelectorItem headers (labels/details are static).
self.alloc.free(self.reasoning_items);
self.reasoning_items = try self.alloc.alloc(SelectorItem, opts.len);
for (opts, 0..) |opt, i| {
self.reasoning_items[i] = .{ .label = opt.label, .detail = opt.detail };
}
self.reasoning_opts = opts;
const active = selectors_mod.ReasoningOption.activeLabel(self.live.provider);
try self.open(.reasoning, "select reasoning", self.reasoning_items, active);
}
fn open(
self: *SelectorController,
kind: @TypeOf(self.kind),
title: []const u8,
items: []const SelectorItem,
preselect: []const u8,
) !void {
self.close(); // a second hotkey replaces any open picker
const sel = try self.alloc.create(Selector);
errdefer self.alloc.destroy(sel);
sel.* = Selector.init(self.alloc, title, items);
try sel.selectLabel(preselect);
self.active = sel;
self.kind = kind;
try self.app.rebuildEngineList();
self.app.scheduler.requestRender();
}
/// Close (dismiss) any open picker without applying.
pub fn close(self: *SelectorController) void {
if (self.active) |sel| {
sel.deinit();
self.alloc.destroy(sel);
self.active = null;
}
self.kind = .none;
}
/// Route one decoded key to the open picker. Returns true if the key was
/// consumed by a picker (so the caller should not feed it to the input box).
pub fn handleKey(self: *SelectorController, k: tui_key.Key) !bool {
const sel = self.active orelse return false;
const action = try sel.applyKey(k);
switch (action) {
.none => {},
.cancel => self.dismissAndRebuild(),
.accept => {
const idx = sel.selectedIndex();
const which = self.kind;
self.dismissAndRebuild();
if (idx) |i| try self.apply(which, i);
},
}
self.app.scheduler.requestRender();
return true;
}
fn dismissAndRebuild(self: *SelectorController) void {
self.close();
self.app.rebuildEngineList() catch {};
}
/// Apply a pick: rebuild and install the live config.
fn apply(self: *SelectorController, which: @TypeOf(self.kind), index: usize) !void {
switch (which) {
.model => try self.applyModel(index),
.reasoning => try self.applyReasoning(index),
.none => {},
}
}
fn applyModel(self: *SelectorController, index: usize) !void {
if (index >= self.model_entry_indices.len) return;
const d = self.defs.entries.items[self.model_entry_indices[index]];
const ref: config_file.ModelRef = .{ .provider = d.provider, .model = d.alias };
const new_provider = config_file.buildProviderConfig(self.file_cfg, self.defs, ref) catch |err| {
try self.statusf("[model switch failed: {s}]", .{@errorName(err)});
return;
};
self.live.provider = new_provider;
// The freshly built provider carries the model's declared reasoning
// knobs from models.toml; we keep those (a model switch resets to the
// new model's defaults rather than forcing the prior model's effort,
// which may not even exist on the other API style).
self.agent.setConfig(self.live);
// Update the label ("provider:alias") and footer.
const label = try std.fmt.allocPrint(self.alloc, "{s}:{s}", .{ d.provider, d.alias });
self.alloc.free(self.model_label);
self.model_label = label;
try self.refreshFooter();
try self.statusf("[model -> {s}]", .{label});
}
fn applyReasoning(self: *SelectorController, index: usize) !void {
if (index >= self.reasoning_opts.len) return;
const opt = self.reasoning_opts[index];
opt.apply(&self.live.provider);
self.agent.setConfig(self.live);
try self.refreshFooter();
try self.statusf("[reasoning -> {s}]", .{opt.label});
}
fn statusf(self: *SelectorController, comptime fmt: []const u8, args: anytype) !void {
const msg = try std.fmt.allocPrint(self.alloc, fmt, args);
defer self.alloc.free(msg);
_ = self.app.spawnStatus(msg) catch {};
}
/// Record one turn's `usage` against the current `(provider, model)`,
/// then push the new session totals into the footer. Model-switch
/// tolerance: each `(provider, model)` is a separate bucket for
/// the per-model token sum; the FOOTER displays the flat sum
/// across all buckets, so a switch in the middle of a session is
/// invisible to the user (it just makes the next turn's tokens
/// land in a new bucket). For cost, the same `addCost` accumulator
/// is used; the per-turn cost is computed against THIS turn's
/// `(provider, model)` pricing, so a model with known pricing
/// before an unpriced model after still produces a known total
/// only up to the switch.
fn recordUsage(self: *SelectorController, usage: panto.Usage) void {
// Resolve the (provider, wire-model) pair for THIS turn. The
// pricing registry is keyed on these strings; the bucket
// string is "<provider>:<wire>" so a model name that happens
// to be reused across providers doesn't collide.
const colon = std.mem.indexOfScalar(u8, self.model_label, ':') orelse {
// A malformed label (no colon) is a programmer error in
// the boot sequence; just bail. The App still shows the
// per-turn context-window tokens.
return;
};
const provider_name = self.model_label[0..colon];
const wire_model = selectors_mod.wireModel(self.live.provider);
const turn_tokens = usage.input + usage.output + usage.cache_read + usage.cache_write;
const key = std.fmt.allocPrint(self.alloc, "{s}:{s}", .{ provider_name, wire_model }) catch return;
defer self.alloc.free(key);
addTokenBucket(self.alloc, &self.session_token_buckets, key, turn_tokens);
// Recompute the flat session sum. Cheap: the bucket count is
// tiny (the user doesn't switch models a thousand times).
var total: u64 = 0;
var it = self.session_token_buckets.iterator();
while (it.next()) |entry| total +%= entry.value_ptr.*;
self.app.footer.setSessionTokens(total);
// Cost: look up the pricing for the model that JUST produced
// this usage. If any priced component is null (no models.toml
// entry for this model), this turn's cost is null and
// `addCost` poisons the session total to null — once poisoned,
// it stays null for the rest of the session (no way to
// "un-poison" a `?u64` back to a known value).
//
// An empty (default-constructed) Pricing has every field null;
// `costMicroCents` treats every nonzero token usage as
// "unknown" and returns null. That's the same as the
// no-pricing case, so a single code path covers both.
const turn_cost: ?u64 = if (self.pricing.get(provider_name, wire_model)) |pricing|
panto.costMicroCents(usage, pricing)
else
null;
self.session_cost = panto.addCost(self.session_cost, turn_cost);
self.app.footer.setSessionCost(self.session_cost);
}
/// Static thunk for the App's `setUsageRecorder` callback. The
/// `ctx` we install with is the `*SelectorController` itself; the
/// thunk casts it back and dispatches to the typed method.
fn recordUsageThunk(ctx: *anyopaque, usage: panto.Usage) void {
const self: *SelectorController = @ptrCast(@alignCast(ctx));
self.recordUsage(usage);
}
};
/// Format the dim detail string for a model item: the wire model id,
/// the reasoning/thinking knobs declared in `models.toml`, and the
/// pricing (when known). The pricing is looked up by the wire model
/// id against the parsed `PricingRegistry` (the same keying the
/// session cost accumulator uses); it surfaces as a compact
/// `"1i/5o/0.1r/1.25w"` suffix on the same line so the picker row
/// is a one-glance summary.
///
/// Anthropic-style entries advertise thinking/effort; openai-style
/// ones advertise reasoning. We don't know the provider's API style
/// here, so we show whatever knobs are non-default. Pricing is
/// appended last so the most-novel information lands closest to the
/// model label and the older knob info reads as supporting context.
fn formatModelDetail(
alloc: std.mem.Allocator,
d: models_toml.ModelDef,
pricing: *const panto.PricingRegistry,
) ![]u8 {
var buf: std.ArrayList(u8) = .empty;
errdefer buf.deinit(alloc);
try buf.appendSlice(alloc, d.model);
if (d.thinking != .disabled) {
try buf.appendSlice(alloc, " thinking:");
try buf.appendSlice(alloc, @tagName(d.thinking));
if (d.thinking == .adaptive) {
try buf.appendSlice(alloc, " effort:");
try buf.appendSlice(alloc, @tagName(d.effort));
}
} else if (d.reasoning != .default) {
try buf.appendSlice(alloc, " reasoning:");
try buf.appendSlice(alloc, @tagName(d.reasoning));
}
if (pricing.get(d.provider, d.model)) |p| {
var scratch: [64]u8 = undefined;
if (pricing_format.formatPriceCompact(p, &scratch)) |tag| {
try buf.appendSlice(alloc, " ");
try buf.appendSlice(alloc, tag);
}
}
return buf.toOwnedSlice(alloc);
}
// ===========================================================================
// TurnRouter — block-index -> component map (no "active component")
// ===========================================================================
/// A reference to the transcript component a libpanto block is streaming into.
/// Keyed by block index in `TurnRouter`. This is the structure that makes the
/// loop parallel-tool-call ready: each block index has its own sink, so there
/// is never a single mutable "current" component.
pub const BlockRef = union(enum) {
assistant: *AssistantText,
/// Streaming thinking block.
thinking: *Thinking,
/// Tool-use block (drives its own ToolUse component).
tool: *ToolUse,
};
/// Block-index -> component routing, plus a SECOND map from tool-call id ->
/// the owning `ToolUse` component. The id map is what correlates a later
/// `ToolResult` (delivered out-of-band in `tool_dispatch_complete`, keyed by
/// `tool_use_id`) back to the component that issued the call — without any
/// "active component" (plan §6).
///
/// The id map borrows transcript-owned `*ToolUse` pointers; both maps are
/// cleared at each turn boundary (the transcript entries themselves persist as
/// history). String keys are duped into an arena so they outlive the borrowed
/// libpanto event slices.
pub const TurnRouter = struct {
map: std.AutoHashMap(usize, BlockRef),
tool_by_id: std.StringHashMap(*ToolUse),
id_arena: std.heap.ArenaAllocator,
pub fn init(alloc: std.mem.Allocator) TurnRouter {
return .{
.map = std.AutoHashMap(usize, BlockRef).init(alloc),
.tool_by_id = std.StringHashMap(*ToolUse).init(alloc),
.id_arena = std.heap.ArenaAllocator.init(alloc),
};
}
pub fn deinit(self: *TurnRouter) void {
self.map.deinit();
self.tool_by_id.deinit();
self.id_arena.deinit();
}
pub fn reset(self: *TurnRouter) void {
self.map.clearRetainingCapacity();
self.tool_by_id.clearRetainingCapacity();
_ = self.id_arena.reset(.retain_capacity);
}
pub fn put(self: *TurnRouter, index: usize, ref: BlockRef) !void {
try self.map.put(index, ref);
}
pub fn get(self: *TurnRouter, index: usize) ?BlockRef {
return self.map.get(index);
}
/// Register a tool-call id -> its `ToolUse` component for result
/// correlation. The id is duped into the router arena (the libpanto slice
/// is borrowed and transient).
pub fn putToolId(self: *TurnRouter, id: []const u8, box: *ToolUse) !void {
const key = try self.id_arena.allocator().dupe(u8, id);
try self.tool_by_id.put(key, box);
}
/// Look up the `ToolUse` component that issued the call with this id.
pub fn getToolById(self: *TurnRouter, id: []const u8) ?*ToolUse {
return self.tool_by_id.get(id);
}
};
// ===========================================================================
// Driving the loop (real terminal)
// ===========================================================================
/// Inputs the loop needs from `main.zig` (kept as a struct so the wiring stays
/// a single call). The agent, command registry, and command context are
/// borrowed for the loop's lifetime.
pub const RunOptions = struct {
agent: *panto.Agent,
cmd_registry: *const command.Registry,
cmd_ctx: *command.Context,
/// In-memory writer that command handlers write to (their `stdout`). After
/// each dispatch the captured text is flushed into the transcript as a dim
/// status line, then cleared. See `runLoop` for the rationale.
cmd_capture: *std.Io.Writer.Allocating,
model_label: []const u8,
/// Working directory shown in the welcome banner. Borrowed for the loop.
cwd: []const u8,
/// panto version string for the welcome banner (empty = omit).
version: []const u8 = "",
/// The std.Io used to spawn `$EDITOR` for the Ctrl+G round-trip.
io: std.Io,
/// Process environment, used to resolve `$EDITOR` (and `$VISUAL`) for the
/// Ctrl+G round-trip. Borrowed for the loop's lifetime.
environ: *const std.process.Environ.Map,
/// Optional auth manager. When set, the active provider's named auth
/// session is resolved (refresh/exchange, or interactive device login)
/// before each turn, and re-resolved with a forced refresh once on a
/// provider auth failure. Null disables turn-time auth resolution.
auth_mgr: ?*auth_manager.AuthManager = null,
};
/// Run the interactive chat loop against a real terminal until EOF / Ctrl+D /
/// Ctrl+C. Restores the terminal on every exit path (the `Terminal` installs
/// signal + the caller installs panic restore).
///
/// Loop shape (single-threaded, poll-based):
/// 1. Render any pending frame (feeding the footer the frame time).
/// 2. Poll the tty for input with a short timeout (so coalesced renders and
/// SIGWINCH are serviced promptly even with no keypress).
/// 3. Decode buffered bytes -> keys -> the focused input box.
/// 4. On a submitted line: drive a turn (or dispatch a slash command),
/// pumping the stream's events into component state.
/// Keyboard-protocol handshake state for one session. Resolved from the
/// terminal's replies to the startup `negotiate_query`.
const Handshake = struct {
/// Kitty keyboard protocol confirmed active (non-zero flags reply seen).
kitty: bool = false,
/// We enabled the modifyOtherKeys fallback (must reset it on teardown).
mok_enabled: bool = false,
/// Handshake has resolved (a DA sentinel reply was seen).
resolved: bool = false,
};
pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void {
var hs: Handshake = .{};
// Start the keyboard-protocol handshake: enable bracketed paste, push the
// Kitty flags we want, then query (Kitty flags + DA sentinel). The replies
// are consumed in `handleBytes`, which enables the modifyOtherKeys fallback
// iff the terminal turns out not to support Kitty.
term.writeAll(input_mod.negotiate_query);
defer {
app.engine.finalizeCursor() catch {};
app.flushSink();
}
defer {
input_mod.setKittyActive(false);
if (hs.mok_enabled) term.writeAll(input_mod.disable_modify_other_keys);
term.writeAll(input_mod.negotiate_teardown);
}
term.hideCursor();
defer term.showCursor();
// The footer's model label: when the selector controller is installed it
// already composed "model (reasoning)" during install, so don't clobber
// that here. Otherwise seed the plain model label.
if (app.selectors == null) try app.footer.setModel(opts.model_label);
// Session-start welcome banner as the first transcript entry.
{
const welcome = try app.spawnWelcome(.{
.version = opts.version,
.cwd = opts.cwd,
.model = opts.model_label,
});
if (opts.cwd.len != 0) try welcome.setCwd(opts.cwd);
if (opts.version.len != 0) try welcome.setVersion(opts.version);
}
// On `--resume`, materialize the restored conversation into transcript
// components below the banner, so the first paint shows the full history.
try app.seedFromConversation(&opts.agent.conversation);
app.input_box.setFocused(true);
try app.rebuildEngineList();
try app.renderNow();
var read_buf: [4096]u8 = undefined;
// Retained partial-sequence tail across reads (a CSI/UTF-8 split across
// read() boundaries).
var tail: std.ArrayList(u8) = .empty;
defer tail.deinit(app.alloc);
while (true) {
// 1. Service a pending coalesced frame.
_ = try app.maybeRender();
// 1b. SIGWINCH -> resize -> full redraw.
if (term.takeResized()) {
const size = term.refreshSize();
app.engine.resize(size.cols, size.rows);
try app.renderNow();
}
// 2. Poll for input (short timeout so renders/resize stay responsive).
const ready = pollReadable(term.fd, 16) catch true;
if (!ready) {
// ESC-timeout: a lone ESC byte sitting in the tail is ambiguous
// (it could begin a CSI/SS3 sequence), so the decoder defers it
// until more bytes arrive. When the poll times out with nothing
// more, commit it as a real Escape keypress instead of waiting for
// the next keystroke to disambiguate it (which made Escape feel
// like it took two presses to register).
if (tail.items.len == 1 and tail.items[0] == 0x1b) {
tail.items.len = 0; // consume the lone ESC either way
// A modal picker is the only consumer of Escape; closing it is
// the behavior that matters. The input box ignores Escape, so
// with no picker open we simply drop the byte.
if (app.selectors) |ctrl| {
if (ctrl.active != null) {
_ = try ctrl.handleKey(.{ .code = .escape });
_ = try app.maybeRender();
}
}
}
continue;
}
const n = posix.read(term.fd, &read_buf) catch |err| switch (err) {
error.WouldBlock => continue,
else => return,
};
if (n == 0) break; // EOF (Ctrl+D on an empty line closes the tty)
// 3. Decode. Prepend any retained tail, decode all complete sequences,
// retain the unconsumed tail for the next read.
try tail.appendSlice(app.alloc, read_buf[0..n]);
const consumed = try handleBytes(app, term, &hs, tail.items, opts);
// Keep the unconsumed tail.
const leftover = tail.items.len - consumed;
std.mem.copyForwards(u8, tail.items[0..leftover], tail.items[consumed..]);
tail.items.len = leftover;
// 4. A frame may now be pending (input edited the box / a turn ran).
_ = try app.maybeRender();
}
}
/// Decode `bytes` into keys, route control keys (Ctrl+C/Ctrl+D) at the app
/// level, feed the rest to the focused input box, and act on any submitted
/// line. Returns the number of bytes consumed (the unconsumed partial tail is
/// retained by the caller).
fn handleBytes(app: *App, term: *Terminal, hs: *Handshake, bytes: []const u8, opts: RunOptions) !usize {
var off: usize = 0;
while (off < bytes.len) {
const step = input_mod.decodeOne(bytes[off..]) orelse break; // partial tail
switch (step.decoded) {
.key => |k| {
// An open selector overlay is MODAL: it consumes every key
// (typeahead filter + navigation + accept/cancel) before the
// app-level chords or the input box see it. Ctrl+C/Ctrl+D still
// fall through to exit, as a safety hatch.
if (app.selectors) |ctrl| {
if (ctrl.active != null and !(k.isCtrl('c') or k.isCtrl('d'))) {
_ = try ctrl.handleKey(k);
off += step.consumed;
continue;
}
}
// App-level control keys.
if (k.isCtrl('c') or k.isCtrl('d')) {
// Clean exit: restore handled by deferred teardown + the
// terminal's deinit in main. Signal EOF by closing the loop.
return error.UserExit;
}
if (k.isCtrl('m')) {
// Open the model selector (live session only). Consume.
if (app.selectors) |ctrl| ctrl.openModel() catch {};
off += step.consumed;
continue;
}
if (k.isCtrl('r')) {
// Open the reasoning/effort selector. Consume.
if (app.selectors) |ctrl| ctrl.openReasoning() catch {};
off += step.consumed;
continue;
}
if (k.isCtrl('o')) {
// Global collapse/expand of all tool-use components. Consume
// the key (do NOT feed it to the input box) and request a
// render.
app.toggleToolCollapse();
off += step.consumed;
continue;
}
if (k.isCtrl('g')) {
// Punt the editor buffer out to $EDITOR (markdown tempfile),
// then read it back. Consume the key; never feed it to the
// box.
editInExternalEditor(app, term, opts.io, opts.environ) catch |err| {
if (std.fmt.allocPrint(app.alloc, "[$EDITOR failed: {s}]", .{@errorName(err)})) |msg| {
defer app.alloc.free(msg);
_ = app.spawnStatus(msg) catch {};
} else |_| {
_ = app.spawnStatus("[$EDITOR failed]") catch {};
}
};
off += step.consumed;
continue;
}
// Feed the key to the focused input box.
app.input_box.comp().handleInput(bytes[off .. off + step.consumed]);
},
.paste => {
app.input_box.comp().handleInput(bytes[off .. off + step.consumed]);
},
.negotiation => |neg| {
handleNegotiation(term, hs, neg);
off += step.consumed;
continue; // not a keypress; no render / submit check
},
}
off += step.consumed;
app.scheduler.requestRender();
// Did the box submit a line?
if (app.input_box.takeSubmitted()) |line_borrowed| {
// Copy: the box may reuse its buffer.
const line = try app.alloc.dupe(u8, line_borrowed);
defer app.alloc.free(line);
try handleSubmittedLine(app, term, line, opts);
}
}
return off;
}
/// React to a keyboard-protocol negotiation reply from the terminal.
///
/// - A non-zero Kitty flags reply confirms the Kitty protocol: mark it active
/// and do NOT enable modifyOtherKeys (they can conflict).
/// - The DA reply is the handshake sentinel: it always arrives. If we reach it
/// without having confirmed Kitty, the terminal lacks Kitty support, so we
/// enable the modifyOtherKeys fallback (e.g. for tmux/xterm).
fn handleNegotiation(term: *Terminal, hs: *Handshake, neg: input_mod.Negotiation) void {
switch (neg) {
.kitty_flags => |flags| {
if (flags != 0 and !hs.kitty) {
hs.kitty = true;
input_mod.setKittyActive(true);
term.caps.kitty_keyboard = true;
}
},
.device_attributes => {
if (hs.resolved) return;
hs.resolved = true;
if (!hs.kitty and !hs.mok_enabled) {
term.writeAll(input_mod.enable_modify_other_keys);
hs.mok_enabled = true;
term.caps.kitty_keyboard = false;
}
},
}
}
/// Punt the input box's buffer to the user's `$EDITOR` (Ctrl+G), then read it
/// back. Mirrors pi's editor escape hatch.
///
/// Flow: write the buffer to a `.md` tempfile -> drop the terminal to cooked
/// mode + show the cursor -> spawn `$EDITOR <file>` inheriting our stdio and
/// wait -> re-enter raw mode + hide the cursor -> read the file back into the
/// box (trimming a single trailing newline most editors add) -> delete the
/// tempfile -> force a full engine redraw (the child scribbled all over the
/// screen, so the differential baseline is stale).
///
/// The terminal's signal/panic restore record stays armed with the ORIGINAL
/// (cooked) termios throughout (`suspendRawMode` does not clear it), so a crash
/// or signal while the editor is open still leaves a sane terminal. We re-enter
/// raw mode on every return path via `defer`.
fn editInExternalEditor(
app: *App,
term: *Terminal,
io: std.Io,
environ: *const std.process.Environ.Map,
) !void {
const editor = environ.get("VISUAL") orelse environ.get("EDITOR") orelse "vi";
// Build a tempfile path: $TMPDIR (or /tmp) + a pid/nanotime-unique name.
const tmp_dir = environ.get("TMPDIR") orelse "/tmp";
const pid = std.c.getpid();
const nanos = std.Io.Clock.now(.awake, io).nanoseconds;
const path = try std.fmt.allocPrint(app.alloc, "{s}/panto-edit-{d}-{d}.md", .{
std.mem.trimEnd(u8, tmp_dir, "/"),
pid,
nanos,
});
defer app.alloc.free(path);
// Write the current buffer out.
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = app.input_box.buffer() });
defer std.Io.Dir.cwd().deleteFile(io, path) catch {};
// Split `$EDITOR` on spaces so commands with flags (e.g. "code -w") work,
// then append the file path as the final argv entry.
var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(app.alloc);
try splitEditorArgv(app.alloc, editor, path, &argv);
// Drop to cooked mode for the child; always re-enter raw mode + force a
// full redraw afterward.
term.suspendRawMode();
app.flushSink();
defer {
term.resumeRawMode() catch {};
app.engine.forceFullRedraw();
app.renderNow() catch {};
}
var child = try std.process.spawn(io, .{
.argv = argv.items,
.stdin = .inherit,
.stdout = .inherit,
.stderr = .inherit,
});
_ = try child.wait(io);
// Read the edited file back. Cap the read so a pathological file can't OOM
// us; 16 MiB is far past any reasonable prompt.
const edited = std.Io.Dir.cwd().readFileAlloc(io, path, app.alloc, .limited(16 * 1024 * 1024)) catch |err| switch (err) {
else => return err,
};
defer app.alloc.free(edited);
// Trim a single trailing newline (the convention most editors add on save).
const trimmed = if (std.mem.endsWith(u8, edited, "\n")) edited[0 .. edited.len - 1] else edited;
try app.input_box.setBuffer(trimmed);
}
/// Build the argv for the `$EDITOR` spawn: split `editor` on spaces (so
/// commands with flags like `"code -w"` work), fall back to `vi` when empty,
/// then append `path` as the final argument. Split out as a pure helper so the
/// arg-splitting seam is unit-testable without a PTY (the spawn + raw-mode
/// round-trip itself is interactive-only).
fn splitEditorArgv(
alloc: std.mem.Allocator,
editor: []const u8,
path: []const u8,
argv: *std.ArrayList([]const u8),
) !void {
var it = std.mem.tokenizeScalar(u8, editor, ' ');
while (it.next()) |part| try argv.append(alloc, part);
if (argv.items.len == 0) try argv.append(alloc, "vi");
try argv.append(alloc, path);
}
/// Handle a submitted input line: slash command vs. model turn.
fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOptions) !void {
if (line.len == 0) return;
if (std.mem.startsWith(u8, line, "/")) {
// Slash command. Output is captured into `opts.cmd_capture` (the
// command Context's stdout) and flushed into the transcript as a dim
// status line — TUI-safe (no raw stdout writes during a frame).
opts.cmd_capture.clearRetainingCapacity();
opts.cmd_registry.dispatch(line, opts.cmd_ctx) catch |err| switch (err) {
command.Error.CommandNotFound => {
const msg = try std.fmt.allocPrint(app.alloc, "[unknown command: {s}]", .{line});
defer app.alloc.free(msg);
_ = try app.spawnStatus(msg);
},
else => {
const msg = try std.fmt.allocPrint(app.alloc, "[command error: {s}]", .{@errorName(err)});
defer app.alloc.free(msg);
_ = try app.spawnStatus(msg);
},
};
// Surface any captured command output.
const captured = opts.cmd_capture.written();
if (captured.len != 0) {
_ = try app.spawnStatus(captured);
}
// Drain any user message the command queued via
// `panto.ext.agent:submit` and drive it as a native turn. A loop,
// not an if: a handler firing during that turn may queue another.
while (try opts.agent.takeSubmission()) |blocks| {
try runSubmission(app, term, opts, blocks);
}
try app.renderNow();
return;
}
// Model turn. Echo the user message, then pump the stream into components.
try app.spawnUser(line); // fires `user_message`
// A `user_message` handler may have overridden `ev.text`: the echo above
// keeps what the user typed; the override is what the turn sends. (The
// fire itself clears any stale override a replayed user_message left.)
const text_override = app.bus.takeOverride();
defer if (text_override) |t| app.alloc.free(t);
const turn_text = text_override orelse line;
app.beginTurn();
try app.renderNow();
// Resolve the active provider's auth before sending (refresh/exchange, or
// an interactive device login when no token is stored). On failure, surface
// it and abort the turn rather than sending an unauthenticated request.
resolveAuthForTurn(app, opts, false) catch |err| {
try app.routeError(err);
try app.renderNow();
return;
};
driveTurn(app, term, opts, turn_text) catch |err| {
try app.routeError(err);
};
try app.renderNow();
}
/// Drive one turn from user-message blocks queued by an extension
/// (`panto.ext.agent:submit`). Mirrors the typed-line path: echo the
/// message (fires `user_message`), honor a text override, resolve auth,
/// drive the turn natively. Owns `queued` (allocated by `takeSubmission`
/// with the conversation allocator): `run` adopts the block contents, the
/// slice is freed here.
fn runSubmission(app: *App, term: *Terminal, opts: RunOptions, queued: []panto.ContentBlock) !void {
const alloc = opts.agent.conversation.allocator;
var blocks = queued;
defer alloc.free(blocks);
// Echo the concatenated text blocks (a message with no text at all
// echoes as a placeholder).
var echo: std.ArrayList(u8) = .empty;
defer echo.deinit(app.alloc);
for (blocks) |b| switch (b) {
.Text => |t| {
if (echo.items.len != 0) try echo.append(app.alloc, '\n');
try echo.appendSlice(app.alloc, t.items);
},
else => {},
};
try app.spawnUser(if (echo.items.len != 0) echo.items else "[non-text message]"); // fires `user_message`
// A `user_message` handler override has the same authority as on a
// typed line: it replaces the queued message wholesale.
if (app.bus.takeOverride()) |t| {
defer app.alloc.free(t);
for (blocks) |*b| b.deinit(alloc);
alloc.free(blocks);
blocks = &.{}; // keep the defer safe if the realloc below fails
blocks = try alloc.alloc(panto.ContentBlock, 1);
blocks[0] = .{ .Text = try panto.textualBlockFromSlice(alloc, t) };
}
app.beginTurn();
try app.renderNow();
resolveAuthForTurn(app, opts, false) catch |err| {
// The turn never opened; the block contents are still ours.
for (blocks) |*b| b.deinit(alloc);
try app.routeError(err);
try app.renderNow();
return;
};
driveTurnBlocks(app, term, opts, blocks) catch |err| {
try app.routeError(err);
};
try app.renderNow();
}
/// The provider name (left of `provider:alias`) currently selected.
fn currentProviderName(app: *App, opts: RunOptions) []const u8 {
const label = if (app.selectors) |c| c.model_label else opts.model_label;
const colon = std.mem.indexOfScalar(u8, label, ':') orelse return label;
return label[0..colon];
}
/// Device-code presenter that renders the verification URL + user code into
/// the transcript as status lines (the TUI is live during an inline login).
const TuiPresenter = struct {
app: *App,
fn deviceCode(ptr: *anyopaque, prompt: panto.DeviceCodePrompt) void {
const app: *App = @ptrCast(@alignCast(ptr));
const msg = std.fmt.allocPrint(
app.alloc,
"[login] open {s} and enter code {s} — waiting…",
.{ prompt.verification_uri, prompt.user_code },
) catch return;
defer app.alloc.free(msg);
_ = app.spawnStatus(msg) catch {};
app.renderNow() catch {};
}
fn status(ptr: *anyopaque, msg: []const u8) void {
const app: *App = @ptrCast(@alignCast(ptr));
_ = app.spawnStatus(msg) catch {};
app.renderNow() catch {};
}
const vtable: panto.Presenter.VTable = .{
.on_device_code = deviceCode,
.on_status = status,
};
fn presenter(self: *TuiPresenter) panto.Presenter {
// The callbacks cast `ptr` back to `*App`, so erase the `*App`
// (not the `*TuiPresenter` wrapper) into the vtable pointer.
return .{ .ptr = self.app, .vtable = &vtable };
}
};
/// Resolve the active provider's auth into the live config and re-point the
/// agent at it. No-op when no auth manager is wired or there is no selector
/// controller to supply the live config. `force` forces a refresh/exchange
/// (used after a provider auth failure).
fn resolveAuthForTurn(app: *App, opts: RunOptions, force: bool) !void {
const mgr = opts.auth_mgr orelse return;
const ctrl = app.selectors orelse return;
const provider = currentProviderName(app, opts);
var tp = TuiPresenter{ .app = app };
try mgr.resolveInto(ctrl.live, provider, force, tp.presenter());
ctrl.agent.setConfig(ctrl.live);
}
/// Try the anthropic adaptive-thinking fallback after a turn open failed with
/// a bad-request error. Returns true if it rewrote the live config (the caller
/// should then `reopen` + continue the stream). No-op (false) when there is no
/// selector controller, the active config is not anthropic+adaptive, or the
/// error isn't a bad request.
fn tryAdaptiveFallback(app: *App, err: anyerror) bool {
if (err != error.ProviderBadRequest) return false;
const ctrl = app.selectors orelse return false;
if (!selectors_mod.adaptiveFallback(&ctrl.live.provider)) return false;
ctrl.agent.setConfig(ctrl.live);
return true;
}
/// Drive one whole turn: open the pull stream, route every event into
/// component state until it terminates, rendering coalesced frames as deltas
/// arrive. The stream is always `deinit`ed (persisting the turn tail) on every
/// exit path — agent persistence is untouched.
fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const u8) !void {
// Open the turn from a single user text block; `run` adopts the block.
var blocks = [_]panto.ContentBlock{
.{ .Text = try panto.textualBlockFromSlice(app.alloc, message_text) },
};
return driveTurnBlocks(app, term, opts, &blocks);
}
/// `driveTurn` for a pre-built user message. `run` adopts the block
/// contents; the slice itself stays the caller's.
fn driveTurnBlocks(app: *App, term: *Terminal, opts: RunOptions, blocks: []panto.ContentBlock) !void {
var stream = try opts.agent.run(.{ .blocks = blocks });
defer stream.deinit();
// Two single-shot fallbacks can re-open the SAME turn (no user-message
// duplication):
// - `ProviderBadRequest` from an anthropic model rejecting adaptive
// thinking: rewrite the live config to manual thinking.
// - `ProviderAuthFailed` (401/403): force an auth refresh/exchange.
// A second failure of either kind propagates.
var fallback_used = false;
var auth_retry_used = false;
var turn_input_tail: std.ArrayList(u8) = .empty;
defer turn_input_tail.deinit(app.alloc);
var interrupted = false;
while (true) {
if (try pumpTurnKeys(app, term, &turn_input_tail)) {
interrupted = true;
stream.cancel();
_ = app.spawnStatus("[interrupted]") catch {};
break;
}
const ev = stream.next() catch |err| {
if (!fallback_used and tryAdaptiveFallback(app, err)) {
fallback_used = true;
try stream.reopen();
continue;
}
if (!auth_retry_used and err == error.ProviderAuthFailed and opts.auth_mgr != null) {
auth_retry_used = true;
// Force a refresh/exchange (no presenter: don't start an
// interactive login mid-turn). If it can't refresh, surface
// the original auth error.
resolveAuthForTurn(app, opts, true) catch return err;
try stream.reopen();
continue;
}
return err;
};
const e = ev orelse break;
try app.routeEvent(e);
try applyPendingOverrides(app, opts.agent, e);
_ = try app.maybeRender();
if (try pumpTurnKeys(app, term, &turn_input_tail)) {
interrupted = true;
stream.cancel();
_ = app.spawnStatus("[interrupted]") catch {};
break;
}
}
if (interrupted) {
app.input_box.setFocused(true);
try app.rebuildEngineList();
try app.renderNow();
}
}
/// Apply staged writable-event-field overrides at their effective stream
/// boundaries. Runs right after `routeEvent` (which is where the fires that
/// stage them happen), still before the next `stream.next()` — i.e. before
/// the agent advances — so the mutation is authoritative for dispatch, the
/// next provider request, and turn persistence:
/// - `tool_dispatch_start`: the assistant message is committed but tools
/// have not run — rewrite ToolUse inputs staged at `tool_call_complete`.
/// - `tool_dispatch_complete`: results are in the conversation tail —
/// rewrite ToolResult text staged at `tool_result`.
/// An override whose call id no longer matches (e.g. a stale stage after a
/// reopen) is dropped silently — the conversation stays untouched.
fn applyPendingOverrides(app: *App, agent: *panto.Agent, ev: Event) !void {
switch (ev) {
.tool_dispatch_start => {
defer app.clearPendingOverrides();
var it = app.pending_input_overrides.iterator();
while (it.next()) |entry| {
_ = try agent.overrideToolUseInput(entry.key_ptr.*, entry.value_ptr.*);
}
},
.tool_dispatch_complete => {
defer app.clearPendingOverrides();
for (app.pending_output_overrides.items) |po| {
_ = try agent.overrideToolResultOutput(po.id, po.text);
}
},
else => {},
}
}
/// Service app-level keybindings while a turn is in flight. Returns true when
/// the user requested an interrupt (Escape). Text-editing keys are ignored: the
/// input box is restored only after the interrupted turn is back in user-input
/// mode. This keeps the old single-threaded stream architecture intact while
/// making global TUI controls responsive between stream/tool-loop events.
fn pumpTurnKeys(app: *App, term: *Terminal, tail: *std.ArrayList(u8)) !bool {
var read_buf: [1024]u8 = undefined;
while (pollReadable(term.fd, 0) catch false) {
const n = posix.read(term.fd, &read_buf) catch |err| switch (err) {
error.WouldBlock => break,
else => return err,
};
if (n == 0) break;
try tail.appendSlice(app.alloc, read_buf[0..n]);
}
if (tail.items.len == 1 and tail.items[0] == 0x1b) {
tail.items.len = 0;
return true;
}
var off: usize = 0;
while (off < tail.items.len) {
const step = input_mod.decodeOne(tail.items[off..]) orelse break;
switch (step.decoded) {
.key => |k| {
if (k.code == .escape) {
off += step.consumed;
const leftover = tail.items.len - off;
std.mem.copyForwards(u8, tail.items[0..leftover], tail.items[off..]);
tail.items.len = leftover;
return true;
}
if (k.isCtrl('o')) app.toggleToolCollapse();
if (k.isCtrl('m')) if (app.selectors) |ctrl| ctrl.openModel() catch {};
if (k.isCtrl('r')) if (app.selectors) |ctrl| ctrl.openReasoning() catch {};
},
.paste => {},
.negotiation => {},
}
off += step.consumed;
}
if (off != 0) {
const leftover = tail.items.len - off;
std.mem.copyForwards(u8, tail.items[0..leftover], tail.items[off..]);
tail.items.len = leftover;
}
_ = try app.maybeRender();
return false;
}
/// Poll the fd for readability with a millisecond timeout. Returns true when
/// data is available. Uses `poll(2)`.
fn pollReadable(fd: posix.fd_t, timeout_ms: i32) !bool {
var fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
const n = try posix.poll(&fds, timeout_ms);
if (n == 0) return false;
return (fds[0].revents & posix.POLL.IN) != 0;
}
// ===========================================================================
// Tests
// ===========================================================================
const testing = std.testing;
/// A test clock that advances by a fixed step each `now()` call so the
/// scheduler's coalescing logic is deterministic.
const TestClock = struct {
t: i128 = 0,
step: i128 = 1,
fn now(ptr: *anyopaque) i128 {
const self: *TestClock = @ptrCast(@alignCast(ptr));
const v = self.t;
self.t += self.step;
return v;
}
fn clock(self: *TestClock) Clock {
return .{ .ptr = self, .nowFn = now };
}
};
/// Build an App backed by an in-memory engine writer (no TTY) for routing
/// tests. Caller owns the returned pieces and must call `teardown`.
const Harness = struct {
buf: std.Io.Writer.Allocating,
engine: Engine,
input_box: InputBox,
footer: Footer,
test_clock: TestClock,
app: App,
fn make(alloc: std.mem.Allocator) !*Harness {
const h = try alloc.create(Harness);
h.buf = std.Io.Writer.Allocating.init(alloc);
h.engine = Engine.init(alloc, &h.buf.writer, 80, 24, false);
h.input_box = InputBox.init(alloc);
h.footer = Footer.init(alloc);
h.test_clock = .{ .t = 0, .step = 100 };
h.app = App.init(alloc, &h.engine, h.test_clock.clock(), &h.input_box, &h.footer);
return h;
}
fn teardown(h: *Harness, alloc: std.mem.Allocator) void {
h.app.deinit();
h.engine.deinit();
h.input_box.deinit();
h.footer.deinit();
h.buf.deinit();
alloc.destroy(h);
}
};
fn delta(index: usize, text: []const u8) Event {
return .{ .content_delta = .{ .index = index, .delta = text } };
}
test "routeEvent: text block + deltas append to an assistant component" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
try h.app.routeEvent(delta(0, "hello"));
try h.app.routeEvent(delta(0, " world"));
// One transcript entry (assistant), buffer accumulated both deltas.
try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len);
const ref = h.app.router.get(0).?;
try testing.expectEqualStrings("hello world", ref.assistant.buffer.items);
}
test "routeEvent: two text blocks key by index, no active-component clobber" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// Two interleaved text blocks (the no-active-component invariant: deltas
// for index 0 must NOT land on index 1 even after block 1 opened).
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 1 } });
try h.app.routeEvent(delta(1, "B"));
try h.app.routeEvent(delta(0, "A"));
try h.app.routeEvent(delta(0, "A2"));
try testing.expectEqualStrings("AA2", h.app.router.get(0).?.assistant.buffer.items);
try testing.expectEqualStrings("B", h.app.router.get(1).?.assistant.buffer.items);
}
test "routeEvent: thinking deltas stream into a dedicated Thinking component" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 0 } });
try h.app.routeEvent(delta(0, "reason"));
try h.app.routeEvent(delta(0, "ing"));
const ref = h.app.router.get(0).?;
try testing.expect(ref == .thinking);
try testing.expectEqualStrings("reasoning", ref.thinking.buffer.items);
}
test "routeEvent: tool block accumulates verbatim args and resolves its name" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
// Tool args stream as deltas and accumulate verbatim into the component.
try h.app.routeEvent(delta(0, "{\"path\":"));
try h.app.routeEvent(delta(0, "\"x\"}"));
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "t1", .name = "read" } });
const ref = h.app.router.get(0).?;
try testing.expect(ref == .tool);
try testing.expect(ref.tool.name != null);
try testing.expectEqualStrings("read", ref.tool.name.?.items);
try testing.expectEqualStrings("{\"path\":\"x\"}", ref.tool.input.items);
// The id was registered for result correlation.
try testing.expect(h.app.router.getToolById("t1") == ref.tool);
}
test "routeEvent: provider_retry adds a dim status line" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
try h.app.routeEvent(.{ .provider_retry = .{
.err = error.ConnectionResetByPeer,
.delay_ms = 1500,
.attempt = 0,
.max_attempts = 3,
.compaction = false,
} });
try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len);
const e = h.app.transcript.items[0];
try testing.expect(e.kind == .status);
try testing.expect(std.mem.indexOf(u8, e.kind.status.buffer.items, "retrying") != null);
}
test "routeEvent: full event stream renders through the real engine, no stdout" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// Pin input + footer like the real loop.
h.app.input_box.setFocused(true);
try h.app.rebuildEngineList();
h.app.beginTurn();
try h.app.routeEvent(.{ .message_start = .assistant });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
// The streaming path renders complete lines through markdown and shows the
// trailing partial line verbatim as it arrives.
try h.app.routeEvent(delta(0, "Hi there"));
try h.app.renderNow();
try testing.expect(std.mem.indexOf(u8, h.buf.written(), "Hi there") != null);
try h.app.routeEvent(delta(0, "\n"));
try h.app.renderNow();
try testing.expect(std.mem.indexOf(u8, h.buf.written(), "Hi there") != null);
try h.app.routeEvent(.{ .turn_complete = {} });
}
test "beginTurn clears the block-index map but keeps transcript history" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
try h.app.routeEvent(delta(0, "first turn"));
try testing.expect(h.app.router.get(0) != null);
h.app.beginTurn();
// Router cleared...
try testing.expect(h.app.router.get(0) == null);
// ...but the transcript entry persists as history.
try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len);
}
test "maybeRender feeds the footer a frame time and respects coalescing" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
try h.app.rebuildEngineList();
// No pending frame => no render.
try testing.expect(!(try h.app.maybeRender()));
h.app.scheduler.requestRender();
try testing.expect(try h.app.maybeRender()); // idle => renders
}
test "routeEvent: tool result correlates to its ToolUse component by id" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// Open a tool call, resolve its id/name, accumulate args.
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
try h.app.routeEvent(delta(0, "{\"q\":1}"));
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "call-1", .name = "search" } });
// Build a tool_dispatch_complete user message carrying a ToolResult for
// call-1 (the out-of-band delivery path).
var msg: panto.Message = .{ .role = .user };
defer msg.deinit(alloc);
var parts: std.ArrayList(panto.ResultPartStored) = .empty;
var text: panto.TextualBlock = .empty;
try text.appendSlice(alloc, "the result body");
try parts.append(alloc, .{ .text = text });
const id = try alloc.dupe(u8, "call-1");
try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = id, .parts = parts } });
try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } });
// The matching component received the output.
const box = h.app.router.getToolById("call-1").?;
try testing.expect(box.output != null);
try testing.expectEqualStrings("the result body", box.output.?.items);
}
test "routeEvent: two concurrent tool calls route results to their OWN component by id" {
// The highest-risk no-active-component case (plan §6): with MULTIPLE tool
// calls in flight, each ToolResult must land on the component that issued
// the matching id — never "the" tool component. We deliberately deliver the
// results in the REVERSE order of the calls and assert no cross-talk.
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// Open two tool calls at distinct block indices; resolve distinct ids.
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
try h.app.routeEvent(delta(0, "{\"a\":1}"));
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "call-A", .name = "read" } });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } });
try h.app.routeEvent(delta(1, "{\"b\":2}"));
try h.app.routeEvent(.{ .tool_details = .{ .index = 1, .id = "call-B", .name = "write" } });
const box_a = h.app.router.getToolById("call-A").?;
const box_b = h.app.router.getToolById("call-B").?;
try testing.expect(box_a != box_b);
// Deliver BOTH results in ONE tool_dispatch_complete user message, in the
// reverse order (B before A), each carrying its own tool_use_id.
var msg: panto.Message = .{ .role = .user };
defer msg.deinit(alloc);
{
var parts_b: std.ArrayList(panto.ResultPartStored) = .empty;
var text_b: panto.TextualBlock = .empty;
try text_b.appendSlice(alloc, "result for B");
try parts_b.append(alloc, .{ .text = text_b });
try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "call-B"), .parts = parts_b } });
var parts_a: std.ArrayList(panto.ResultPartStored) = .empty;
var text_a: panto.TextualBlock = .empty;
try text_a.appendSlice(alloc, "result for A");
try parts_a.append(alloc, .{ .text = text_a });
try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "call-A"), .parts = parts_a } });
}
try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } });
// Each result landed on its OWN component — no clobber, no cross-talk.
try testing.expect(box_a.output != null);
try testing.expect(box_b.output != null);
try testing.expectEqualStrings("result for A", box_a.output.?.items);
try testing.expectEqualStrings("result for B", box_b.output.?.items);
// And the inputs were never crossed either.
try testing.expectEqualStrings("{\"a\":1}", box_a.input.items);
try testing.expectEqualStrings("{\"b\":2}", box_b.input.items);
}
test "routeEvent: an unmatched tool_use_id is ignored, matched siblings still route" {
// A result whose id has no live ToolUse must be skipped (orelse continue),
// never crash or smear onto another component.
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "known", .name = "read" } });
const known = h.app.router.getToolById("known").?;
var msg: panto.Message = .{ .role = .user };
defer msg.deinit(alloc);
{
var p_unknown: std.ArrayList(panto.ResultPartStored) = .empty;
var t_unknown: panto.TextualBlock = .empty;
try t_unknown.appendSlice(alloc, "orphan");
try p_unknown.append(alloc, .{ .text = t_unknown });
try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "ghost"), .parts = p_unknown } });
var p_known: std.ArrayList(panto.ResultPartStored) = .empty;
var t_known: panto.TextualBlock = .empty;
try t_known.appendSlice(alloc, "real");
try p_known.append(alloc, .{ .text = t_known });
try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "known"), .parts = p_known } });
}
try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } });
try testing.expect(known.output != null);
try testing.expectEqualStrings("real", known.output.?.items);
}
test "seedFromConversation materializes resumed history into transcript components" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// Build a resumed conversation: system (skipped), user, assistant text +
// a tool call, the tool result (in a user-role carrier), and a final
// assistant reply.
var conv = panto.Conversation.init(alloc);
defer conv.deinit();
try conv.addSystemMessage("you are a helpful assistant"); // must NOT render
try conv.addUserMessage(&.{.{ .Text = try panto.textualBlockFromSlice(alloc, "hello there") }});
try conv.addAssistantMessage(&.{
.{ .Text = try panto.textualBlockFromSlice(alloc, "on it") },
.{ .ToolUse = .{
.id = try alloc.dupe(u8, "call-1"),
.name = try alloc.dupe(u8, "bash"),
.input = try panto.textualBlockFromSlice(alloc, "{\"cmd\":\"ls\"}"),
} },
}, null);
{
var parts: std.ArrayList(panto.ResultPartStored) = .empty;
try parts.append(alloc, .{ .text = try panto.textualBlockFromSlice(alloc, "file-a\nfile-b") });
try conv.addUserMessage(&.{.{ .ToolResult = .{
.tool_use_id = try alloc.dupe(u8, "call-1"),
.parts = parts,
} }});
}
try conv.addAssistantMessage(&.{
.{ .Text = try panto.textualBlockFromSlice(alloc, "done") },
}, null);
try h.app.seedFromConversation(&conv);
// Entries: user, assistant("on it"), tool, assistant("done"). The system
// message and the tool-result carrier produce no standalone entries.
try testing.expectEqual(@as(usize, 4), h.app.transcript.items.len);
try testing.expect(h.app.transcript.items[0].kind == .user);
try testing.expectEqualStrings("hello there", h.app.transcript.items[0].kind.user.buffer.items);
try testing.expect(h.app.transcript.items[1].kind == .assistant);
try testing.expectEqualStrings("on it", h.app.transcript.items[1].kind.assistant.buffer.items);
try testing.expect(h.app.transcript.items[2].kind == .tool);
const tool = h.app.transcript.items[2].kind.tool;
try testing.expectEqualStrings("bash", tool.name.?.items);
try testing.expect(std.mem.indexOf(u8, tool.input.items, "ls") != null);
// The tool result correlated by id and set the output + success state.
try testing.expect(tool.output != null);
try testing.expect(std.mem.indexOf(u8, tool.output.?.items, "file-b") != null);
try testing.expectEqual(@as(?bool, true), tool.result_ok);
try testing.expect(h.app.transcript.items[3].kind == .assistant);
try testing.expectEqualStrings("done", h.app.transcript.items[3].kind.assistant.buffer.items);
// The id map was a replay scratchpad and is cleared afterward.
try testing.expect(h.app.router.getToolById("call-1") == null);
// It renders through the real engine without error.
try h.app.rebuildEngineList();
try h.app.renderNow();
const out = h.buf.written();
try testing.expect(std.mem.indexOf(u8, out, "hello there") != null);
try testing.expect(std.mem.indexOf(u8, out, "done") != null);
}
test "streaming a new block with expanded tools stays differential (no scrollback flash)" {
// Regression for the expanded-tool-call flash: with tall (scrolled)
// content, appending a new transcript entry must NOT trigger a
// scrollback-clearing full redraw. Before the syncComponents fix this
// emitted a full_clear on the first frame after each new block.
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// Short engine so content scrolls => viewport_top > 0 when expanded.
h.engine.resize(80, 12);
h.app.input_box.setFocused(true);
try h.app.rebuildEngineList();
// A tool call with a big multi-line output, expanded via ctrl+o.
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
const tool = h.app.router.get(0).?.tool;
try tool.setName("bash");
var big: std.ArrayList(u8) = .empty;
defer big.deinit(alloc);
var i: usize = 0;
while (i < 40) : (i += 1) {
var lb: [32]u8 = undefined;
try big.appendSlice(alloc, std.fmt.bufPrint(&lb, "output line {d}\n", .{i}) catch unreachable);
}
try tool.setOutput(big.items);
tool.setResultOk(true);
h.app.toggleToolCollapse();
try testing.expect(!tool.collapsed);
try h.app.renderNow();
try testing.expect(h.engine.viewport_top > 0);
// Open a new assistant text block and stream into it. No frame across the
// new-block boundary or the deltas may clear the scrollback.
var clears: usize = 0;
h.buf.clearRetainingCapacity();
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 1 } });
try h.app.renderNow();
if (std.mem.indexOf(u8, h.buf.written(), terminal_mod.seq.full_clear) != null) clears += 1;
const chunks = [_][]const u8{ "Lorem ", "ipsum ", "dolor ", "sit ", "amet " };
for (chunks) |c| {
h.buf.clearRetainingCapacity();
try h.app.routeEvent(delta(1, c));
try h.app.renderNow();
if (std.mem.indexOf(u8, h.buf.written(), terminal_mod.seq.full_clear) != null) clears += 1;
}
try testing.expectEqual(@as(usize, 0), clears);
}
fn testModelDef(provider: []const u8, alias: []const u8, model: []const u8) models_toml.ModelDef {
return .{
.provider = provider,
.alias = alias,
.model = model,
.reasoning = .default,
.max_tokens = null,
.api_version = null,
.thinking = .disabled,
.effort = .medium,
.thinking_budget_tokens = null,
.thinking_interleaved = false,
};
}
test "formatModelDetail: shows wire model + non-default knobs + pricing" {
const alloc = testing.allocator;
// Empty pricing registry: no tag appended (this is the default shape for
// test definitions that don't bother declaring a price).
var pricing = panto.PricingRegistry.init(alloc);
defer pricing.deinit();
// Plain entry: just the wire model id.
{
const d = testModelDef("openai", "gpt", "gpt-4o");
const s = try formatModelDetail(alloc, d, &pricing);
defer alloc.free(s);
try testing.expectEqualStrings("gpt-4o", s);
}
// openai reasoning knob surfaced.
{
var d = testModelDef("openai", "o3", "o3");
d.reasoning = .high;
const s = try formatModelDetail(alloc, d, &pricing);
defer alloc.free(s);
try testing.expect(std.mem.indexOf(u8, s, "reasoning:high") != null);
}
// anthropic adaptive thinking + effort surfaced.
{
var d = testModelDef("anthropic", "opus", "claude-opus-4");
d.thinking = .adaptive;
d.effort = .xhigh;
const s = try formatModelDetail(alloc, d, &pricing);
defer alloc.free(s);
try testing.expect(std.mem.indexOf(u8, s, "thinking:adaptive") != null);
try testing.expect(std.mem.indexOf(u8, s, "effort:xhigh") != null);
}
// With pricing: a tag like "1i/5o/0.1r/1.25w" is appended to the line.
{
try pricing.set("openai", "gpt-4o", .{
.input = 250,
.output = 1000,
.cache_read = 125,
.cache_write = 0,
});
const d = testModelDef("openai", "gpt-4o", "gpt-4o");
const s = try formatModelDetail(alloc, d, &pricing);
defer alloc.free(s);
try testing.expectEqualStrings("gpt-4o 2.5i/10o/1.25r/0w", s);
}
}
test "model picker rows sort by provider:alias and keep mapping" {
var rows = [_]ModelPickerRow{
.{ .def_index = 2, .item = .{ .label = "openai:gpt-4o", .detail = "gpt-4o" } },
.{ .def_index = 0, .item = .{ .label = "anthropic:sonnet", .detail = "claude-sonnet-4" } },
.{ .def_index = 1, .item = .{ .label = "anthropic:haiku", .detail = "claude-haiku-4" } },
};
std.mem.sort(ModelPickerRow, rows[0..], {}, modelPickerRowLessThan);
try testing.expectEqualStrings("anthropic:haiku", rows[0].item.label);
try testing.expectEqual(@as(usize, 1), rows[0].def_index);
try testing.expectEqualStrings("anthropic:sonnet", rows[1].item.label);
try testing.expectEqual(@as(usize, 0), rows[1].def_index);
try testing.expectEqualStrings("openai:gpt-4o", rows[2].item.label);
try testing.expectEqual(@as(usize, 2), rows[2].def_index);
}
test "toggleToolCollapse flips every tool component globally" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// Two tool calls. Default collapsed == true.
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } });
const a = h.app.router.get(0).?.tool;
const b = h.app.router.get(1).?.tool;
try testing.expect(a.collapsed and b.collapsed);
// ctrl+o equivalent: expand all.
h.app.toggleToolCollapse();
try testing.expect(!a.collapsed and !b.collapsed);
try testing.expect(!h.app.tools_collapsed);
// Toggle again: collapse all.
h.app.toggleToolCollapse();
try testing.expect(a.collapsed and b.collapsed);
}
test "toggleToolCollapse: a tool spawned AFTER the toggle inherits the global state" {
// ctrl+o is a GLOBAL mode, not a per-component flip: a tool call that opens
// later must adopt whatever the current global collapse state is, so the
// whole transcript stays consistent.
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// Default is collapsed; flip the global mode to EXPANDED before any tool.
h.app.toggleToolCollapse();
try testing.expect(!h.app.tools_collapsed);
// A tool that opens now must be expanded to match the global mode.
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
const late = h.app.router.get(0).?.tool;
try testing.expect(!late.collapsed);
// Flip back to collapsed; a still-later tool must open collapsed.
h.app.toggleToolCollapse();
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } });
const later = h.app.router.get(1).?.tool;
try testing.expect(later.collapsed);
// And the earlier one flipped along with the global toggle.
try testing.expect(late.collapsed);
}
test "spawnWelcome shows a session-start banner entry" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
const w = try h.app.spawnWelcome(.{});
_ = w;
try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len);
try testing.expect(h.app.transcript.items[0].kind == .welcome);
}
test "routeEvent: compaction summary block spawns a compaction entry" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
var cs: panto.TextualBlock = .empty;
defer cs.deinit(alloc);
try cs.appendSlice(alloc, "old turns summarized");
try h.app.routeEvent(.{ .block_complete = .{
.index = 0,
.block = .{ .CompactionSummary = .{ .text = cs } },
} });
try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len);
try testing.expect(h.app.transcript.items[0].kind == .compaction);
}
// -- event system wiring (plan §7) -------------------------------------------
/// A test component that renders a fixed marker line, used to prove an
/// extension handler's chosen component reaches the engine.
const MarkerComponent = struct {
line: []const u8,
cache: component.RenderCache,
fn init(alloc: std.mem.Allocator, line: []const u8) MarkerComponent {
return .{ .line = line, .cache = component.RenderCache.init(alloc) };
}
fn deinit(self: *MarkerComponent) void {
self.cache.deinit();
}
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = width;
_ = alloc;
const self: *MarkerComponent = @ptrCast(@alignCast(ptr));
const lines = [_][]const u8{self.line};
try self.cache.store(&lines);
const owned = self.cache.lines orelse return &.{};
return @ptrCast(owned);
}
fn firstLineChangedImpl(ptr: *anyopaque) ?usize {
const self: *MarkerComponent = @ptrCast(@alignCast(ptr));
return self.cache.firstLineChanged();
}
fn invalidateImpl(ptr: *anyopaque) void {
const self: *MarkerComponent = @ptrCast(@alignCast(ptr));
self.cache.invalidate();
}
const vtable = Component.VTable{
.render = renderImpl,
.firstLineChanged = firstLineChangedImpl,
.invalidate = invalidateImpl,
};
fn comp(self: *MarkerComponent) Component {
return .{ .ptr = self, .vtable = &vtable };
}
};
test "event wiring: no handler => entry keeps the built-in default (override null)" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// No handlers registered. Spawn one of each event-bearing boundary and
// confirm none got an override — i.e. the engine renders the built-in
// default, byte-identical to the pre-event-system behavior.
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 1 } });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 2 } });
_ = try h.app.spawnWelcome(.{});
try h.app.spawnUser("hi");
try testing.expect(h.app.transcript.items.len == 5);
for (h.app.transcript.items) |e| try testing.expect(e.override == null);
}
test "event wiring: assistant_text default render is identical with vs without a no-op handler" {
const alloc = testing.allocator;
// Render once with NO handlers.
const baseline = blk: {
const h = try Harness.make(alloc);
defer h.teardown(alloc);
h.app.input_box.setFocused(true);
try h.app.rebuildEngineList();
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
try h.app.routeEvent(delta(0, "identical body"));
try h.app.renderNow();
break :blk try alloc.dupe(u8, h.buf.written());
};
defer alloc.free(baseline);
// Render again with a handler that reads the default and sets it back
// unchanged (a no-op pass-through). Output must be byte-identical.
{
const h = try Harness.make(alloc);
defer h.teardown(alloc);
const NoOp = struct {
fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
_ = ctx;
if (ev.getComponent()) |c| ev.setComponent(c); // set back unchanged
}
};
try h.app.bus.on("assistant_text", .{ .ctx = &h.app, .callback = NoOp.cb });
h.app.input_box.setFocused(true);
try h.app.rebuildEngineList();
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
try h.app.routeEvent(delta(0, "identical body"));
try h.app.renderNow();
try testing.expectEqualStrings(baseline, h.buf.written());
}
}
test "event wiring: a handler replaces the component; engine renders it, deltas drive the default" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
var marker = MarkerComponent.init(alloc, "REPLACED-BY-EXTENSION");
defer marker.deinit();
const Replace = struct {
marker: *MarkerComponent,
fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
const self: *@This() = @ptrCast(@alignCast(ctx));
ev.setComponent(self.marker.comp());
}
};
var rep = Replace{ .marker = &marker };
try h.app.bus.on("assistant_text", .{ .ctx = &rep, .callback = Replace.cb });
h.app.input_box.setFocused(true);
try h.app.rebuildEngineList();
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
// Deltas still drive the DEFAULT typed box (the override would normally
// wrap + render it; this stub marker ignores it, which is fine for the
// wiring assertion).
try h.app.routeEvent(delta(0, "hidden body"));
// The entry recorded the override.
try testing.expect(h.app.transcript.items[0].override != null);
// The default box still received the delta (no-active-component routing).
try testing.expectEqualStrings("hidden body", h.app.router.get(0).?.assistant.buffer.items);
try h.app.renderNow();
const out = h.buf.written();
// The engine rendered the EXTENSION component, not the default text.
try testing.expect(std.mem.indexOf(u8, out, "REPLACED-BY-EXTENSION") != null);
try testing.expect(std.mem.indexOf(u8, out, "hidden body") == null);
}
test "event wiring: two concurrent tool boundaries get independent components" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// A handler that mints a distinct marker per tool block index, proving the
// bus carries no "active component" across emits.
var markers = [_]MarkerComponent{
MarkerComponent.init(alloc, "TOOL-0"),
MarkerComponent.init(alloc, "TOOL-1"),
};
defer for (&markers) |*m| m.deinit();
const Mint = struct {
markers: []MarkerComponent,
fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
const self: *@This() = @ptrCast(@alignCast(ctx));
const idx = ev.payload.tool.index;
if (idx < self.markers.len) ev.setComponent(self.markers[idx].comp());
}
};
var mint = Mint{ .markers = &markers };
try h.app.bus.on("tool", .{ .ctx = &mint, .callback = Mint.cb });
// The `tool` event now fires at block_start (name unknown). The index IS
// present at start, so the Mint handler (keyed on index) sets each call's
// own marker immediately — each tool boundary gets its own component.
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } });
const o0 = h.app.transcript.items[0].override.?;
const o1 = h.app.transcript.items[1].override.?;
try testing.expect(o0.ptr == markers[0].comp().ptr);
try testing.expect(o1.ptr == markers[1].comp().ptr);
try testing.expect(o0.ptr != o1.ptr);
}
test "event wiring: tool lifecycle events each fire EXACTLY ONCE at their boundary" {
// The named tool-lifecycle events (`tool`, `tool_details`,
// `tool_call_complete`, `tool_result`) each fire once per slot, in order.
// `tool_delta` fires per chunk (not guarded). This replaces the old
// deferral test: `tool` now fires at block_start (name unknown), and the
// later events carry the resolving data.
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
const Counter = struct {
tool: usize = 0,
details: usize = 0,
delta: usize = 0,
call_complete: usize = 0,
result: usize = 0,
last_name: []const u8 = "",
// One callback that buckets by the event NAME, so the same ctx tracks
// every lifecycle event (the name disambiguates which counter to bump).
fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
const c: *@This() = @ptrCast(@alignCast(ctx));
const n = ev.name;
if (std.mem.eql(u8, n, "tool")) c.tool += 1 //
else if (std.mem.eql(u8, n, "tool_details")) c.details += 1 //
else if (std.mem.eql(u8, n, "tool_delta")) c.delta += 1 //
else if (std.mem.eql(u8, n, "tool_call_complete")) c.call_complete += 1 //
else if (std.mem.eql(u8, n, "tool_result")) c.result += 1;
c.last_name = ev.payload.tool.tool_name;
}
};
var counter = Counter{};
try h.app.bus.on("tool", .{ .ctx = &counter, .callback = Counter.cb });
try h.app.bus.on("tool_details", .{ .ctx = &counter, .callback = Counter.cb });
try h.app.bus.on("tool_delta", .{ .ctx = &counter, .callback = Counter.cb });
try h.app.bus.on("tool_call_complete", .{ .ctx = &counter, .callback = Counter.cb });
try h.app.bus.on("tool_result", .{ .ctx = &counter, .callback = Counter.cb });
// block_start => `tool` (name unknown).
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
try testing.expectEqual(@as(usize, 1), counter.tool);
try testing.expectEqualStrings("", counter.last_name);
// two args deltas => `tool_delta` twice (repeatable).
try h.app.routeEvent(delta(0, "{\"a\":"));
try h.app.routeEvent(delta(0, "1}"));
try testing.expectEqual(@as(usize, 2), counter.delta);
// tool_details => `tool_details` once, with the name.
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } });
try testing.expectEqual(@as(usize, 1), counter.details);
try testing.expectEqualStrings("read", counter.last_name);
// block_complete => `tool_call_complete` once.
var tu = panto.ToolUseBlock{
.id = try alloc.dupe(u8, "a"),
.name = try alloc.dupe(u8, "read"),
};
defer tu.deinit(alloc);
try tu.input.appendSlice(alloc, "{\"a\":1}");
try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .ToolUse = tu } } });
try testing.expectEqual(@as(usize, 1), counter.call_complete);
// tool_dispatch_complete carrying the result => `tool_result` once.
var msg: panto.Message = .{ .role = .user };
defer msg.deinit(alloc);
var parts: std.ArrayList(panto.ResultPartStored) = .empty;
var text: panto.TextualBlock = .empty;
try text.appendSlice(alloc, "out");
try parts.append(alloc, .{ .text = text });
try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "a"), .parts = parts } });
try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } });
try testing.expectEqual(@as(usize, 1), counter.result);
// Each named event fired exactly once (delta is the only repeatable one).
try testing.expectEqual(@as(usize, 1), counter.tool);
try testing.expectEqual(@as(usize, 1), counter.details);
try testing.expectEqual(@as(usize, 1), counter.call_complete);
try testing.expectEqual(@as(usize, 1), counter.result);
}
test "writable-field overrides are staged by call id at tool_call_complete and tool_result" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// A native handler standing in for a Lua `ev.input = ...` / `ev.output
// = ...` assignment (the bridge routes those to the same bus slot).
const Writer = struct {
bus: *EventBus,
fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
const w: *@This() = @ptrCast(@alignCast(ctx));
if (std.mem.eql(u8, ev.name, "tool_call_complete")) {
w.bus.setOverride("{\"a\":2}") catch {};
} else if (std.mem.eql(u8, ev.name, "tool_result")) {
w.bus.setOverride("redacted") catch {};
}
}
};
var w = Writer{ .bus = &h.app.bus };
try h.app.bus.on("tool_call_complete", .{ .ctx = &w, .callback = Writer.cb });
try h.app.bus.on("tool_result", .{ .ctx = &w, .callback = Writer.cb });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } });
var tu = panto.ToolUseBlock{
.id = try alloc.dupe(u8, "a"),
.name = try alloc.dupe(u8, "read"),
};
defer tu.deinit(alloc);
try tu.input.appendSlice(alloc, "{\"a\":1}");
try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .ToolUse = tu } } });
// The input override is staged, keyed by the resolved call id.
try testing.expectEqual(@as(usize, 1), h.app.pending_input_overrides.count());
try testing.expectEqualStrings("{\"a\":2}", h.app.pending_input_overrides.get("a").?);
var msg: panto.Message = .{ .role = .user };
defer msg.deinit(alloc);
var parts: std.ArrayList(panto.ResultPartStored) = .empty;
var text: panto.TextualBlock = .empty;
try text.appendSlice(alloc, "out");
try parts.append(alloc, .{ .text = text });
try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "a"), .parts = parts } });
try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } });
// The output override is staged with the id it belongs to.
try testing.expectEqual(@as(usize, 1), h.app.pending_output_overrides.items.len);
try testing.expectEqualStrings("a", h.app.pending_output_overrides.items[0].id);
try testing.expectEqualStrings("redacted", h.app.pending_output_overrides.items[0].text);
// Nothing left on the bus once staged.
try testing.expect(h.app.bus.takeOverride() == null);
}
test "event wiring: thinking lifecycle fires start + per-delta + complete" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
const Rec = struct {
start: usize = 0,
delta: usize = 0,
complete: usize = 0,
last_delta: []const u8 = "",
last_text: []const u8 = "",
fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
const r: *@This() = @ptrCast(@alignCast(ctx));
if (std.mem.eql(u8, ev.name, "thinking")) r.start += 1 //
else if (std.mem.eql(u8, ev.name, "thinking_delta")) {
r.delta += 1;
r.last_delta = ev.payload.thinking.delta;
r.last_text = ev.payload.thinking.text;
} else if (std.mem.eql(u8, ev.name, "thinking_complete")) {
r.complete += 1;
r.last_text = ev.payload.thinking.text;
}
}
};
var rec = Rec{};
try h.app.bus.on("thinking", .{ .ctx = &rec, .callback = Rec.cb });
try h.app.bus.on("thinking_delta", .{ .ctx = &rec, .callback = Rec.cb });
try h.app.bus.on("thinking_complete", .{ .ctx = &rec, .callback = Rec.cb });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 0 } });
try h.app.routeEvent(delta(0, "hmm"));
try h.app.routeEvent(delta(0, " ok"));
var th = panto.ThinkingBlock{};
defer th.deinit(alloc);
try th.text.appendSlice(alloc, "hmm ok");
try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .Thinking = th } } });
try testing.expectEqual(@as(usize, 1), rec.start);
try testing.expectEqual(@as(usize, 2), rec.delta);
try testing.expectEqual(@as(usize, 1), rec.complete);
// The last delta carried the chunk + accumulated text; complete carried
// the final text.
try testing.expectEqualStrings("hmm ok", rec.last_text);
}
test "event wiring: assistant_text lifecycle fires start + per-delta + complete" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
const Rec = struct {
start: usize = 0,
delta: usize = 0,
complete: usize = 0,
last_text: []const u8 = "",
fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
const r: *@This() = @ptrCast(@alignCast(ctx));
if (std.mem.eql(u8, ev.name, "assistant_text")) r.start += 1 //
else if (std.mem.eql(u8, ev.name, "assistant_text_delta")) {
r.delta += 1;
r.last_text = ev.payload.assistant_text.text;
} else if (std.mem.eql(u8, ev.name, "assistant_text_complete")) {
r.complete += 1;
r.last_text = ev.payload.assistant_text.text;
}
}
};
var rec = Rec{};
try h.app.bus.on("assistant_text", .{ .ctx = &rec, .callback = Rec.cb });
try h.app.bus.on("assistant_text_delta", .{ .ctx = &rec, .callback = Rec.cb });
try h.app.bus.on("assistant_text_complete", .{ .ctx = &rec, .callback = Rec.cb });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .Text, .index = 0 } });
try h.app.routeEvent(delta(0, "Hel"));
try h.app.routeEvent(delta(0, "lo"));
var tb: panto.TextualBlock = .empty;
defer tb.deinit(alloc);
try tb.appendSlice(alloc, "Hello");
try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .Text = tb } } });
try testing.expectEqual(@as(usize, 1), rec.start);
try testing.expectEqual(@as(usize, 2), rec.delta);
try testing.expectEqual(@as(usize, 1), rec.complete);
try testing.expectEqualStrings("Hello", rec.last_text);
}
test "event wiring: mid-stream swap at tool_details takes over and keeps driving the default box" {
// A handler ignores the `tool` start (name unknown) and only swaps at
// `tool_details` when the name is "read". The swap must (a) replace the
// rendered component, (b) fully take over the region (the swapped-in
// component renders, the default's taller `tool (?)`/args content is NOT
// visible), and (c) panto keeps driving args/result into the DEFAULT box.
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
var marker = MarkerComponent.init(alloc, "SWAPPED-AT-DETAILS");
defer marker.deinit();
const Claim = struct {
marker: *MarkerComponent,
fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
const self: *@This() = @ptrCast(@alignCast(ctx));
if (std.mem.eql(u8, ev.payload.tool.tool_name, "read")) {
ev.setComponent(self.marker.comp());
}
}
};
var claim = Claim{ .marker = &marker };
try h.app.bus.on("tool_details", .{ .ctx = &claim, .callback = Claim.cb });
h.app.input_box.setFocused(true);
try h.app.rebuildEngineList();
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
// No swap yet (only `tool` fired, name unknown).
try testing.expect(h.app.transcript.items[0].override == null);
// Stream some args so the default box has multi-line content (a taller
// predecessor than the single-line marker).
try h.app.routeEvent(delta(0, "{\"path\":\"a\",\n\"mode\":\"r\"}"));
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } });
// The override was installed at tool_details.
try testing.expect(h.app.transcript.items[0].override != null);
try testing.expect(h.app.transcript.items[0].override.?.ptr == marker.comp().ptr);
try h.app.renderNow();
var out = h.buf.written();
// Full takeover: the swapped-in component is visible; the default's args
// content is not.
try testing.expect(std.mem.indexOf(u8, out, "SWAPPED-AT-DETAILS") != null);
try testing.expect(std.mem.indexOf(u8, out, "mode") == null);
// panto KEEPS DRIVING the default box: deliver a result and confirm the
// DEFAULT ToolUse box received it (even though the override renders).
const box = h.app.router.getToolById("a").?;
var msg: panto.Message = .{ .role = .user };
defer msg.deinit(alloc);
var parts: std.ArrayList(panto.ResultPartStored) = .empty;
var text: panto.TextualBlock = .empty;
try text.appendSlice(alloc, "the-output");
try parts.append(alloc, .{ .text = text });
try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "a"), .parts = parts } });
try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } });
try testing.expect(box.output != null);
try testing.expectEqualStrings("the-output", box.output.?.items);
// Args were driven into the default box too.
try testing.expect(std.mem.indexOf(u8, box.input.items, "path") != null);
// The override still owns the slot (not the default), even after the
// result drove the default box. Delivering the result dirtied the DEFAULT
// box, but the override is what renders that slot and it did NOT change,
// so the next differential frame must NOT repaint the default's content:
// the swapped-in marker stays on screen and "the-output" never appears.
// (Under the incremental model an unchanged slot is not re-emitted; we
// assert the slot is still the override and that the default content is
// absent from the frame.)
try testing.expect(h.app.transcript.items[0].override.?.ptr == marker.comp().ptr);
h.buf.clearRetainingCapacity();
try h.app.renderNow();
out = h.buf.written();
try testing.expect(std.mem.indexOf(u8, out, "the-output") == null);
}
test "event wiring: a replaced override is handed back to the release hook" {
// Two handlers swap the same slot in turn (at `tool` then `tool_details`).
// The App owns neither override; when the second replaces the first, the
// App must hand the FIRST one back to the installed release hook so its
// owner can drop it (the Lua-bridge leak-prevention point).
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
var first = MarkerComponent.init(alloc, "FIRST");
defer first.deinit();
var second = MarkerComponent.init(alloc, "SECOND");
defer second.deinit();
const Swap = struct {
first: *MarkerComponent,
second: *MarkerComponent,
fn at_tool(ctx: *anyopaque, ev: *ui_event.Event) void {
const self: *@This() = @ptrCast(@alignCast(ctx));
ev.setComponent(self.first.comp());
}
fn at_details(ctx: *anyopaque, ev: *ui_event.Event) void {
const self: *@This() = @ptrCast(@alignCast(ctx));
ev.setComponent(self.second.comp());
}
};
var swap = Swap{ .first = &first, .second = &second };
try h.app.bus.on("tool", .{ .ctx = &swap, .callback = Swap.at_tool });
try h.app.bus.on("tool_details", .{ .ctx = &swap, .callback = Swap.at_details });
const Released = struct {
ptr: ?*anyopaque = null,
count: usize = 0,
fn rel(ctx: *anyopaque, old: Component) void {
const r: *@This() = @ptrCast(@alignCast(ctx));
r.ptr = old.ptr;
r.count += 1;
}
};
var released = Released{};
h.app.setOverrideRelease(&released, Released.rel);
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
// First override installed at `tool`; no release yet.
try testing.expectEqual(@as(usize, 0), released.count);
try testing.expect(h.app.transcript.items[0].override.?.ptr == first.comp().ptr);
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } });
// Second override replaced the first; the FIRST was handed to the hook.
try testing.expect(h.app.transcript.items[0].override.?.ptr == second.comp().ptr);
try testing.expectEqual(@as(usize, 1), released.count);
try testing.expect(released.ptr == first.comp().ptr);
}
test "event wiring: an idempotent same-ptr swap does NOT release (no release-then-use)" {
// A handler that sets the SAME component again (same ptr) at a later
// lifecycle event must NOT trigger the release hook: there is no
// superseded component, so releasing would free a component the slot
// still renders (a release-then-use). `setOverride` guards this with
// `old.ptr != new.ptr`. This also covers a handler that re-affirms its
// own component across `tool` -> `tool_details` -> `tool_call_complete`.
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
var only = MarkerComponent.init(alloc, "ONLY");
defer only.deinit();
// The same handler fires on every tool lifecycle event and always sets the
// SAME component instance.
const Same = struct {
only: *MarkerComponent,
fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
const self: *@This() = @ptrCast(@alignCast(ctx));
ev.setComponent(self.only.comp());
}
};
var same = Same{ .only = &only };
try h.app.bus.on("tool", .{ .ctx = &same, .callback = Same.cb });
try h.app.bus.on("tool_details", .{ .ctx = &same, .callback = Same.cb });
try h.app.bus.on("tool_call_complete", .{ .ctx = &same, .callback = Same.cb });
const Released = struct {
count: usize = 0,
fn rel(ctx: *anyopaque, old: Component) void {
_ = old;
const r: *@This() = @ptrCast(@alignCast(ctx));
r.count += 1;
}
};
var released = Released{};
h.app.setOverrideRelease(&released, Released.rel);
// block_start: the `tool` handler sets `only` (installed via pushEntryFired,
// which does not call the release hook on the first set).
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
try testing.expect(h.app.transcript.items[0].override.?.ptr == only.comp().ptr);
try testing.expectEqual(@as(usize, 0), released.count);
// tool_details: the handler sets `only` AGAIN (same ptr) => no release.
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } });
try testing.expectEqual(@as(usize, 0), released.count);
// tool_call_complete: same ptr once more => still no release.
var tu = panto.ToolUseBlock{
.id = try alloc.dupe(u8, "a"),
.name = try alloc.dupe(u8, "read"),
.input = .empty,
};
defer tu.deinit(alloc);
try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .ToolUse = tu } } });
try testing.expectEqual(@as(usize, 0), released.count);
// The slot still renders the same component, untouched.
try testing.expect(h.app.transcript.items[0].override.?.ptr == only.comp().ptr);
}
test "event wiring: two concurrent tool calls each get + release their own override independently" {
// No "active component": two ToolUse blocks are live at once, each keyed by
// its own index/id. A handler swaps a per-call override on EACH at
// `tool`, then swaps AGAIN on EACH at `tool_details`. The two slots must
// release independently and with no cross-talk: slot 0's first override is
// released when slot 0's second replaces it, and likewise for slot 1 —
// never one slot releasing the other's component.
const alloc = testing.allocator;
const h = try Harness.make(alloc);
defer h.teardown(alloc);
// Per-slot first/second markers (4 total).
var a0 = MarkerComponent.init(alloc, "A0");
defer a0.deinit();
var a1 = MarkerComponent.init(alloc, "A1");
defer a1.deinit();
var b0 = MarkerComponent.init(alloc, "B0");
defer b0.deinit();
var b1 = MarkerComponent.init(alloc, "B1");
defer b1.deinit();
// `tool` (start) sets the FIRST per-slot marker (a0 for index 0, b0 for 1).
// `tool_details` sets the SECOND (a1 / b1), superseding the first.
const Swap = struct {
a0: *MarkerComponent,
a1: *MarkerComponent,
b0: *MarkerComponent,
b1: *MarkerComponent,
fn at_tool(ctx: *anyopaque, ev: *ui_event.Event) void {
const self: *@This() = @ptrCast(@alignCast(ctx));
switch (ev.payload.tool.index) {
0 => ev.setComponent(self.a0.comp()),
1 => ev.setComponent(self.b0.comp()),
else => {},
}
}
fn at_details(ctx: *anyopaque, ev: *ui_event.Event) void {
const self: *@This() = @ptrCast(@alignCast(ctx));
switch (ev.payload.tool.index) {
0 => ev.setComponent(self.a1.comp()),
1 => ev.setComponent(self.b1.comp()),
else => {},
}
}
};
var swap = Swap{ .a0 = &a0, .a1 = &a1, .b0 = &b0, .b1 = &b1 };
try h.app.bus.on("tool", .{ .ctx = &swap, .callback = Swap.at_tool });
try h.app.bus.on("tool_details", .{ .ctx = &swap, .callback = Swap.at_details });
// Record every released component ptr.
const Released = struct {
ptrs: [8]?*anyopaque = .{null} ** 8,
n: usize = 0,
fn rel(ctx: *anyopaque, old: Component) void {
const r: *@This() = @ptrCast(@alignCast(ctx));
if (r.n < r.ptrs.len) r.ptrs[r.n] = old.ptr;
r.n += 1;
}
};
var released = Released{};
h.app.setOverrideRelease(&released, Released.rel);
// Both calls start; each gets its FIRST override. No releases yet.
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 1 } });
try testing.expectEqual(@as(usize, 0), released.n);
try testing.expect(h.app.transcript.items[0].override.?.ptr == a0.comp().ptr);
try testing.expect(h.app.transcript.items[1].override.?.ptr == b0.comp().ptr);
// Slot 1 resolves first: b1 supersedes b0 => exactly b0 released.
try h.app.routeEvent(.{ .tool_details = .{ .index = 1, .id = "B", .name = "write" } });
try testing.expectEqual(@as(usize, 1), released.n);
try testing.expect(released.ptrs[0] == b0.comp().ptr);
// Slot 0 is untouched (no cross-talk).
try testing.expect(h.app.transcript.items[0].override.?.ptr == a0.comp().ptr);
try testing.expect(h.app.transcript.items[1].override.?.ptr == b1.comp().ptr);
// Slot 0 resolves: a1 supersedes a0 => exactly a0 released.
try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "A", .name = "read" } });
try testing.expectEqual(@as(usize, 2), released.n);
try testing.expect(released.ptrs[1] == a0.comp().ptr);
try testing.expect(h.app.transcript.items[0].override.?.ptr == a1.comp().ptr);
try testing.expect(h.app.transcript.items[1].override.?.ptr == b1.comp().ptr);
// Only the two FIRST overrides were ever released; the two SECOND ones
// remain live and owned by the test markers (no spurious cross-release).
try testing.expectEqual(@as(usize, 2), released.n);
}
test "splitEditorArgv: splits flags, appends the path, and falls back to vi" {
const alloc = testing.allocator;
// Bare editor name: [editor, path].
{
var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(alloc);
try splitEditorArgv(alloc, "nvim", "/tmp/panto-edit-1.md", &argv);
try testing.expectEqual(@as(usize, 2), argv.items.len);
try testing.expectEqualStrings("nvim", argv.items[0]);
try testing.expectEqualStrings("/tmp/panto-edit-1.md", argv.items[1]);
}
// Editor with flags: each space-delimited token is its own argv entry,
// then the path is last (e.g. "code -w" -> [code, -w, path]).
{
var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(alloc);
try splitEditorArgv(alloc, "code -w", "/tmp/x.md", &argv);
try testing.expectEqual(@as(usize, 3), argv.items.len);
try testing.expectEqualStrings("code", argv.items[0]);
try testing.expectEqualStrings("-w", argv.items[1]);
try testing.expectEqualStrings("/tmp/x.md", argv.items[2]);
}
// Empty editor string: falls back to vi, then the path.
{
var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(alloc);
try splitEditorArgv(alloc, "", "/tmp/y.md", &argv);
try testing.expectEqual(@as(usize, 2), argv.items.len);
try testing.expectEqualStrings("vi", argv.items[0]);
try testing.expectEqualStrings("/tmp/y.md", argv.items[1]);
}
}
test "session token bucket helper initializes new buckets at zero" {
var buckets: std.StringHashMapUnmanaged(u64) = .empty;
defer {
var kit = buckets.keyIterator();
while (kit.next()) |k| testing.allocator.free(k.*);
buckets.deinit(testing.allocator);
}
addTokenBucket(testing.allocator, &buckets, "anthropic:haiku", 5);
addTokenBucket(testing.allocator, &buckets, "anthropic:haiku", 7);
var it = buckets.iterator();
const entry = it.next() orelse unreachable;
try testing.expectEqualStrings("anthropic:haiku", entry.key_ptr.*);
try testing.expectEqual(@as(u64, 12), entry.value_ptr.*);
}
|