synaps-tui 0.9.0

Terminal UI layer — ratatui, crossterm, syntect, tachyonfx
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
//! Focus + modal-stack foundation for the TUI (P7.1 + P7.2).
//!
//! This module is the **unwired** foundation for the P7 FocusManager /
//! ModalStack migration described in
//! `docs/plans/2026-07-09-p7-focusmanager-modalstack-design.md`. It ships as a
//! self-contained set of data structures with unit tests and is deliberately
//! **not yet referenced** by any call site — wiring lands in P7.3+ (App owns
//! the stack, `input.rs` grows the routing shim). Nothing here mutates App
//! state; the stack is designed to be an *index over* the existing
//! `Option<…State>` fields on App, never a new owner (§6 behavior-preservation).
//!
//! Design references: §2 (`PaneId`), §3 (`ModalStack`),
//! §4 (`FocusManager`).

use std::collections::HashMap;

// ---------------------------------------------------------------------------
// §2 — PaneId
// ---------------------------------------------------------------------------

/// Typed identity for every input-receiving surface. App-grade, not
/// framework-grade: a closed enum, no id-paths, no arenas (Yoru scoping).
///
/// `PaneId` is `Copy` and carries **no state**. Modal state stays exactly where
/// it is today (the `Option<…State>` fields on App, `app.rs:83-89`). The stack
/// is an *index over* open modals, not a new owner — this is the core of the
/// behavior-preservation strategy (§6).
///
/// # Deliberate NON-members (each with its reason, per §2)
/// - **Toast** — never focusable, purely passive (`draw.rs:1564`).
/// - **Gamba** — terminal handoff, gated upstream of the event loop
///   (`mod.rs:570`); the stack never sees events while it runs.
/// - **Sidecars / lightbox helpers** — render-only.
/// - **Models expanded lightbox** — internal view state of `ModelsModalState`
///   (`models/mod.rs:191`); its keys are already dispatched inside
///   `models::handle_event`. Promoting it would change behavior for zero
///   benefit. Revisit only if it ever needs to outlive the models modal.
/// - **Settings' other `ActiveEditor` variants** (Text/Picker/ApiKey/…) — stay
///   internal to the Settings pane handler; they are field editors, not panes.
///   Only `PluginCustom` is promoted (see `PluginEditor`), because it already
///   routes through the main loop as a distinct machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum PaneId {
    /// Base pane — chat transcript + input box. Never on the stack;
    /// it is the implicit bottom (empty stack ⇒ Chat gets input).
    Chat,
    /// `/help find` lightbox (`app.rs:89`). Single search field; no Focus enum.
    HelpFind,
    /// `/effort` lightbox — valid effort levels for the active exact model.
    Effort,
    /// `/model` / `/models` modal (`app.rs:87`). The expanded-models lightbox
    /// stays INTERNAL to Models (`models/mod.rs:191`).
    Models,
    /// `/plugins` marketplace (`app.rs:85`). Has `Focus { Left, Right }`.
    Plugins,
    /// `/settings` modal (`app.rs:83`). Has `Focus { Left, Right }`.
    Settings,
    /// `ActiveEditor::PluginCustom` promoted from `settings.edit_mode`
    /// special-case (`input.rs:104-118`) to a real stack level above Settings
    /// (`settings/mod.rs:196+`).
    PluginEditor,
    /// Secret / masked prompt queue (`mod.rs:170` queue → moves to App, §5).
    SecretPrompt,
}

// ---------------------------------------------------------------------------
// §3 — ModalStack
// ---------------------------------------------------------------------------

/// Ordered set of currently-open modal panes, bottom → top.
///
/// # Contracts (§3; each tested below)
/// 1. **Top-of-stack gets input** — routing dispatches on [`ModalStack::top`];
///    nothing below the top ever sees an event (hidden-widget rule). Occluded
///    panes keep their state but are input-dead.
/// 2. **Escape pops uniformly** — each pane's close path pops the stack;
///    the routing layer performs the pop AND clears the matching `Option`
///    field. One Esc = one level. (The per-pane outcome→pop mapping is
///    realized inline in each `input.rs` route handler, §7.)
/// 3. **Duplicate-push rejected** — `debug_assert!` + no-op in release
///    (matches today: opening `/settings` while settings is open is impossible).
///    Pushing [`PaneId::Chat`] is likewise rejected — Chat is the implicit
///    bottom, never stored.
/// 4. **Sync invariant** — `stack.contains(X) ⇔ corresponding app field
///    is_some()`. Enforced in P7.3 by `debug_assert_stack_sync` after every
///    `handle_event` / reconcile. (See the stub at the bottom of this module —
///    the App cross-check is wired once App owns the stack.)
/// 5. **Empty stack ⇒ Chat** — the base pane is not stored; [`ModalStack::top`]
///    returns [`PaneId::Chat`] for an empty stack.
#[derive(Debug, Default)]
pub(crate) struct ModalStack {
    /// Bottom → top. Draw order is bottom-up; input goes to the top.
    stack: Vec<PaneId>,
}

impl ModalStack {
    /// Create an empty stack (base `Chat` pane is implicit, never stored).
    pub fn new() -> Self {
        Self::default()
    }

    /// Push a pane onto the top of the stack.
    ///
    /// Rejects [`PaneId::Chat`] (the implicit bottom) and duplicate pushes:
    /// both are `debug_assert!` failures in debug builds and silent no-ops in
    /// release (§3 contract 3).
    pub fn push(&mut self, id: PaneId) {
        debug_assert!(
            id != PaneId::Chat,
            "ModalStack::push(Chat) — Chat is the implicit bottom, never stored"
        );
        debug_assert!(
            !self.stack.contains(&id),
            "ModalStack::push({id:?}) — duplicate push (pane already open)"
        );
        if id == PaneId::Chat || self.stack.contains(&id) {
            return;
        }
        self.stack.push(id);
    }

    /// Pop the top pane, if any. Returns the removed pane.
    pub fn pop(&mut self) -> Option<PaneId> {
        self.stack.pop()
    }

    /// Remove a specific pane from anywhere in the stack (out-of-order close,
    /// e.g. a secret prompt resolved by cancel-all). No-op if not present.
    /// Panes above the removed one keep their relative order and shift down.
    pub fn remove(&mut self, id: PaneId) {
        if let Some(pos) = self.stack.iter().position(|&p| p == id) {
            self.stack.remove(pos);
        }
    }

    /// The pane that currently receives input. Returns [`PaneId::Chat`] when the
    /// stack is empty (§3 contract 5).
    pub fn top(&self) -> PaneId {
        self.stack.last().copied().unwrap_or(PaneId::Chat)
    }

    /// Whether `id` is currently open (anywhere in the stack).
    pub fn contains(&self, id: PaneId) -> bool {
        self.stack.contains(&id)
    }

    /// Iterate panes bottom → top — i.e. draw order (topmost paints last).
    pub fn iter_bottom_up(&self) -> impl Iterator<Item = PaneId> + '_ {
        self.stack.iter().copied()
    }

    /// Number of open modal panes (does not count the implicit `Chat` base).
    ///
    /// Used under `debug_assertions` (stack-integrity checks), the `testing`
    /// feature (harness API), and in unit tests. In a normal release library
    /// build none of those gates are active, so we suppress the lint there.
    #[cfg_attr(
        not(any(test, debug_assertions, feature = "testing")),
        allow(dead_code)
    )]
    pub fn depth(&self) -> usize {
        self.stack.len()
    }

    /// Whether no modal panes are open (input falls through to `Chat`).
    #[allow(dead_code)] // test-only today; reserved for stack-empty guards
    pub fn is_empty(&self) -> bool {
        self.stack.is_empty()
    }
}

// ---------------------------------------------------------------------------
// §4 — FocusManager
// ---------------------------------------------------------------------------

/// A focusable slot within a pane: a small id + a visibility flag.
///
/// Hidden (`visible == false`) slots are skipped by ring traversal so that
/// conditionally-shown widgets never trap focus.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FocusSlot {
    /// Stable per-pane slot identity (e.g. Left ↦ 0, Right ↦ 1).
    id: SlotId,
    /// Whether this slot currently participates in traversal.
    visible: bool,
}

impl FocusSlot {
    /// A visible slot with the given id.
    pub fn new(id: u8) -> Self {
        Self {
            id: SlotId(id),
            visible: true,
        }
    }

    /// This slot's stable id value.
    pub fn id(&self) -> u8 {
        self.id.0
    }

    /// Whether this slot is currently visible / traversable.
    #[allow(dead_code)] // read via the field internally; public accessor unused
    pub fn is_visible(&self) -> bool {
        self.visible
    }
}

/// A small `u8` newtype identifying a focusable slot within a single pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct SlotId(pub(crate) u8);

/// Per-pane focus ring: the ordered focusable slots plus the current index.
///
/// Traversal ([`FocusRing::next`] / [`FocusRing::prev`]) wraps around the ends
/// and skips `visible == false` slots. If no slot is visible, the current index
/// is left untouched.
#[derive(Debug, Clone, Default)]
pub(crate) struct FocusRing {
    slots: Vec<FocusSlot>,
    current: usize,
}

impl FocusRing {
    /// Build a ring from a sequence of slots. `current` starts at 0.
    pub fn new(slots: Vec<FocusSlot>) -> Self {
        Self { slots, current: 0 }
    }

    /// Build a ring of `count` visible slots with ids `0..count`.
    pub fn of_len(count: u8) -> Self {
        Self::new((0..count).map(FocusSlot::new).collect())
    }

    /// The currently-focused slot, if the ring has any slots.
    /// Read by draw code for highlight styling.
    pub fn current(&self) -> Option<FocusSlot> {
        self.slots.get(self.current).copied()
    }

    /// Move focus to the next visible slot (Tab), wrapping.
    pub fn next(&mut self) {
        self.step(true);
    }

    /// Move focus to the previous visible slot (Shift-Tab / BackTab), wrapping.
    #[allow(dead_code)] // no modal binds BackTab yet; reverse traversal reserved
    pub fn prev(&mut self) {
        self.step(false);
    }

    /// Set the visibility of the slot with id `id`. Returns `true` if a slot
    /// matched. If hiding the currently-focused slot, focus is advanced to the
    /// next visible slot so `current()` never points at a hidden widget.
    #[allow(dead_code)] // both live rings are all-visible; reserved for hidden slots
    pub fn set_visible(&mut self, id: u8, visible: bool) -> bool {
        let mut matched = false;
        for slot in &mut self.slots {
            if slot.id.0 == id {
                slot.visible = visible;
                matched = true;
            }
        }
        if matched && !visible && self.slots.get(self.current).is_some_and(|s| s.id.0 == id) {
            self.step(true);
        }
        matched
    }

    fn step(&mut self, forward: bool) {
        let n = self.slots.len();
        if n == 0 {
            return;
        }
        if !self.slots.iter().any(|s| s.visible) {
            return;
        }
        let mut idx = self.current;
        for _ in 0..n {
            idx = if forward {
                (idx + 1) % n
            } else {
                (idx + n - 1) % n
            };
            if self.slots[idx].visible {
                self.current = idx;
                return;
            }
        }
    }
}

/// Per-pane focus rings with occlusion-surviving persistence.
///
/// Focus state **survives occlusion**: push `Plugins` over `Settings`, pop back,
/// and Settings' Left/Right position is exactly where you left it — matching
/// today, where `Focus` lives inside the retained `SettingsState` (§4). Rings
/// are keyed by [`PaneId`] and are independent of the [`ModalStack`]; nothing
/// about push/pop mutates a ring.
///
/// Deliberately small: exactly two per-modal focus enums exist today
/// (`plugins/state.rs:26`, `settings/mod.rs:161`), both `{ Left, Right }`.
/// help_find / models / secret_prompt get single-slot rings registered purely
/// for uniformity. Explicitly NOT built: no global focus tree, no id-paths, no
/// focus events/observers, no inter-pane Tab (Tab never leaves the active pane).
// UNWIRED (P7-complete): the central FocusManager registry is not adopted —
// panes embed a `FocusRing` directly (plugins/state.rs, settings/mod.rs).
// Kept as the §4 reference shape for a future multi-ring pane.
#[allow(dead_code)]
#[derive(Debug, Default)]
pub(crate) struct FocusManager {
    rings: HashMap<PaneId, FocusRing>,
}

#[allow(dead_code)]
impl FocusManager {
    /// Empty manager — panes register their rings lazily.
    pub fn new() -> Self {
        Self::default()
    }

    /// Register (or replace) the focus ring for `pane`.
    pub fn register(&mut self, pane: PaneId, ring: FocusRing) {
        self.rings.insert(pane, ring);
    }

    /// Register a simple ring of `count` visible slots for `pane`.
    pub fn register_slots(&mut self, pane: PaneId, count: u8) {
        self.register(pane, FocusRing::of_len(count));
    }

    /// Whether `pane` has a registered ring.
    pub fn is_registered(&self, pane: PaneId) -> bool {
        self.rings.contains_key(&pane)
    }

    /// Tab within `pane` — advance to the next visible slot (wraps). No-op if
    /// `pane` is unregistered.
    pub fn next(&mut self, pane: PaneId) {
        if let Some(ring) = self.rings.get_mut(&pane) {
            ring.next();
        }
    }

    /// Shift-Tab / BackTab within `pane` — previous visible slot (wraps).
    /// No-op if `pane` is unregistered.
    pub fn prev(&mut self, pane: PaneId) {
        if let Some(ring) = self.rings.get_mut(&pane) {
            ring.prev();
        }
    }

    /// The currently-focused slot in `pane`, read by draw code for highlight
    /// styling. `None` if `pane` is unregistered or its ring is empty.
    pub fn current(&self, pane: PaneId) -> Option<FocusSlot> {
        self.rings.get(&pane).and_then(FocusRing::current)
    }

    /// Toggle a slot's visibility within `pane` (hidden slots are skipped by
    /// traversal). Returns `true` if `pane` and the slot both matched.
    pub fn set_visible(&mut self, pane: PaneId, slot_id: u8, visible: bool) -> bool {
        self.rings
            .get_mut(&pane)
            .is_some_and(|ring| ring.set_visible(slot_id, visible))
    }
}

// ---------------------------------------------------------------------------
// §3 invariant 4 — sync tripwire (stub; App cross-check wired in P7.3)
// ---------------------------------------------------------------------------

/// Debug-only stack/app-field sync tripwire (§3 contract 4).
///
/// Cross-checks the [`ModalStack`] against `App`. The invariant is
/// `stack.contains(X) ⇔ X's backing App state is present`; it is **extended
/// per-modal as each pane is migrated (P7.4+)**.
///
/// In **P7.3** no modal is migrated yet — the stack is wired but permanently
/// empty (every open modal is still routed by the legacy `input.rs` chain and
/// never pushes). So the P7.3 invariant is simply: **the stack is empty**.
/// Each migration step adds one `⇔` clause here (e.g.
/// `stack.contains(HelpFind) == app.help_find.is_some()`), and the tripwire
/// fails the harness loudly if any open/close site is missed.
///
/// Always also checks the stack's internal invariants (no `Chat`, no
/// duplicates) via [`debug_assert_stack_internal`].
#[cfg(debug_assertions)]
pub(crate) fn debug_assert_stack_sync(app: &super::app::App) {
    // P7.4: HelpFind membership on the stack must exactly mirror its backing
    // App field (§3 contract 4).
    debug_assert_eq!(
        app.modal_stack.contains(PaneId::HelpFind),
        app.help_find.is_some(),
        "stack sync (P7.4): modal_stack.contains(HelpFind)={} but help_find.is_some()={} \
         — a push/pop site was missed",
        app.modal_stack.contains(PaneId::HelpFind),
        app.help_find.is_some()
    );

    // Effort lightbox membership must exactly mirror `app.effort`.
    debug_assert_eq!(
        app.modal_stack.contains(PaneId::Effort),
        app.effort.is_some(),
        "stack sync: modal_stack.contains(Effort)={} but effort.is_some()={} \
         — a push/pop site was missed",
        app.modal_stack.contains(PaneId::Effort),
        app.effort.is_some()
    );

    // P7.5: Models membership on the stack must exactly mirror `app.models`.
    debug_assert_eq!(
        app.modal_stack.contains(PaneId::Models),
        app.models.is_some(),
        "stack sync (P7.5): modal_stack.contains(Models)={} but models.is_some()={} \
         — a push/pop site was missed",
        app.modal_stack.contains(PaneId::Models),
        app.models.is_some()
    );

    // Plugins membership on the stack must exactly mirror `app.plugins`.
    // As of P7.7 settings is ALSO a stack member, so marketplace-from-settings
    // is a real two-deep stack [Settings, Plugins]; this clause cross-checks the
    // Plugins level, the Settings clause below checks the Settings level.
    debug_assert_eq!(
        app.modal_stack.contains(PaneId::Plugins),
        app.plugins.is_some(),
        "stack sync (P7.6): modal_stack.contains(Plugins)={} but plugins.is_some()={} \
         — a push/pop site was missed",
        app.modal_stack.contains(PaneId::Plugins),
        app.plugins.is_some()
    );

    // P7.7: Settings membership on the stack must exactly mirror `app.settings`.
    // Settings is now a TRUE stack member; marketplace-from-settings therefore
    // becomes a real two-deep stack [Settings, Plugins], and the nested
    // PluginCustom editor a [.., Settings, PluginEditor] two-deep stack.
    debug_assert_eq!(
        app.modal_stack.contains(PaneId::Settings),
        app.settings.is_some(),
        "stack sync (P7.7): modal_stack.contains(Settings)={} but settings.is_some()={} \
         — a push/pop site was missed",
        app.modal_stack.contains(PaneId::Settings),
        app.settings.is_some()
    );

    // P7.7: PluginEditor is the `ActiveEditor::PluginCustom` editor promoted to
    // a real stack level above Settings (§2). Its membership must mirror
    // edit_mode == Some(PluginCustom); it can only be active while Settings is
    // open. Pushed at PluginEditorOpen's Ok branch; popped at the Esc path
    // (route_settings) and both commit paths (ConfigWrite / InvokeCommand).
    let plugin_editor_active = matches!(
        app.settings.as_ref().map(|st| &st.edit_mode),
        Some(Some(super::settings::ActiveEditor::PluginCustom { .. }))
    );
    debug_assert_eq!(
        app.modal_stack.contains(PaneId::PluginEditor),
        plugin_editor_active,
        "stack sync (P7.7): modal_stack.contains(PluginEditor)={} but PluginCustom \
         edit_mode active={} — a push/pop site was missed",
        app.modal_stack.contains(PaneId::PluginEditor),
        plugin_editor_active
    );

    // P7.8: SecretPrompt membership on the stack must exactly mirror the async
    // queue's `is_active()` (§5 reconcile). This is the tripwire for a missed
    // reconcile after `poll_requests` / `submit` / `cancel`.
    debug_assert_eq!(
        app.modal_stack.contains(PaneId::SecretPrompt),
        app.secret_prompts.is_active(),
        "stack sync (P7.8): modal_stack.contains(SecretPrompt)={} but \
         secret_prompts.is_active()={} — a reconcile_secret_prompt call was missed",
        app.modal_stack.contains(PaneId::SecretPrompt),
        app.secret_prompts.is_active()
    );

    // Every pane is now stack-routed (P7.8): the stack is some ordering of a
    // subset of {HelpFind, Models, Plugins, Settings, PluginEditor, SecretPrompt}.
    // Nothing is chain-routed any more — Chat is the implicit base, never stored.
    for pane in app.modal_stack.iter_bottom_up() {
        debug_assert!(
            matches!(
                pane,
                PaneId::HelpFind
                    | PaneId::Effort
                    | PaneId::Models
                    | PaneId::Plugins
                    | PaneId::Settings
                    | PaneId::PluginEditor
                    | PaneId::SecretPrompt
            ),
            "stack sync (P7.8): unexpected pane {pane:?} found on the ModalStack \
             — only HelpFind, Effort, Models, Plugins, Settings, PluginEditor and \
             SecretPrompt are valid stack members"
        );
    }

    debug_assert_stack_internal(&app.modal_stack);
}

/// Internal stack-only invariants (no `Chat`, no duplicates), independent of
/// `App`. Split out so it can be exercised by unit tests that build a bare
/// [`ModalStack`] without an `App`.
#[cfg(debug_assertions)]
pub(crate) fn debug_assert_stack_internal(stack: &ModalStack) {
    debug_assert!(
        !stack.contains(PaneId::Chat),
        "stack sync: Chat must never be stored on the ModalStack"
    );
    let mut seen: Vec<PaneId> = Vec::with_capacity(stack.depth());
    for pane in stack.iter_bottom_up() {
        debug_assert!(
            !seen.contains(&pane),
            "stack sync: duplicate {pane:?} on the ModalStack"
        );
        seen.push(pane);
    }
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    // --- ModalStack (§3) ---------------------------------------------------

    #[test]
    fn push_pop_preserves_lifo_order() {
        let mut s = ModalStack::new();
        s.push(PaneId::Settings);
        s.push(PaneId::Plugins);
        s.push(PaneId::HelpFind);
        assert_eq!(s.depth(), 3);
        assert_eq!(s.top(), PaneId::HelpFind);

        // bottom -> top draw order
        let order: Vec<PaneId> = s.iter_bottom_up().collect();
        assert_eq!(
            order,
            vec![PaneId::Settings, PaneId::Plugins, PaneId::HelpFind]
        );

        assert_eq!(s.pop(), Some(PaneId::HelpFind));
        assert_eq!(s.pop(), Some(PaneId::Plugins));
        assert_eq!(s.top(), PaneId::Settings);
        assert_eq!(s.pop(), Some(PaneId::Settings));
        assert_eq!(s.pop(), None);
    }

    #[test]
    fn top_is_chat_when_empty() {
        let s = ModalStack::new();
        assert!(s.is_empty());
        assert_eq!(s.depth(), 0);
        assert_eq!(s.top(), PaneId::Chat);
    }

    #[test]
    #[cfg(not(debug_assertions))]
    fn duplicate_push_is_noop_release() {
        // In release builds the debug_assert is compiled out; the contains-guard
        // makes a duplicate push a true silent no-op (§3 contract 3).
        let mut s = ModalStack::new();
        s.push(PaneId::Models);
        s.push(PaneId::Models);
        assert_eq!(s.depth(), 1);
        assert_eq!(s.top(), PaneId::Models);
    }

    #[test]
    #[cfg(debug_assertions)]
    fn duplicate_push_panics_in_debug() {
        // In debug builds the duplicate-push contract is enforced loudly by the
        // debug_assert! tripwire — verify it actually fires.
        let result = std::panic::catch_unwind(|| {
            let mut s = ModalStack::new();
            s.push(PaneId::Models);
            s.push(PaneId::Models);
        });
        assert!(result.is_err(), "duplicate push must panic in debug builds");
    }

    #[test]
    #[cfg(not(debug_assertions))]
    fn push_chat_rejected_release() {
        let mut s = ModalStack::new();
        s.push(PaneId::Chat);
        assert!(s.is_empty());
        assert_eq!(s.top(), PaneId::Chat);
    }

    #[test]
    #[cfg(debug_assertions)]
    fn push_chat_panics_in_debug() {
        let result = std::panic::catch_unwind(|| {
            let mut s = ModalStack::new();
            s.push(PaneId::Chat);
        });
        assert!(result.is_err(), "pushing Chat must panic in debug builds");
    }

    #[test]
    fn remove_mid_stack_keeps_order() {
        let mut s = ModalStack::new();
        s.push(PaneId::Settings);
        s.push(PaneId::Plugins);
        s.push(PaneId::HelpFind);

        // Remove the middle pane.
        s.remove(PaneId::Plugins);
        assert_eq!(s.depth(), 2);
        assert!(!s.contains(PaneId::Plugins));
        let order: Vec<PaneId> = s.iter_bottom_up().collect();
        assert_eq!(order, vec![PaneId::Settings, PaneId::HelpFind]);
        assert_eq!(s.top(), PaneId::HelpFind);

        // Removing an absent pane is a no-op.
        s.remove(PaneId::Models);
        assert_eq!(s.depth(), 2);
    }

    #[test]
    fn contains_reflects_membership() {
        let mut s = ModalStack::new();
        assert!(!s.contains(PaneId::Settings));
        s.push(PaneId::Settings);
        assert!(s.contains(PaneId::Settings));
        s.remove(PaneId::Settings);
        assert!(!s.contains(PaneId::Settings));
    }

    #[test]
    fn stack_sync_tripwire_passes_for_valid_stack() {
        let mut s = ModalStack::new();
        s.push(PaneId::Settings);
        s.push(PaneId::Plugins);
        // Should not panic — no Chat, no duplicates. (P7.3 split the App-facing
        // `debug_assert_stack_sync(&App)` from this bare-stack internal check.)
        debug_assert_stack_internal(&s);
    }

    // --- FocusManager / FocusRing (§4) ------------------------------------

    #[test]
    fn wrap_traversal_forward_and_back() {
        let mut fm = FocusManager::new();
        fm.register_slots(PaneId::Plugins, 2); // Left=0, Right=1

        assert_eq!(fm.current(PaneId::Plugins).map(|s| s.id()), Some(0));
        fm.next(PaneId::Plugins);
        assert_eq!(fm.current(PaneId::Plugins).map(|s| s.id()), Some(1));
        // wrap forward: 1 -> 0
        fm.next(PaneId::Plugins);
        assert_eq!(fm.current(PaneId::Plugins).map(|s| s.id()), Some(0));
        // wrap backward: 0 -> 1
        fm.prev(PaneId::Plugins);
        assert_eq!(fm.current(PaneId::Plugins).map(|s| s.id()), Some(1));
        fm.prev(PaneId::Plugins);
        assert_eq!(fm.current(PaneId::Plugins).map(|s| s.id()), Some(0));
    }

    #[test]
    fn hidden_slot_is_skipped_by_traversal() {
        let mut fm = FocusManager::new();
        // three slots: 0,1,2 — hide the middle one.
        fm.register(
            PaneId::Settings,
            FocusRing::new(vec![
                FocusSlot::new(0),
                FocusSlot::new(1),
                FocusSlot::new(2),
            ]),
        );
        assert!(fm.set_visible(PaneId::Settings, 1, false));

        // 0 -> (skip 1) -> 2
        assert_eq!(fm.current(PaneId::Settings).map(|s| s.id()), Some(0));
        fm.next(PaneId::Settings);
        assert_eq!(fm.current(PaneId::Settings).map(|s| s.id()), Some(2));
        // 2 -> wrap -> 0 (still skipping 1)
        fm.next(PaneId::Settings);
        assert_eq!(fm.current(PaneId::Settings).map(|s| s.id()), Some(0));
        // backward from 0 -> wrap -> 2 (skip 1)
        fm.prev(PaneId::Settings);
        assert_eq!(fm.current(PaneId::Settings).map(|s| s.id()), Some(2));
    }

    #[test]
    fn hiding_current_slot_advances_focus_off_it() {
        let mut fm = FocusManager::new();
        fm.register_slots(PaneId::Settings, 2); // 0,1 ; current=0
        assert!(fm.set_visible(PaneId::Settings, 0, false));
        // current was 0 (now hidden) -> advanced to 1
        assert_eq!(fm.current(PaneId::Settings).map(|s| s.id()), Some(1));
    }

    #[test]
    fn all_hidden_leaves_current_untouched() {
        let mut fm = FocusManager::new();
        fm.register_slots(PaneId::Models, 2);
        fm.set_visible(PaneId::Models, 0, false);
        // hiding slot 0 advanced to 1; now hide 1 as well
        fm.set_visible(PaneId::Models, 1, false);
        let before = fm.current(PaneId::Models).map(|s| s.id());
        fm.next(PaneId::Models);
        assert_eq!(fm.current(PaneId::Models).map(|s| s.id()), before);
    }

    #[test]
    fn per_pane_focus_persists_across_stack_push_pop() {
        // FocusManager rings are independent of the ModalStack: occlusion
        // (push Plugins over Settings) must not disturb Settings' focus.
        let mut fm = FocusManager::new();
        fm.register_slots(PaneId::Settings, 2);
        fm.register_slots(PaneId::Plugins, 2);

        // Move Settings focus to slot 1.
        fm.next(PaneId::Settings);
        assert_eq!(fm.current(PaneId::Settings).map(|s| s.id()), Some(1));

        // Simulate occlusion via the stack.
        let mut stack = ModalStack::new();
        stack.push(PaneId::Settings);
        stack.push(PaneId::Plugins);
        // Fiddle with the occluding pane's focus.
        fm.next(PaneId::Plugins);
        assert_eq!(fm.current(PaneId::Plugins).map(|s| s.id()), Some(1));

        // Pop back to Settings.
        assert_eq!(stack.pop(), Some(PaneId::Plugins));
        assert_eq!(stack.top(), PaneId::Settings);

        // Settings' focus is exactly where we left it.
        assert_eq!(fm.current(PaneId::Settings).map(|s| s.id()), Some(1));
    }

    #[test]
    fn unregistered_pane_is_inert() {
        let mut fm = FocusManager::new();
        assert!(!fm.is_registered(PaneId::HelpFind));
        assert_eq!(fm.current(PaneId::HelpFind), None);
        // next/prev on an unregistered pane are no-ops (must not panic).
        fm.next(PaneId::HelpFind);
        fm.prev(PaneId::HelpFind);
        assert_eq!(fm.current(PaneId::HelpFind), None);
    }

    #[test]
    fn single_slot_ring_traversal_is_stable() {
        // help_find / models / secret_prompt shape: one slot, registered for
        // uniformity. Tab/BackTab must stay put.
        let mut fm = FocusManager::new();
        fm.register_slots(PaneId::HelpFind, 1);
        assert_eq!(fm.current(PaneId::HelpFind).map(|s| s.id()), Some(0));
        fm.next(PaneId::HelpFind);
        assert_eq!(fm.current(PaneId::HelpFind).map(|s| s.id()), Some(0));
        fm.prev(PaneId::HelpFind);
        assert_eq!(fm.current(PaneId::HelpFind).map(|s| s.id()), Some(0));
    }
}