abstractcode 0.5.0

A reactive terminal client for AbstractGateway: durable coding-agent runs with live activity, tool approvals, steering, and sessions — rendered by AbstractTUI.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
//! App-scale reactive state: a store struct of signals provided as context.
//!
//! All signals are written on the UI thread only — worker threads post
//! closures through `WakeHandle` (the engine rule).

use std::sync::Arc;
use std::time::Instant;

use abstracttui::prelude::*;
use abstracttui::widgets::Bitmap;

use crate::transcript::Fold;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
    Idle,
    Starting,
    Running,
}

/// Gateway connection state. `Down` carries the evidence-worded message
/// (from `GwError`'s kind-aware `Display`) plus `gone: bool` — `true` only
/// on connect-level proof (refused/DNS/host-down, `GwError::is_gone()`),
/// `false` when the down-mark came from the soft-failure threshold
/// (repeated timeouts against a gateway that may just be busy). Display
/// sites use the flag to pick honest words ("unreachable" vs "not
/// responding") instead of re-deriving from message substrings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Conn {
    Unknown,
    Ok,
    Down(String, bool),
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Workflow {
    pub bundle_id: String,
    pub flow_id: String,
    pub name: String,
    pub description: String,
}

impl Workflow {
    /// Whether this workflow exposes the human-approval gating control
    /// (the multi-agent coder's `gating_mode` pin). Heuristic on the
    /// bundle id today — the coder is the one gating-capable workflow;
    /// this is the SINGLE place to swap for a gateway-served capability
    /// marker when flow ships one (the design's `abstractcode.gated.v1`
    /// interface), so the modal trigger moves in one edit.
    pub fn supports_gating(&self) -> bool {
        self.bundle_id.contains("multiagent-coding") || self.flow_id.contains("multiagent-cod")
    }

    pub fn label(&self) -> String {
        if self.name.is_empty() {
            format!("{}:{}", self.bundle_id, self.flow_id)
        } else {
            self.name.clone()
        }
    }
}

/// What the capability probe learned about one (provider, model) pair.
/// `supported: None` = probe failed or capability genuinely unknown —
/// the picker OFFERS with a caveat (three-state coupling, contract v1);
/// it never fabricates a lock from absence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReasoningProbe {
    pub provider: String,
    pub model: String,
    pub supported: Option<bool>,
    pub levels: Vec<String>,
    /// Match provenance when served ("exact"/"alias"/"default"/"" —
    /// core's capability_source ask; empty until the registry serves it).
    pub source: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProviderInfo {
    pub name: String,
    pub models: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ToolInfo {
    pub name: String,
    pub description: String,
    /// Gateway grouping ("files", "web", "system", MCP server name, …).
    pub toolset: String,
    /// Server-served capability tier ("tier2_world", …) — informational
    /// (`None` until the gateway bounce that adds the field). ALL
    /// core-registry tools are tier2_world by the ruled boundary; the
    /// finer approval dial below is the real discriminator.
    pub tier: Option<String>,
    /// Server-served per-tool approval default ("auto" | "ask"). `None` =
    /// not served (the client name table classifies instead).
    pub approval: Option<String>,
    /// Served `risk_rank` (core's band: observe=1 act=2 outreach=3
    /// destroy=4). Floors the tier mapping — see
    /// `tool_policy::server_tier` (the c5028 transitional-belt rule).
    pub risk_rank: Option<u8>,
    /// Served `enabled: false` (tool-tiers item H, the full-catalog
    /// surfacing fix — this seat's c4555 consumer commitment): the row
    /// EXISTS on the gateway but a gate disables it. VISIBLE, never
    /// grantable: the /tools modal renders it with its gate and refuses
    /// toggles/pins; run allowlists and the auto-approve expansion
    /// exclude it (disabled rows always ask — the F3 clamp, client
    /// side). `false` (the derive default) = enabled: the gateway only
    /// stamps the field on disabled rows.
    pub served_disabled: bool,
    /// The named gate that would enable a served-disabled row (env var /
    /// config knob — served as `enable_gate`).
    pub enable_gate: String,
    /// The gateway's one-line reason for the disablement.
    pub why_disabled: String,
}

/// What a session's runs say it is doing RIGHT NOW, folded from the
/// gateway's own run summaries — never from anything this client
/// remembers. Ordered by how much it wants a human: a session with one
/// paused run and nine finished ones is Paused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionState {
    /// A run is blocked on a human — an approval or an ask. (A PAUSED
    /// run reaches the client as `waiting` too: `GET /runs` hardcodes
    /// `"paused": False` on its index-row fast path
    /// — `routes/gateway.py:8007`, the only `paused` in the whole
    /// listing response — so a separate Paused state could never be
    /// derived here. It was removed rather than left as a variant
    /// nothing can produce; reading real pause state needs the gateway
    /// to report it on the listing.)
    Waiting,
    Running,
    Failed,
    /// Every run reached a terminal state.
    Done,
    /// The gateway reported a status this client has no word for (an
    /// empty string from a malformed index row, or a status added
    /// server-side since this build). NOT a verdict: it never beats a
    /// known state in the fold, and it renders as absent.
    Unknown,
}

impl SessionState {
    pub fn label(self) -> &'static str {
        match self {
            SessionState::Waiting => "waiting on you",
            SessionState::Running => "running",
            SessionState::Failed => "failed",
            SessionState::Done => "done",
            SessionState::Unknown => "",
        }
    }

    /// How much this state wants a human — lower folds over higher, so
    /// one waiting run makes the whole session "waiting on you".
    /// `Unknown` ranks LAST: an unreported status contributes nothing
    /// rather than dragging a live session down to a guess.
    pub fn rank(self) -> u8 {
        match self {
            SessionState::Waiting => 0,
            SessionState::Running => 1,
            SessionState::Failed => 2,
            SessionState::Done => 3,
            SessionState::Unknown => 4,
        }
    }

    /// True where the session still wants something from you — the
    /// rows the picker sorts to the top.
    pub fn is_live(self) -> bool {
        matches!(self, SessionState::Waiting | SessionState::Running)
    }
}

/// One session as the GATEWAY reports it (`/sessions` discovery). The
/// human label is NOT here: it is the session's first prompt, which
/// only the local prefs remember, and merging the two is the picker's
/// job — so a row this client has never seen still renders honestly
/// with its id and its live state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionRow {
    pub id: String,
    pub state: SessionState,
    /// `updated_at` of the session's NEWEST root run (RFC3339, as the
    /// gateway wrote it) — the sort key and the "when" column. The
    /// gateway pages by this field, so the board's order matches the
    /// page's membership.
    pub last_at: String,
    /// Root runs the gateway listed for this session. A root run IS a
    /// turn, so this is the session's turn count — EXACT when the
    /// listing was complete (`SessionIndex::Loaded.truncated == false`)
    /// and a floor when it was not. The picker says which.
    pub turns: usize,
    /// The session's FIRST root run — the turn whose prompt names the
    /// session. (Its newest run is not needed once the board sorts and
    /// stamps by `last_at`.)
    pub first_run: String,
    /// The session's opening prompt, FROM THE GATEWAY (`input_data`).
    /// `None` = not fetched (the listing itself carries no prompt: the
    /// `/runs` summary has no such field, and neither does the session
    /// turn list, so it costs one request per session and is bounded).
    pub prompt: Option<String>,
}

/// The gateway's session listing, as a THREE-state fact — the absence
/// of an answer is never rendered as an empty gateway (ADR 0001: a
/// blank list would say "you have no sessions there" about a fetch that
/// has not happened or has failed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionIndex {
    /// Never asked (the picker asks at open).
    Unfetched,
    /// A request is in flight — the picker shows local rows and says
    /// the live column is still arriving.
    Loading,
    /// The gateway answered. `truncated` = the listing hit its limit,
    /// so older sessions exist beyond these rows and the picker says so.
    Loaded {
        rows: Vec<SessionRow>,
        truncated: bool,
        /// How many of `rows` the prompt pass covers. Beyond this the
        /// board has no prompt to show and SAYS so, instead of leaving
        /// one glyph to mean "still arriving", "outside the bound" and
        /// "genuinely none" at once (review D3).
        labeled: usize,
    },
    /// The gateway refused or could not be reached; the message is the
    /// evidence-worded one from `GwError`. Local rows still render —
    /// with the live column marked unavailable, never faked.
    Failed(String),
}

/// The two durable verbs the quit modal can send (leave sends nothing).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuitVerb {
    Pause,
    Cancel,
}

/// Quit-with-live-run state machine (design: untracked/reviews/
/// quit-modal-design.md). `None` = no quit in flight; `Choosing` = the
/// modal is up; `Delivering` = a verb was sent and the app quits only
/// on the gateway's ACCEPTANCE (the durable command store's 2xx —
/// never "the run finished pausing"); `Failed` = honest failure state
/// offering quit-anyway/stay.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum QuitState {
    #[default]
    None,
    Choosing,
    Delivering {
        verb: QuitVerb,
        run_id: String,
        gen: u64,
    },
    /// The gateway ACCEPTED the verb; the app is exiting — exists so
    /// the post-teardown echo can say "paused durably"/"cancel
    /// accepted" instead of misreading a resolved delivery as
    /// unconfirmed.
    Acked {
        verb: QuitVerb,
        run_id: String,
    },
    Failed {
        verb: QuitVerb,
        run_id: String,
        /// True when the failure is DEFINITIVE (the gateway answered
        /// with an error / the worker is dead) — the command will NOT
        /// land. False = timeout: the request may still be in flight
        /// in this app and can land if the user stays. The Failed copy
        /// splits on it (audit P2: "may still land" was false for the
        /// definitive arm).
        definitive: bool,
        error: String,
    },
}

/// Structured pause/cancel outcome (the quit sequencer's ack channel).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerbAck {
    pub verb: QuitVerb,
    pub run_id: String,
    pub ok: bool,
    /// On failure: TRUE when the command definitively did NOT land
    /// (every attempt was refused/answered — server spoke or connect
    /// never made); FALSE when any attempt was AMBIGUOUS (timeout /
    /// transport after the request may have left — the command may
    /// have landed with only the response lost). Derived from error
    /// CLASSES by the send authority, never from message text (D2:
    /// a blanket `definitive: true` overclaimed for transport-
    /// exhausted retries).
    pub definitive: bool,
    pub error: String,
}

/// One file staged for the NEXT plain-prompt send (attachments design
/// §4.1). Validated at ATTACH (exists, regular, ≤ cap when known);
/// uploaded at SEND on the worker thread — send-time upload is the only
/// shape that survives `/new` session rotation and makes removing a chip
/// a true no-op (session uploads are permanent server-side).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PendingAttachment {
    /// Absolute, canonicalized at attach.
    pub path: String,
    /// File name for chips + notices.
    pub name: String,
    /// Attach-time stat (display + pre-check; the upload re-reads).
    pub size: u64,
    /// Send-time upload result cached for retry-after-start-failure:
    /// (session_id at upload, the WHOLE ref object). A cached ref is
    /// reused only while the session matches — never re-uploaded, never
    /// carried across sessions.
    pub uploaded: Option<(String, serde_json::Value)>,
}

/// The ONE `ToolInfo → ToolClass` projection (policy-relevant fields
/// only; `why_disabled` is render-side and deliberately not carried).
/// It was hand-copied in `Store::tool_classes` and exec's discovery
/// mapping — when `risk_rank` landed, both sites had to be found and
/// touched (the agreement-by-coincidence class this codebase names).
impl From<&ToolInfo> for crate::tool_policy::ToolClass {
    fn from(t: &ToolInfo) -> Self {
        crate::tool_policy::ToolClass {
            name: t.name.clone(),
            approval: t.approval.clone(),
            tier: t.tier.clone(),
            served_disabled: t.served_disabled,
            enable_gate: t.enable_gate.clone(),
            risk_rank: t.risk_rank,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SkillInfo {
    pub name: String,
    pub description: String,
    pub trust: String,
    pub blocked: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct McpServer {
    pub name: String,
    pub url: String,
    pub description: String,
    pub auth_required: bool,
}

/// Prompt-cache posture for the effective provider/model route.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CacheInfo {
    pub provider: String,
    pub model: String,
    pub supported: bool,
    /// "keyed" | "local" | … (the gateway's capability answer).
    pub mode: String,
}

/// One GPU utilization sample from the gateway host (`/host/metrics/gpu`).
#[derive(Debug, Clone, PartialEq)]
pub struct GpuSample {
    /// Top-level `utilization_gpu_pct` (0–100).
    pub util_pct: f64,
    /// First GPU's name ("Apple M5 Max"); empty when the host names none.
    pub name: String,
}

/// `/gpu` meter state (OBS-6). The DATA half lives here — the poller
/// thread (`gateway::gpu`) posts transitions; the status-bar render
/// matches on this enum. Honesty contract: `Unsupported` means the host
/// SAID so (`supported:false`, or the endpoint is absent) and polling
/// has STOPPED — the meter must never fabricate a number; `Error` keeps
/// the last failure visible while polling continues (transient).
#[derive(Debug, Clone, PartialEq, Default)]
pub enum GpuMeter {
    /// Toggled off (`/gpu`) — zero polling, renders nothing.
    #[default]
    Off,
    /// Enabled; the first sample is in flight.
    Pending,
    Ready(GpuSample),
    /// The gateway host cannot serve GPU metrics (reason). Poller stopped.
    Unsupported(String),
    /// The last poll failed (message); the poller keeps trying.
    Error(String),
}

/// Which host/resource CONTRACTS this gateway declares
/// (`/discovery/capabilities` → `contracts.common`). The `/resources`
/// surface is gated on `host_state`: absent contract = the modal says
/// "not supported by this gateway", never a fabricated view. `None` in
/// the store means the capabilities fetch has not answered yet (still
/// probing); `Some(default())` means the gateway ANSWERED and declares
/// none of these.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HostContracts {
    pub model_residency: bool,
    pub host_state: bool,
    pub session_caches: bool,
    /// task → display label from `model_residency.modality_ui.colors`
    /// (`{task: {color, label}}`). The hex color is deliberately DROPPED:
    /// this client styles with theme inks only — modality is
    /// distinguished by LABEL text.
    pub modality_labels: Vec<(String, String)>,
}

impl HostContracts {
    /// The served label for a task, or the task id itself.
    pub fn label_for<'a>(&'a self, task: &'a str) -> &'a str {
        self.modality_labels
            .iter()
            .find(|(t, _)| t == task)
            .map(|(_, l)| l.as_str())
            .unwrap_or(task)
    }
}

/// One resident-model row (`/host/state` → `models[]`, row_v1). Every
/// numeric field is `Option` — the wire fields are nullable and absence
/// renders as absence, never as a fabricated zero. `resident` is
/// TRI-STATE by contract: `None` = the gateway does not know — rendered
/// distinct from "no".
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ResidencyRow {
    pub runtime_id: String,
    pub task: String,
    pub provider: String,
    pub model: String,
    pub source: String,
    pub resident: Option<bool>,
    pub state: String,
    pub locked: bool,
    /// TRI-STATE like `resident`: `None` = the gateway did not say.
    /// It must NOT read as "not lockable" — `POST /models/lock` now
    /// ADOPTS externally-loaded (sweep) models, and those rows are
    /// exactly the ones that arrive with a null here.
    pub lockable: Option<bool>,
    pub modalities: Vec<String>,
    pub size_bytes: Option<u64>,
    pub size_vram_bytes: Option<u64>,
    /// The gateway's ESTIMATE of the weight footprint (derived from the
    /// artifact, not measured on the host) — the last resort of
    /// [`ResidencyRow::display_size`], and why that helper hands back an
    /// "estimated" flag instead of a bare number.
    pub est_weights_bytes: Option<u64>,
    /// KV/prompt cache the runtime holds FOR THIS MODEL — a SECOND
    /// figure beside the weights, never folded into the size.
    pub cache_bytes: Option<u64>,
    pub context_length: Option<u64>,
    pub calibrated_context_length: Option<u64>,
    pub context_calibrated: bool,
    pub is_default: bool,
    pub loaded_at: String,
    pub last_used_at: String,
}

impl ResidencyRow {
    /// THE DISPLAY-SIZE COALESCE, shared by every surface that prints a
    /// per-model footprint: first KNOWN of `size_bytes` →
    /// `size_vram_bytes` → `est_weights_bytes`. The flag is `true` only
    /// for the third — an estimate, not a measurement — so no renderer
    /// can present it as reported (the `~` marker).
    pub fn display_size(&self) -> Option<(u64, bool)> {
        self.display_size_source().map(|(b, est, _)| (b, est))
    }

    /// [`ResidencyRow::display_size`] plus WHICH FIELD supplied the
    /// number, spelled as the breakdown's source phrase. Defined here —
    /// and `display_size` defined in terms of it — so the coalesce order
    /// and the sentence naming it can never drift apart.
    pub fn display_size_source(&self) -> Option<(u64, bool, &'static str)> {
        self.size_bytes
            .map(|b| (b, false, "reported by the model server (size_bytes)"))
            .or(self
                .size_vram_bytes
                .map(|b| (b, false, "reported by the model server (size_vram_bytes)")))
            .or(self
                .est_weights_bytes
                .map(|b| (b, true, "estimated on-disk weight size (est_weights_bytes)")))
    }

    /// Does this row CLAIM residency? `resident` is tri-state: only an
    /// explicit `true` is a yes (a null is unknown, never a yes).
    pub fn is_resident(&self) -> bool {
        self.resident == Some(true)
    }
}

/// What the `k` key offers on a row — the ONE authority behind the key
/// handler, the row's own hint and the tests.
///
/// The rule: EVERY resident line offers the lock verb, sweep rows
/// included, because `POST /models/lock` now ADOPTS a model LM Studio or
/// ollama loaded. `locked` outranks residency, so a locked-but-evicted
/// row keeps its Unlock. A row that is not resident carries no lock verb
/// at all — and the refusal names why rather than being a dead key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LockAction {
    Unlock,
    /// Lock; `adopt` when the row looks externally loaded.
    Lock {
        adopt: bool,
    },
    Refused(&'static str),
}

pub fn lock_action(r: &ResidencyRow) -> LockAction {
    if r.locked {
        return LockAction::Unlock;
    }
    if !r.is_resident() {
        return LockAction::Refused(match r.resident {
            Some(false) => "not resident — only a loaded model can be locked",
            _ => "residency unknown — r refreshes before locking",
        });
    }
    if r.lockable == Some(false) {
        return LockAction::Refused("this gateway reports the model as not lockable");
    }
    // `lockable: null` is UNKNOWN, never a "no": the gateway is the
    // authority, and it adopts externally-loaded models.
    LockAction::Lock {
        // `provider_server` is the ONE string the gateway stamps on a
        // host-sweep row (core `server/app.py`, runtime
        // `_merge_host_sweep_into_text_records`), and the ONE selector
        // every surface keys the adopt wording off (spec PART D1).
        // `sweep`/`external` were tolerated aliases the wire never emits;
        // accepting them only made a fixture disagree with the wire.
        adopt: r.source == "provider_server",
    }
}

/// May the unload verb target this row? A row the host says is NOT
/// resident has nothing to unload; an UNKNOWN residency still may — the
/// tri-state's third answer is not a "no", and the gateway is the
/// authority on what it holds.
pub fn unload_refusal(r: &ResidencyRow) -> Option<&'static str> {
    match r.resident {
        Some(false) => Some("not resident — there is nothing to unload"),
        _ => None,
    }
}

/// One session prompt-cache row (`/host/state` → `session_caches[]`).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SessionCacheRow {
    pub key: String,
    pub provider: String,
    pub model: String,
    pub session_id: String,
    pub bytes: Option<u64>,
    pub token_count: Option<u64>,
}

/// Parsed `/host/state` facts. Same honesty contract as [`GpuMeter`]:
/// unknown → `None`/empty, never a fabricated number.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct HostFacts {
    /// The gateway's own `ts` for this snapshot (served verbatim; "" =
    /// not reported). Carried so staleness is VISIBLE: after a failed
    /// refresh the view says "as of <ts>" instead of rendering last-good
    /// numbers indistinguishable from fresh ones.
    pub ts: String,
    /// `memory.host.host_name` when served; "" = not reported.
    pub host_name: String,
    pub ram_total: Option<u64>,
    pub ram_used: Option<u64>,
    pub ram_available: Option<u64>,
    pub ram_percent: Option<f64>,
    /// This gateway process's RSS.
    pub process_rss: Option<u64>,
    /// `memory.device.backend` ("mlx", "cuda", …); "" = not reported.
    pub device_backend: String,
    /// PROCESS-LOCAL allocation. On Metal this counts only what THIS
    /// gateway process allocated: the live host serves `0` here while a
    /// 93 GB GGUF is resident under LM Studio. Never the meter's first
    /// choice — see [`HostFacts::device_meter`].
    pub device_allocated: Option<u64>,
    pub device_total: Option<u64>,
    pub device_free: Option<u64>,
    /// ACCELERATOR-HEAP bytes in use across ALL PROCESSES (ioreg "In use
    /// system memory"), and the OS's wired limit for it. It is a genuine
    /// accelerator counter — and it is BLIND to memory-mapped GGUF
    /// weights, so it is never the machine's memory use and never a
    /// denominator for "how full is this host".
    pub device_host_in_use: Option<u64>,
    pub device_wired_limit: Option<u64>,
    /// `gpu.supported` — false/absent = no GPU number is rendered.
    pub gpu_supported: bool,
    pub gpu_util_pct: Option<f64>,
    pub models: Vec<ResidencyRow>,
    pub caches: Vec<SessionCacheRow>,
    /// Numeric totals as served (key, value) — `*_bytes` keys humanize
    /// at render; non-numeric totals are omitted (unknown = omission).
    pub totals: Vec<(String, u64)>,
    /// Degraded lanes, each folded with its reason when `reasons{}`
    /// names one ("gpu: ioreg unavailable").
    pub degraded: Vec<String>,
}

/// WHOSE accelerator-heap bytes a meter is showing. The scope MUST ride
/// with the number: a process-local figure presented as everyone's is
/// the exact lie that made this meter read "0 B allocated" beside a full
/// machine. The scope words are `all processes` / `this process only` —
/// never the word "host" in any spelling, and nothing else that reads as
/// whole-system usage, because this counter is the ACCELERATOR HEAP and
/// is blind to memory-mapped GGUF weights (spec PART A3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceScope {
    /// `host_in_use_bytes / wired_limit_bytes` — every process.
    AllProcesses,
    /// `allocated_bytes / total_bytes` — this gateway process only.
    Process,
}

impl DeviceScope {
    pub fn label(self) -> &'static str {
        match self {
            DeviceScope::AllProcesses => "all processes",
            DeviceScope::Process => "this process only",
        }
    }
}

impl HostFacts {
    /// THE ACCELERATOR FIGURE (spec PART A2): the all-processes reading
    /// wins wherever it exists; the process-local one is the LABELLED
    /// fallback; absence of both is `None` — a figure is never drawn
    /// from nothing. The ceiling is OPTIONAL: a `used` with no ceiling
    /// is still a fact, it just cannot draw a bar.
    pub fn accelerator_figure(&self) -> Option<(u64, Option<u64>, DeviceScope)> {
        let ceiling = |c: Option<u64>| c.filter(|t| *t > 0);
        if let Some(used) = self.device_host_in_use {
            // An all-processes figure with no wired limit still beats the
            // process-local one: the device total is its denominator.
            return Some((
                used,
                ceiling(self.device_wired_limit).or_else(|| ceiling(self.device_total)),
                DeviceScope::AllProcesses,
            ));
        }
        self.device_allocated
            .map(|used| (used, ceiling(self.device_total), DeviceScope::Process))
    }

    /// THE DEVICE-METER RULE: [`HostFacts::accelerator_figure`] narrowed
    /// to the pairs that can actually draw a BAR (a known ceiling).
    pub fn device_meter(&self) -> Option<(u64, u64, DeviceScope)> {
        self.accelerator_figure()
            .and_then(|(used, ceiling, scope)| ceiling.map(|t| (used, t, scope)))
    }
}

/// `/resources` host-state lane (the [`GpuMeter`] shape): written on the
/// UI thread only; the worker posts transitions through the wake handle.
/// `Unsupported` means the gateway SAID so (404 / contract absent) —
/// the view never fabricates a state.
#[derive(Debug, Clone, PartialEq, Default)]
pub enum HostState {
    /// Never fetched (the modal open triggers the first fetch).
    #[default]
    Idle,
    /// A fetch is in flight.
    Pending,
    Ready(HostFacts),
    /// A refresh FAILED but an earlier fetch is still held: the facts
    /// were true when taken and stay visible, MARKED stale (footer `*`,
    /// modal banner) — last-good must never render indistinguishable
    /// from fresh.
    Stale(HostFacts),
    /// The endpoint is not on this gateway (reason).
    Unsupported(String),
    /// The fetch failed (message); a later refresh may succeed.
    Error(String),
}

/// Upper bound on retained image entries (F3): with decode-time
/// downscaling (`runner::downscale_for_transcript`) each entry is
/// ≤ ~0.7 MB, so the worst case stays ~22 MB instead of unbounded
/// full-resolution bitmaps across a whole session.
pub const IMAGE_ENTRY_CAP: usize = 32;

#[derive(Clone)]
pub struct ImageEntry {
    pub artifact_id: String,
    pub bitmap: Option<Arc<Bitmap>>,
    pub error: String,
}

/// One queued prompt (`/queue <text>`): FIFO. PERSISTED per session id
/// (prefs `session_queues` slot, write-through on every mutation) — the
/// queue contract is "piling up requests that each gets executed", and a
/// silent drop at quit broke it. Safety is the RESTORE POSTURE, not
/// non-persistence: any restore (boot, session switch) lands PAUSED and
/// never auto-starts, so persistence costs zero unattended token spend.
///
/// Thin-client conformance (class ii, 2026-07-23): queued prompts are
/// CLIENT-HELD future work — un-submitted composer text, plural. The
/// gateway has NO completion-chained queue primitive today
/// (`POST /runs/schedule` is time-based only), so nothing server-side
/// could hold them; other apps see the work only once each item starts
/// as a normal traceable gateway run. The `/queue` help line names this
/// locality; the server-side primitive ask is on the record in
/// docs/roadmap/conformance-ledger-asks.md.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueuedPrompt {
    /// Stable identity for modal edits (remove/reorder survive drains).
    pub id: u64,
    pub text: String,
}

/// Text buffered while the run has NO cycling target yet (Starting, or
/// Running before the first reason-cycle record). Delivery keys on the
/// fold's `cycling_target()` landing PLUS a run-identity predicate — a
/// root-targeted steer is silently never folded on wrapper bundles (the
/// agent loop drains guidance in a SUBRUN), and a stale previous-run
/// cycle must never satisfy delivery.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingSteer {
    /// `fold.root_run_id()` at buffer time.
    pub armed_at_root: String,
    /// Armed during Starting: deliver only once a NEW root began
    /// (`root != armed_at_root`). Armed while Running: deliver only
    /// while the SAME root lives (`root == armed_at_root`).
    pub armed_while_starting: bool,
    pub text: String,
}

/// The active `/goal` run for this session (client half of the goal-agent
/// contract; the bundle is flow-seat-owned and may not be published yet).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GoalState {
    pub text: String,
    /// Empty while the start is in flight; bound by `wire_goal` when the
    /// run reaches Running (starts are phase-serialized, so the next
    /// Running run IS the goal run).
    pub run_id: String,
}

/// How the last run ended — written by the runner at terminal points,
/// CONSUMED (reset to None) by the queue-drain effect. Take-semantics
/// makes the drain edge-triggered: a resume must not re-pause against a
/// stale Failed, and a replayed Success must not double-drain.
///
/// Semantics note (thin-client conformance): `Success` means "the TURN
/// concluded with a usable conclusion" — on wrapper bundles the ROOT run
/// may still be open on the gateway at that moment (it finalizes
/// server-side; the transcript overlay says so). This mailbox is client
/// scheduling state, never a claim about the root's server status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RunOutcome {
    #[default]
    None,
    Success,
    /// The turn ENDED without finishing — the loop stopped it (iteration
    /// budget, stuck loop, …). Not a failure and not a success: the work is
    /// incomplete, so the queue must not stack the next prompt on top of it.
    /// `/help` promises the queue "auto-runs after the current run succeeds";
    /// folding this into `Success` broke that promise on the one outcome
    /// where continuing is most likely to compound the problem.
    StoppedShort,
    Failed,
    Cancelled,
}

impl RunOutcome {
    /// The ONE mapping from a concluded turn's facts to the outcome the
    /// queue reads. A pure function on purpose: the two conclusion sites in
    /// `runner.rs` live inside async closures that no test reaches, and the
    /// decision they were making — "unfinished counts as success" — was
    /// invisible until an auditor read it.
    pub fn for_conclusion(failed: bool, cancelled: bool, stopped_short: bool) -> Self {
        if failed {
            RunOutcome::Failed
        } else if cancelled {
            RunOutcome::Cancelled
        } else if stopped_short {
            RunOutcome::StoppedShort
        } else {
            RunOutcome::Success
        }
    }

    /// The turn did not complete its work: the queue must hold rather than
    /// stack the next prompt on top of it.
    pub fn holds_the_queue(self) -> bool {
        matches!(
            self,
            RunOutcome::Failed | RunOutcome::Cancelled | RunOutcome::StoppedShort
        )
    }
}

/// Session-scope token totals (across runs; per-run stats live in the fold).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SessionTotals {
    pub input_tokens: u64,
    pub output_tokens: u64,
    /// Cumulative total tokens: the only honest number for providers that
    /// report no input/output split (the coder-run shape, bug (e)).
    pub total_tokens: u64,
    pub runs: u64,
}

#[derive(Clone, Copy)]
pub struct Store {
    pub fold: Signal<Fold>,
    pub phase: Signal<Phase>,
    pub conn: Signal<Conn>,
    pub session_id: Signal<String>,
    pub run_id: Signal<String>,
    pub workflow: Signal<Workflow>,
    pub workflows: Signal<Vec<Workflow>>,
    pub provider: Signal<String>,
    pub model: Signal<String>,
    /// Gating mode for the current session ("" = the workflow default,
    /// gated; "auto" = unattended, skip the workflow's human pauses). Set
    /// by the workflow-select modal or /gating; sent as input_data.gating_mode.
    pub gating_mode: Signal<String>,
    /// Verifier-before-conclude for this session (`_runtime.review_mode`):
    /// before a tool-call-free response is accepted as final, a strict
    /// verifier re-reads the transcript and can force more tool calls.
    /// Seeded from `--review`/`--no-review` (default ON — abstractcode's
    /// long-standing default) and toggled by `/review`.
    pub review_mode: Signal<bool>,
    /// Verifier round budget (`_runtime.review_max_rounds`); `/review rounds N`.
    pub review_rounds: Signal<u32>,
    /// Iteration budget REQUESTED for new runs (`_limits.max_iterations`);
    /// `0` = ask for nothing and take the server's own, which is what every
    /// other client gets.
    ///
    /// A signal, not a `UiCtx` copy, for the reason stated on
    /// `workspace_mode`: the launch flag seeds it and `/iterations` edits it,
    /// so there is exactly ONE authority and nothing to drift.
    ///
    /// Why it exists at all: when a turn dies on the budget the server's
    /// remedy reads "Raise the iteration budget, or send the remaining work
    /// as a follow-up turn." Until 2026-08-22 the first half of that named
    /// an action this client could not perform — `--max-iterations` is a
    /// LAUNCH flag, `--param max_iterations=` is refused as client-owned
    /// (`cli.rs`), and no command, pref or modal touched it. The operator had
    /// to quit and relaunch to follow the advice they were just given.
    pub max_iterations: Signal<u32>,
    /// Reasoning effort override ("" = gateway default). The third leg
    /// of the route triple; provider/model changes reset it (coupling
    /// rule — an effort may only apply under the model it was chosen
    /// for).
    pub reasoning: Signal<String>,
    /// Per-model reasoning capability probe result for the picker's
    /// third stage: (provider, model, probe). None while in flight.
    pub reasoning_probe: Signal<Option<ReasoningProbe>>,
    pub providers: Signal<Vec<ProviderInfo>>,
    pub tools: Signal<Vec<ToolInfo>>,
    pub tools_error: Signal<String>,
    /// Tools the user switched OFF (persisted per session; `/tools`).
    pub disabled_tools: Signal<Vec<String>>,
    /// EPHEMERAL: this session has no persisted tool-prefs slot yet, so
    /// camera tools (privacy-sensitive) should be seeded OFF once the tool
    /// inventory loads (operator ask: camera off by default). Cleared the
    /// moment the seed runs OR when a session with a saved slot loads —
    /// never re-seeds a session the user has already shaped.
    pub camera_seed_pending: Signal<bool>,
    /// Gateway skill shelf (`/skills`).
    pub skills_catalog: Signal<Vec<SkillInfo>>,
    pub skills_error: Signal<String>,
    /// Skill names attached to every run (persisted; `input_data.skills`).
    pub selected_skills: Signal<Vec<String>>,
    /// Gateway MCP server registry (`/mcp`), plus its honest empty-state note.
    pub mcp_servers: Signal<Vec<McpServer>>,
    pub mcp_note: Signal<String>,
    /// Prompt-cache capability for the effective route (None until probed).
    pub cache: Signal<Option<CacheInfo>>,
    /// `/gpu` meter state (OBS-6): written only by the UI thread (the
    /// poller posts closures through the wake handle, generation-guarded
    /// in `gateway::gpu` so a disabled poller's late sample never lands).
    pub gpu: Signal<GpuMeter>,
    /// `/resources` host state (memory + resident models + caches):
    /// fetched on modal open + explicit refresh only — never polled
    /// (`/host/state` is slow by contract).
    pub host_state: Signal<HostState>,
    /// Which host/resource contracts the gateway declares
    /// (`/discovery/capabilities`); `None` until the boot fetch answers.
    pub host_contracts: Signal<Option<HostContracts>>,
    /// Latest `/models/context_estimate` answer for the `/resources`
    /// modal's inline result line: (subject "provider/model", line).
    pub host_estimate: Signal<Option<(String, String)>>,
    /// OPERATOR-DECLARED context window in tokens (CTX-0; 0 = not
    /// declared). Seeded from `--max-tokens`/prefs; `/context` edits +
    /// persists. Drives the `ctx N/M (P%)` meter — always labeled
    /// "declared", never a client capability table.
    pub context_window: Signal<u64>,
    /// tok/s of the newest COMPLETED llm_call (OBS-1a-live), measured
    /// client-side by `wire_llm_meter`: the cumulative-OUTPUT delta
    /// across the call's started→completed transition over the
    /// client-observed wall window. The numerator is receipt-true in
    /// both usage shapes — splitless receipts add nothing to
    /// `stats.output_tokens`, so they never mint a rate (honest
    /// absence; the cycle-2 P1-A overstatement — total tokens over wall
    /// time — is structurally unreachable). Network + ledger latency
    /// ride the denominator, so a measured rate slightly UNDERSTATES
    /// provider throughput (conservative, labeled "(last call)"). None
    /// until a split-usage call completes; cleared at session
    /// boundaries.
    pub last_call_rate: Signal<Option<f64>>,
    /// The gateway's configured default text route (provider, model) — what
    /// "gateway defaults" actually resolves to (capability input.text route).
    pub default_route: Signal<(String, String)>,
    pub images: Signal<Vec<ImageEntry>>,
    pub totals: Signal<SessionTotals>,
    pub run_started: Signal<Option<Instant>>,
    pub elapsed_secs: Signal<u64>,
    /// Pending toast texts; a UI effect drains them into Toast overlays.
    pub notices: Signal<Vec<String>>,
    /// Bumped by Esc; two within a second cancels the run.
    pub last_esc: Signal<Option<Instant>>,
    /// Transcript VERBOSITY (operator directive 2026-08-19: /details
    /// toggles the full tool call vs. just the call + a status tag).
    /// true = full cards — wrapped args, result bodies, thinking
    /// content + the labeled reasoning channel. false (default) = the
    /// collapsed view — cycle rules + thinking gists + one-line tool
    /// calls with right-aligned status words. The thinking itself and
    /// every called tool stay visible in BOTH states; this signal
    /// gates detail, never existence. Toggled by Ctrl+D / /details;
    /// /details full|fold set it directly.
    /// `/animation` — 0 = the transcript, N = ambient variant N showing
    /// in its place. Session-scoped and opt-in: nothing else writes it,
    /// and Esc or a click puts it back to 0.
    pub animation: Signal<u8>,
    pub show_details: Signal<bool>,
    /// Tool cards whose OUTPUT is expanded inline in the collapsed view
    /// — the per-card override of `show_details` (operator ask,
    /// 2026-08-28: "an icon (+) or equivalent where we could see more
    /// information about a cycle or tool call"). Keyed by
    /// `Fold::tool_key` (`run_id:node_id:index:call_id`), the code's
    /// single authority for card identity: the feed's own `i{index}`
    /// keys are POSITIONS and a truncation drain shifts every one of
    /// them, which would silently move an expansion onto a different
    /// call. A `Vec` rather than a set because it holds a handful of
    /// entries and is cloned once per sync pass.
    ///
    /// Session-scoped and never persisted: expansion is a reading
    /// gesture, not a preference. `/details full` outranks it (every
    /// card is expanded, and the markers stop rendering).
    pub expanded_tools: Signal<Vec<String>>,
    /// The GATEWAY's session listing, fetched when `/sessions` opens
    /// and on `r` — never polled (the `/resources` rule). See
    /// [`SessionIndex`] for why absence is its own state.
    pub session_index: Signal<SessionIndex>,
    /// The active run tree is PAUSED on the gateway (durable /pause).
    pub paused: Signal<bool>,
    /// Files staged for the NEXT plain-prompt send (chips above the
    /// composer). Uploaded at SEND; kept on failure; cleared on started;
    /// discarded (with a notice) at session boundaries.
    pub pending_attachments: Signal<Vec<PendingAttachment>>,
    /// Gateway attachment size cap (`/workspace/policy` →
    /// `max_attachment_bytes`); 0 = unknown → no client pre-refusal
    /// (the server 413 stays the authority).
    pub max_attachment_bytes: Signal<u64>,
    /// Quit-with-live-run flow (quit-modal design, 2026-07-25): the
    /// modal's state machine. `Delivering.gen` guards the timeout job
    /// (a stale timer must never fail a NEWER delivery).
    pub quit_state: Signal<QuitState>,
    /// Structured pause/cancel outcome posted by the ONE send authority
    /// (`runner::send_verb_blocking` — reached from the worker's slash-
    /// command handlers AND the quit modal's dedicated send thread; both
    /// post via wake, so the write lands on the UI thread). The quit
    /// sequencer matches verb + run_id; outside a quit nothing reads
    /// it. Toast text is never matched — the error-substring class
    /// stays banned.
    pub verb_ack: Signal<Option<VerbAck>>,
    /// `/status` server-truth probe result: `(run_id, status-line)` from
    /// a live `get_run` at modal open — client phase vs gateway status
    /// divergence made inspectable (visibility review P2-5). `None` =
    /// no probe yet / probing.
    pub run_status_probe: Signal<Option<(String, String)>>,
    /// /history cursor: `created_at` of the OLDEST restored turn — the
    /// next bloc streams turns strictly before it. None = nothing
    /// restored yet / no older history.
    /// A history bloc is streaming (auto-load or /history) — the stub
    /// line renders progress, the strip names it, and the scroll-top
    /// auto-loader refuses to double-dispatch while true. Reset on
    /// session switches and on every runner completion path.
    pub history_loading: Signal<bool>,
    pub history_cursor: Signal<Option<String>>,
    /// Older turns known to exist on the gateway beyond the restored
    /// bloc (the boot lists wide, fetches the last bloc only — the
    /// ruling's shape). Drives the stub + /history availability.
    pub older_turns: Signal<usize>,
    /// Session-history rehydration in flight (boot / session switch):
    /// the idle strip says "restoring session history…" instead of the
    /// "no runs yet" lie while up to ~21 bundles fetch (visibility
    /// review P2-7), and the transcript pane shows the animated
    /// loading screen (`ui::loading`, operator ask 2026-08-28).
    pub restoring: Signal<bool>,
    /// A session restore that FAILED and may yet succeed — an
    /// EPHEMERAL condition, not a transcript entry (operator,
    /// 2026-08-31: "those kinds of ephemeral warnings/errors would be
    /// better served as temporary modal that disappear if the
    /// connexion is re-established").
    ///
    /// It used to be an `Item::Error` pushed into the fold, which is
    /// the wrong shape three ways: a permanent card for a transient
    /// fact, it survived the reconnection that resolved it, and it
    /// told the user to `/sessions` and re-select by hand when the app
    /// reconnects on its own. The text is `compact_reason()`-class —
    /// URL-free, because a failure label carrying the gateway's own
    /// endpoint is an instruction kit on a screen-share (2026-07-23).
    ///
    /// Cleared by: a successful restore, the Down→Ok reconnect (which
    /// also RETRIES it), a session switch, and `/new`.
    pub restore_failed: Signal<Option<String>>,
    /// The loading screen's counters: `(fetched, total)` prior-turn
    /// bundles of the current ProbeAttach window, posted by the worker
    /// as each bundle lands. `None` while the run list itself is in
    /// flight (the bar sweeps, indeterminate — the denominator is not
    /// yet a fact). Meaningful only while `restoring` is true; cleared
    /// with it on every completion path.
    pub restore_progress: Signal<Option<(usize, usize)>>,
    /// Drop-undo slot: (raw paste text, paths attached from it). Armed
    /// when a dropped-path paste is consumed into chips; Ctrl+O undoes —
    /// removes those chips and puts the RAW text into the composer
    /// (the pasted-path-as-prose escape hatch). One level, newest wins.
    pub paste_undo: Signal<Option<(String, Vec<String>)>>,
    /// The open attachment preview (`/attach preview`, `p` in the
    /// manager). Minted on the UI thread as `Loading`, filled by the
    /// worker's loader thread; `None` = no preview open. The modal
    /// renders this signal, so the body can arrive after the frame that
    /// opened it. See [`crate::preview`].
    pub preview: Signal<Option<crate::preview::PreviewState>>,
    /// Monotonic mint for `PreviewState::seq` — the staleness guard
    /// that keeps a slow loader from overwriting a newer preview.
    pub preview_seq: Signal<u64>,
    /// PERSISTED permissions level ("read"|"write"|"all"; "" reads as
    /// "read"): batches at-or-below it auto-approve (`/permissions`;
    /// the c5028 consolidation — the old session-scoped /auto blanket
    /// signal is DELETED, its three latent holes with it). Mirrors
    /// `prefs.tool_approval.accepted_tier` (at-rest key unchanged:
    /// documented hand-editable for headless).
    pub accepted_tier: Signal<String>,
    /// Per-tool approval pins (name → "auto"|"ask"), persisted.
    pub tool_overrides: Signal<Vec<(String, String)>>,
    /// Live workspace access mode for new runs ("" = server-managed:
    /// send nothing). Seeded from --workspace-mode/prefs; edited by
    /// `/workspace`; persisted.
    pub workspace_mode: Signal<String>,
    /// Extra allowlisted roots sent as `workspace_allowed_paths`
    /// (`/workspace`; persisted; applies in workspace_or_allowed mode).
    pub workspace_allowed: Signal<Vec<String>>,
    // -- queue / steer lane ---------------------------------------------
    /// FIFO prompt queue (`/queue <text>`); drains as NEW runs when a run
    /// completes successfully. Persisted per session (prefs
    /// `session_queues`, write-through); every restore lands PAUSED.
    pub queue: Signal<Vec<QueuedPrompt>>,
    /// Halted after a failure/cancel/restore; explicit resume (`r`).
    pub queue_paused: Signal<bool>,
    /// Text submitted with no cycling target yet (Starting, or Running
    /// pre-first-cycle), buffered until the fold's cycling target lands
    /// (delivered as a steer into the CYCLING run) or the run dies
    /// (error/info-carded). The old behavior DROPPED the text.
    pub pending_steer: Signal<Option<PendingSteer>>,
    /// Terminal outcome mailbox for the drain effect (take-semantics).
    pub last_outcome: Signal<RunOutcome>,
    /// Monotonic id mint for `QueuedPrompt::id`.
    pub queue_next_id: Signal<u64>,
    /// One-shot composer seed (queue modal `e` pops an item into the
    /// composer; root() owns the TextAreaState and drains this).
    pub composer_seed: Signal<Option<String>>,
    /// One-shot RESTORE of an undelivered steer (2026-08-20). Distinct
    /// from `composer_seed` on purpose: the seed REPLACES the draft
    /// because the operator asked for it, while a restore must never
    /// clobber words typed since the failure — root() drops it when the
    /// composer is non-empty, and the error card keeps the text either
    /// way, so nothing is ever lost.
    pub steer_restore: Signal<Option<String>>,
    // -- /goal lane -------------------------------------------------------
    /// The active goal (text + bound run id), persisted per session.
    pub goal: Signal<Option<GoalState>>,
    /// Catalog entrypoints carrying the GOAL interface
    /// (`abstractcode.goal.v1`) — disjoint from `workflows` (agent.v1).
    pub goal_workflows: Signal<Vec<Workflow>>,
    // -- entity collaboration lane -------------------------------------
    /// Which conversation the transcript pane mirrors (agent or entity).
    pub focus: Signal<crate::convo::Focus>,
    /// Every entity conversation of this session (open, parked, closed —
    /// closed transcripts stay readable; never removed in-session).
    pub convos: Signal<Vec<crate::convo::EntityConvo>>,
    /// Cached entity roster (last-good; `/entities` + '@' completion read
    /// this and NEVER trigger a synchronous fetch).
    pub entities: Signal<Vec<crate::entities::EntityInfo>>,
    /// "HH:MM" (UTC) label of the roster snapshot; empty = never fetched.
    pub entities_as_of: Signal<String>,
    pub entities_loading: Signal<bool>,
    pub entities_error: Signal<String>,
    /// Identity cards by slug (async-filled; browsing hits the cache).
    pub entity_cards: Signal<Vec<(String, crate::entities::EntityCard)>>,
    /// MCP registry honesty (source path + probed flag) for `/mcp`.
    pub mcp_info: Signal<crate::entities::McpRegistryInfo>,
}

impl Store {
    pub fn create(cx: Scope) -> Store {
        Store {
            fold: cx.signal(Fold::new()),
            phase: cx.signal(Phase::Idle),
            animation: cx.signal(0u8),
            conn: cx.signal(Conn::Unknown),
            session_id: cx.signal(String::new()),
            run_id: cx.signal(String::new()),
            workflow: cx.signal(Workflow::default()),
            workflows: cx.signal(Vec::new()),
            provider: cx.signal(String::new()),
            model: cx.signal(String::new()),
            gating_mode: cx.signal(String::new()),
            review_mode: cx.signal(crate::cli::DEFAULT_REVIEW_MODE),
            review_rounds: cx.signal(crate::cli::DEFAULT_REVIEW_ROUNDS),
            // 0 = request nothing; the server's own default applies, the
            // same one every other client gets.
            max_iterations: cx.signal(0u32),
            reasoning: cx.signal(String::new()),
            reasoning_probe: cx.signal(None),
            providers: cx.signal(Vec::new()),
            tools: cx.signal(Vec::new()),
            tools_error: cx.signal(String::new()),
            disabled_tools: cx.signal(Vec::new()),
            camera_seed_pending: cx.signal(false),
            skills_catalog: cx.signal(Vec::new()),
            skills_error: cx.signal(String::new()),
            selected_skills: cx.signal(Vec::new()),
            mcp_servers: cx.signal(Vec::new()),
            mcp_note: cx.signal(String::new()),
            cache: cx.signal(None),
            gpu: cx.signal(GpuMeter::Off),
            host_state: cx.signal(HostState::Idle),
            host_contracts: cx.signal(None),
            host_estimate: cx.signal(None),
            context_window: cx.signal(0),
            last_call_rate: cx.signal(None),
            default_route: cx.signal((String::new(), String::new())),
            images: cx.signal(Vec::new()),
            totals: cx.signal(SessionTotals::default()),
            run_started: cx.signal(None),
            elapsed_secs: cx.signal(0),
            notices: cx.signal(Vec::new()),
            last_esc: cx.signal(None),
            // Collapsed by default: the readable scan view (thinking
            // gists + tagged one-line tool calls); /details expands.
            show_details: cx.signal(false),
            expanded_tools: cx.signal(Vec::new()),
            session_index: cx.signal(SessionIndex::Unfetched),
            paused: cx.signal(false),
            quit_state: cx.signal(QuitState::None),
            verb_ack: cx.signal(None),
            run_status_probe: cx.signal(None),
            history_loading: cx.signal(false),
            history_cursor: cx.signal(None),
            older_turns: cx.signal(0),
            restoring: cx.signal(false),
            restore_failed: cx.signal(None),
            restore_progress: cx.signal(None),
            pending_attachments: cx.signal(Vec::new()),
            max_attachment_bytes: cx.signal(0),
            paste_undo: cx.signal(None),
            preview: cx.signal(None),
            preview_seq: cx.signal(0),
            accepted_tier: cx.signal(String::new()),
            tool_overrides: cx.signal(Vec::new()),
            workspace_mode: cx.signal(String::new()),
            workspace_allowed: cx.signal(Vec::new()),
            queue: cx.signal(Vec::new()),
            queue_paused: cx.signal(false),
            pending_steer: cx.signal(None),
            last_outcome: cx.signal(RunOutcome::None),
            queue_next_id: cx.signal(1),
            composer_seed: cx.signal(None),
            steer_restore: cx.signal(None),
            goal: cx.signal(None),
            goal_workflows: cx.signal(Vec::new()),
            focus: cx.signal(crate::convo::Focus::Agent),
            convos: cx.signal(Vec::new()),
            entities: cx.signal(Vec::new()),
            entities_as_of: cx.signal(String::new()),
            entities_loading: cx.signal(false),
            entities_error: cx.signal(String::new()),
            entity_cards: cx.signal(Vec::new()),
            mcp_info: cx.signal(Default::default()),
        }
    }

    pub fn notify(&self, text: impl Into<String>) {
        let text = text.into();
        self.notices.update(|n| n.push(text));
    }

    /// Mint a stable id for a queued prompt.
    pub fn mint_queue_id(&self) -> u64 {
        let id = self.queue_next_id.get_untracked();
        self.queue_next_id.set(id.wrapping_add(1));
        id
    }

    /// Reset the STEER half of the lane at a session boundary (/new,
    /// session switch). Returns the dropped buffer so callers can echo it
    /// visibly. The QUEUE is deliberately NOT touched here: it is stashed
    /// per session (prefs write-through) and swapped by the session
    /// boundary itself — a pending steer is moment-bound guidance for a
    /// run that no longer matters, a queue is durable work.
    pub fn reset_steer_lane(&self) -> Option<PendingSteer> {
        let dropped = self.pending_steer.get_untracked();
        self.pending_steer.set(None);
        self.last_outcome.set(RunOutcome::None);
        dropped
    }

    /// The live inventory as tool-policy classification facts (name +
    /// served tier/approval). Empty server fields fall back to the name
    /// table inside `tool_policy`. Read untracked — callers use this at
    /// discrete moments (run start, approval decision), not reactively.
    /// The ONE effective-user-disabled predicate (cycle-3 adversary
    /// P2-2: the run-start "customized?" decision and the /tools title
    /// carried it as two textual copies — agreement by coincidence is
    /// the divergence class this wave just fixed once already): a
    /// user-disabled NAME counts only when it matches an ENABLED
    /// inventory row. Served-disabled matches are a server fact, not a
    /// customization — the row cannot run either way.
    pub fn effective_user_disabled(inventory: &[ToolInfo], disabled: &[String]) -> usize {
        disabled
            .iter()
            .filter(|d| {
                inventory
                    .iter()
                    .any(|t| t.name == **d && !t.served_disabled)
            })
            .count()
    }

    /// Every tool this session could actually be granted: the inventory
    /// minus server-disabled rows minus the user's `/tools` opt-outs.
    ///
    /// This is the materialized form of "the default tool set" — needed for
    /// bundles that require an EXPLICIT `tools` list and treat a missing one
    /// as an empty one (see the goal lane), where sending nothing means
    /// sending nothing rather than "your defaults, please".
    pub fn grantable_tool_names(&self) -> Vec<String> {
        let disabled = self.disabled_tools.get_untracked();
        self.tools.with_untracked(|inv| {
            inv.iter()
                .filter(|t| !t.served_disabled)
                .map(|t| t.name.clone())
                .filter(|n| !disabled.contains(n))
                .collect()
        })
    }

    pub fn tool_classes(&self) -> Vec<crate::tool_policy::ToolClass> {
        self.tools.with_untracked(|inv| {
            inv.iter()
                .map(crate::tool_policy::ToolClass::from)
                .collect()
        })
    }

    pub fn image_for(&self, artifact_id: &str) -> Option<ImageEntry> {
        self.images
            .with(|imgs| imgs.iter().find(|e| e.artifact_id == artifact_id).cloned())
    }

    /// Insert or replace the image entry for its artifact id — UPSERT,
    /// never append: session revisits re-request the same artifacts (the
    /// fold's dedup resets with the fold), and append-only entries both
    /// leaked bitmaps and let a transient error entry permanently shadow
    /// a later successful fetch, because `image_for` returns the first
    /// match (adversary finding 7, 2026-07-22).
    ///
    /// Success is STICKY: artifacts are immutable, so an already-decoded
    /// bitmap stays valid forever — a transient re-fetch error must not
    /// clobber it (last-wins would degrade a rendered image to an error
    /// card on a gateway hiccup). Successful decodes always replace.
    ///
    /// Bounded (F3): at most [`IMAGE_ENTRY_CAP`] entries, oldest-inserted
    /// evicted first. An evicted artifact still in the scrollback renders
    /// its placeholder again (a session revisit re-fetches it); the cap
    /// exists because decoded bitmaps are the client's dominant retained
    /// allocation and the list previously grew forever.
    pub fn upsert_image(&self, entry: ImageEntry) {
        self.images.update(|imgs| {
            match imgs.iter_mut().find(|e| e.artifact_id == entry.artifact_id) {
                Some(slot) => {
                    if entry.bitmap.is_some() || slot.bitmap.is_none() {
                        *slot = entry;
                    }
                }
                None => imgs.push(entry),
            }
            if imgs.len() > IMAGE_ENTRY_CAP {
                let overflow = imgs.len() - IMAGE_ENTRY_CAP;
                imgs.drain(..overflow);
            }
        });
    }
}

#[cfg(test)]
mod tests {

    /// The queue's contract in one place: only a turn that FINISHED lets the
    /// next prompt start. A stop is not a failure, and it is not a success
    /// either — reporting it as one drained the queue onto incomplete work
    /// while every other surface of this client called the turn unfinished.
    #[test]
    fn only_a_finished_turn_releases_the_queue() {
        use super::RunOutcome as O;
        assert_eq!(O::for_conclusion(false, false, false), O::Success);
        assert_eq!(O::for_conclusion(false, false, true), O::StoppedShort);
        assert_eq!(O::for_conclusion(true, false, false), O::Failed);
        assert_eq!(O::for_conclusion(false, true, false), O::Cancelled);
        // Failure outranks the rest — a failed turn is reported as failed
        // whatever else was true of it.
        assert_eq!(O::for_conclusion(true, true, true), O::Failed);

        assert!(!O::Success.holds_the_queue());
        assert!(O::StoppedShort.holds_the_queue());
        assert!(O::Failed.holds_the_queue());
        assert!(O::Cancelled.holds_the_queue());
    }

    use super::*;
    use abstracttui::widgets::Bitmap;

    #[test]
    fn queue_lane_mints_ids_and_steer_reset_leaves_the_queue_alone() {
        let (root, ()) = abstracttui::reactive::create_root(|cx| {
            let store = Store::create(cx);
            let a = store.mint_queue_id();
            let b = store.mint_queue_id();
            assert_ne!(a, b, "ids are unique");
            store.queue.update(|q| {
                q.push(QueuedPrompt {
                    id: a,
                    text: "one".into(),
                });
                q.push(QueuedPrompt {
                    id: b,
                    text: "two".into(),
                });
            });
            store.queue_paused.set(true);
            store.pending_steer.set(Some(PendingSteer {
                armed_at_root: "r1".into(),
                armed_while_starting: true,
                text: "buffered".into(),
            }));
            store.last_outcome.set(RunOutcome::Failed);

            // The steer reset drops the moment-bound buffer + mailbox but
            // NEVER the queue (queues are stashed per session, not reset).
            let dropped = store.reset_steer_lane();
            assert_eq!(dropped.map(|p| p.text).as_deref(), Some("buffered"));
            assert_eq!(
                store.queue.with_untracked(|q| q.len()),
                2,
                "the queue survives a steer-lane reset"
            );
            assert!(store.queue_paused.get_untracked(), "pause flag untouched");
            assert!(store.pending_steer.get_untracked().is_none());
            assert_eq!(store.last_outcome.get_untracked(), RunOutcome::None);
            // Ids keep minting past a reset (identity never recycles
            // within a session — modal edits key on it).
            assert!(store.mint_queue_id() > b);
        });
        root.dispose();
    }

    #[test]
    fn image_upsert_replaces_by_artifact_id_and_keeps_good_bitmaps() {
        let (root, ()) = abstracttui::reactive::create_root(|cx| {
            let store = Store::create(cx);
            let entry = |id: &str, bitmap: Option<Arc<Bitmap>>, error: &str| ImageEntry {
                artifact_id: id.into(),
                bitmap,
                error: error.into(),
            };
            let bitmap = || {
                Some(Arc::new(Bitmap::new(
                    1,
                    1,
                    abstracttui::prelude::Rgba::BLACK,
                )))
            };

            // A transient error entry must NOT permanently shadow a later
            // successful fetch (`image_for` returns the first match).
            store.upsert_image(entry("a1", None, "image fetch failed: timeout"));
            store.upsert_image(entry("a2", None, ""));
            store.upsert_image(entry("a1", bitmap(), ""));
            assert_eq!(
                store.images.with_untracked(|v| v.len()),
                2,
                "upsert never grows"
            );
            let a1 = store.image_for("a1").expect("entry exists");
            assert!(a1.bitmap.is_some(), "success replaced the error entry");
            assert!(a1.error.is_empty());

            // Success is sticky: a transient error on a session-revisit
            // re-fetch must not clobber the already-decoded bitmap
            // (artifacts are immutable; the old pixels are still true).
            store.upsert_image(entry("a1", None, "image fetch failed: 503"));
            let a1 = store.image_for("a1").expect("entry exists");
            assert!(
                a1.bitmap.is_some(),
                "a good bitmap survives a transient re-fetch error"
            );
            assert_eq!(store.images.with_untracked(|v| v.len()), 2);

            // A fresh successful decode still replaces (same artifact,
            // same pixels — replacement is harmless and keeps one entry).
            store.upsert_image(entry("a1", bitmap(), ""));
            assert_eq!(store.images.with_untracked(|v| v.len()), 2);

            // An error for an artifact with NO good bitmap does land
            // (the honest failure state renders in the transcript).
            store.upsert_image(entry("a3", None, "decode failed"));
            let a3 = store.image_for("a3").expect("entry exists");
            assert!(a3.bitmap.is_none());
            assert_eq!(a3.error, "decode failed");
        });
        root.dispose();
    }

    #[test]
    fn image_list_is_capped_evicting_oldest_first() {
        // F3: the entry list is bounded — a long session's images must
        // not accumulate bitmaps forever. Oldest-inserted evict first;
        // the newest CAP entries survive.
        let (root, ()) = abstracttui::reactive::create_root(|cx| {
            let store = Store::create(cx);
            for i in 0..(IMAGE_ENTRY_CAP + 8) {
                store.upsert_image(ImageEntry {
                    artifact_id: format!("art-{i}"),
                    bitmap: None,
                    error: String::new(),
                });
            }
            assert_eq!(
                store.images.with_untracked(|v| v.len()),
                IMAGE_ENTRY_CAP,
                "the list never exceeds the cap"
            );
            assert!(
                store.image_for("art-0").is_none(),
                "the oldest entry evicted"
            );
            assert!(
                store
                    .image_for(&format!("art-{}", IMAGE_ENTRY_CAP + 7))
                    .is_some(),
                "the newest entry survives"
            );
            // An upsert of an EXISTING id never evicts (no growth).
            store.upsert_image(ImageEntry {
                artifact_id: format!("art-{}", IMAGE_ENTRY_CAP + 7),
                bitmap: None,
                error: "retry".into(),
            });
            assert_eq!(store.images.with_untracked(|v| v.len()), IMAGE_ENTRY_CAP);
        });
        root.dispose();
    }

    #[test]
    fn gpu_meter_defaults_off() {
        // OBS-6: the meter starts OFF (zero polling until /gpu).
        let (root, ()) = abstracttui::reactive::create_root(|cx| {
            let store = Store::create(cx);
            assert_eq!(store.gpu.get_untracked(), GpuMeter::Off);
        });
        root.dispose();
    }
}