<system-reminder>[Truncated: PARTIAL view — showing lines 1-1241 of 1550 total (26534 tokens, cap 25000). Call Read with offset=1242 limit=1241 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]</system-reminder>

1	//! The picker **state model** — pure data + transitions, no terminal I/O.
2	//!
3	//! Everything testable about the picker lives here: the screen graph, cursor
4	//! windowing, category navigation, type-to-filter narrowing, the status→square
5	//! mapping, the adapter→profile tree build, the ledger→resume-title render, and
6	//! the confirm-layer option set. The live key loop ([`super::run`]) is a thin
7	//! driver over these transitions; `view.rs` renders a `&PickerModel` to a
8	//! ratatui `Buffer`. Keeping this layer pure is what lets a future tap-mode
9	//! (phone PTY) reuse the exact same model without re-coupling to keybinds
10	//! (the phone caveat).
11	// [impl->REQ-RUN-PICKER]
12
13	use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
14	use nucleo_matcher::{Config, Matcher};
15
16	/// The recognizable glyph set, lifted from `vercel-labs/skills`
17	/// (`src/prompts/search-multiselect.ts`) for look/feel parity.
18	pub mod glyph {
19	    /// Online endpoint — filled square (rendered green in the view).
20	    pub const ONLINE: &str = "■";
21	    /// Offline endpoint — hollow square (rendered gray in the view).
22	    pub const OFFLINE: &str = "▢";
23	    /// The cursor caret.
24	    pub const CURSOR: &str = "❯";
25	    /// A tree branch into a nested profile.
26	    pub const BRANCH: &str = "└─";
27	}
28
29	/// Online / offline — the W2 status duality. The blue "attached" tri-state is
30	/// DEFERRED (doyle ruling Q1: it needs a broker attach-presence query + a
31	/// force-detach op, its own slice). Derived from the registry `Status` in
32	/// `data.rs`; the model only ever sees this reduced form.
33	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34	pub enum EpStatus {
35	    Online,
36	    Offline,
37	}
38
39	impl EpStatus {
40	    /// The status square glyph (color is applied in the view).
41	    pub fn square(self) -> &'static str {
42	        match self {
43	            EpStatus::Online => glyph::ONLINE,
44	            EpStatus::Offline => glyph::OFFLINE,
45	        }
46	    }
47	}
48
49	/// The **four-state display status** the picker renders — the list-item square's
50	/// colour and the pick-existing details STATUS line (REQ-PICKER-1). Derived from
51	/// the raw online/offline ([`EpStatus`]) plus broker-PTY-controllability, the
52	/// remote-control marker, and the endpoint type. Distinct from [`EpStatus`] (the
53	/// raw duality the confirm-layer logic keys on).
54	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55	pub enum EpDisplay {
56	    /// Gray hollow ▢ — offline.
57	    Offline,
58	    /// Green filled ■ — online + broker-PTY-controllable (spt-hosted), free.
59	    Online,
60	    /// Amber filled ■ — online LiveAgent that is NOT broker-PTY-controllable
61	    /// (harness-hosted, no broker PTY seat).
62	    HarnessOnly,
63	    /// Blue filled ■ — online + a node currently controls it (`driven_by`).
64	    Controlled,
65	    /// Green HOLLOW ▢ — UNBOUND (REQ-ENDPOINT-UNBOUND-ATTACH): a live broker
66	    /// session exists but the perch has not bound yet, so it is ATTACHABLE but
67	    /// not message-addressable. Hollow (the bound/unbound fill duality) in a live
68	    /// colour — the opposite of amber (amber = online-but-not-controllable; an
69	    /// unbound endpoint IS broker-attachable).
70	    Unbound,
71	    /// Blue HOLLOW ▢ — an UNBOUND endpoint a node is currently driving
72	    /// (`driven_by`). Parallels filled Online→Controlled for the unbound state.
73	    UnboundControlled,
74	}
75
76	impl EpDisplay {
77	    /// The STATUS-line / legend label.
78	    pub fn label(self) -> &'static str {
79	        match self {
80	            EpDisplay::Offline => "OFFLINE",
81	            EpDisplay::Online => "ONLINE",
82	            EpDisplay::HarnessOnly => "ONLINE - HARNESS ONLY",
83	            EpDisplay::Controlled => "ONLINE + CONTROLLED",
84	            EpDisplay::Unbound => "UNBOUND",
85	            EpDisplay::UnboundControlled => "UNBOUND + CONTROLLED",
86	        }
87	    }
88	}
89
90	/// One selectable adapter option in the create-new tree: a registered
91	/// `kind="harness"` adapter, optionally narrowed to one of its profiles. A
92	/// `None` profile = the bare adapter (its default view). The composite
93	/// `<adapter>[:profile]` address is what `cmd_endpoint_run` resolves.
94	#[derive(Debug, Clone, PartialEq, Eq)]
95	pub struct AdapterOption {
96	    pub adapter: String,
97	    pub profile: Option<String>,
98	    /// `true` for a profile leaf, `false` for the bare-adapter root row — drives
99	    /// the tree indentation in the view.
100	    pub is_leaf: bool,
101	}
102
103	impl AdapterOption {
104	    /// The composite `<adapter>` or `<adapter>:<profile>` address.
105	    pub fn address(&self) -> String {
106	        match &self.profile {
107	            Some(p) => format!("{}:{}", self.adapter, p),
108	            None => self.adapter.clone(),
109	        }
110	    }
111	}
112
113	/// The shipped+local profiles known for one adapter (gathered in `data.rs`).
114	#[derive(Debug, Clone, PartialEq, Eq)]
115	pub struct AdapterInfo {
116	    pub name: String,
117	    /// Shipped profile names (from the parent manifest), sorted.
118	    pub shipped: Vec<String>,
119	    /// Local overlay profile names (beside the adapter), sorted.
120	    pub local: Vec<String>,
121	}
122
123	/// Flatten the gathered adapters into the tree-nested selectable row list: for
124	/// each adapter, a bare-adapter root row followed by one leaf row per profile
125	/// (shipped then local), each addressable as `<adapter>:<profile>`.
126	// [impl->REQ-RUN-PICKER]
127	pub fn build_adapter_tree(adapters: &[AdapterInfo]) -> Vec<AdapterOption> {
128	    let mut rows = Vec::new();
129	    for a in adapters {
130	        rows.push(AdapterOption {
131	            adapter: a.name.clone(),
132	            profile: None,
133	            is_leaf: false,
134	        });
135	        for p in a.shipped.iter().chain(a.local.iter()) {
136	            rows.push(AdapterOption {
137	                adapter: a.name.clone(),
138	                profile: Some(p.clone()),
139	                is_leaf: true,
140	            });
141	        }
142	    }
143	    rows
144	}
145
146	/// One prior session from the per-endpoint ledger, rendered as a Resume option.
147	/// Built from `spt_store::sessions::SessionEntry` in `data.rs`.
148	#[derive(Debug, Clone, PartialEq, Eq)]
149	pub struct ResumeRow {
150	    pub session_id: String,
151	    pub ts: String,
152	    /// The boundary token (`boot`/`clear`/`compact`) — the title fallback when
153	    /// no project association exists (the ledger row carries no project).
154	    pub trigger: String,
155	    /// Best-effort project name (empty if none derivable).
156	    pub project: String,
157	    /// The session's recorded project cwd (ledger `cwd`, REQ-SESSION-RESUME-TEMPLATE).
158	    /// `None` for a pre-migration row → resume falls back to info.cwd/current_dir.
159	    pub cwd: Option<String>,
160	}
161
162	impl ResumeRow {
163	    /// The option title: `<project|trigger> - <local_time> (…<id5>)` (picker
164	    /// spec + design D). `local_time` renders the stored RFC3339-UTC `ts` in the
165	    /// operator's LOCAL timezone for human legibility.
166	    pub fn title(&self) -> String {
167	        let head = if self.project.is_empty() {
168	            &self.trigger
169	        } else {
170	            &self.project
171	        };
172	        let id5: String = {
173	            let s = &self.session_id;
174	            let n = s.chars().count();
175	            s.chars().skip(n.saturating_sub(5)).collect()
176	        };
177	        format!("{head} - {} (…{id5})", Self::local_time(&self.ts))
178	    }
179
180	    /// Render an RFC3339-UTC `ts` as a local-time human stamp
181	    /// `HH:MM<AM|PM> MM-DD-YYYY` (e.g. `03:45PM 06-19-2026`). The ts is converted
182	    /// to the operator's LOCAL timezone, so no tz token is shown (operator ruling,
183	    /// design D resolve: the local conversion already conveys the zone; a short
184	    /// abbreviation is unreliable cross-platform and the numeric offset is noise).
185	    /// A parse failure degrades to the raw `ts` — the picker degrades, never
186	    /// panics.
187	    // [impl->REQ-SESSION-RESUME-TEMPLATE]
188	    fn local_time(ts: &str) -> String {
189	        match chrono::DateTime::parse_from_rfc3339(ts) {
190	            Ok(dt) => dt
191	                .with_timezone(&chrono::Local)
192	                .format("%I:%M%p %m-%d-%Y")
193	                .to_string(),
194	            Err(_) => ts.to_string(),
195	        }
196	    }
197	}
198
199	/// A pick-existing category. Left/right cycles through these.
200	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201	pub enum Category {
202	    /// Endpoints scoped to the cwd project.
203	    Project,
204	    /// This node's local perches.
205	    Local,
206	    /// Subnet endpoints.
207	    Subnet,
208	}
209
210	impl Category {
211	    /// Display label (the project category carries the cwd project name).
212	    pub fn label(self, project_name: &str) -> String {
213	        match self {
214	            Category::Project => format!("{project_name} project"),
215	            Category::Local => "Local node".to_string(),
216	            Category::Subnet => "Subnet".to_string(),
217	        }
218	    }
219
220	    /// The ordered category ring for left/right navigation.
221	    pub const ALL: [Category; 3] = [Category::Project, Category::Local, Category::Subnet];
222	}
223
224	/// One discovered endpoint row (gathered in `data.rs`).
225	#[derive(Debug, Clone, PartialEq, Eq)]
226	pub struct EndpointRow {
227	    pub id: String,
228	    /// Grouping label within the category (e.g. "local", "subnet:node",
229	    /// project name) — rows render grouped then alpha-sorted.
230	    pub group: String,
231	    pub node: String,
232	    pub status: EpStatus,
233	    /// `true` if this endpoint lives on THIS node (gates Resume-from-history,
234	    /// which is offline+LOCAL only).
235	    pub is_local: bool,
236	    /// The right-pane description: harness `adapter:profile`.
237	    pub adapter_profile: String,
238	    /// Project history newest→oldest (best-effort; empty if none).
239	    pub project_history: Vec<String>,
240	    /// The `spt endpoint description` blurb (empty if none).
241	    pub description: String,
242	    /// Prior sessions for Resume-from-history (only populated for offline+local).
243	    pub resume_rows: Vec<ResumeRow>,
244	    /// The node currently controlling this endpoint's session (its broker-owned
245	    /// `driven_by`), if any — drives the W2.5 status-conditional confirm options
246	    /// (REQ-RCVIEW-1 / REQ-KICK-1): a CONTROLLED endpoint offers View + Kick, not
247	    /// a plain Attach. Local rows read it off `info.json` and render it through the
248	    /// canonical `node_label_display` so the "controlled by" pin shows the NODE NAME
249	    /// (`LABEL (keyprefix…)`), not raw key-hex (REQ-PICKER-4); subnet rows leave it
250	    /// `None` (the wire busy-refuse + client guidance still protect a remote one).
251	    /// Only ever read as `is_some()` (the controlled gate) or rendered verbatim.
252	    pub driven_by: Option<String>,
253	    /// Read-only viewers currently attached (broker-owned `viewer_count`), for the
254	    /// `controlled by <node> (+N viewing)` pin. `0` ⇒ none.
255	    pub viewer_count: u32,
256	    /// Broker-PTY-controllability (`info.json.controllable`, REQ-PICKER-1): `Some(true)`
257	    /// = spt-hosted (broker PTY, attachable); `Some(false)` = harness-hosted LiveAgent
258	    /// (no broker PTY); `None` = unknown (pre-v0.10.0 record / non-live / adapterless).
259	    /// Local rows read it off `info.json`; subnet rows leave it `None`.
260	    pub controllable: Option<bool>,
261	    /// The endpoint type tag (`info.json.state`, e.g. `live_agent`/`gateway`) — the
262	    /// type-gate for the amber "harness-only" display state (amber is LiveAgent-only).
263	    pub endpoint_type: String,
264	    /// `true` when this perch's on-disk status is `STATUS_UNBOUND` (a live broker
265	    /// session, not yet bound — REQ-ENDPOINT-UNBOUND-ATTACH). Drives the hollow
266	    /// `Unbound`/`UnboundControlled` display. Local rows read it off `info.json`;
267	    /// subnet/remote rows leave it `false` (a remote node's pre-bind state isn't
268	    /// observable here — they render plain, the existing controllability limit).
269	    pub is_unbound: bool,
270	}
271
272	impl EndpointRow {
273	    /// The four-state DISPLAY status (REQ-PICKER-1), type-gated so amber
274	    /// "harness-only" is a **LiveAgent-only** state: a gateway/shell — never a
275	    /// PTY-attachable agent — renders green when online regardless of
276	    /// controllability. Precedence: offline → gray; a controlled endpoint → blue;
277	    /// a non-LiveAgent → green; a LiveAgent that is not controllable (`Some(false)`
278	    /// or a legacy `None`, which self-corrects on the next bind) → amber; else
279	    /// (a controllable LiveAgent) → green.
280	    // [impl->REQ-PICKER-1]
281	    pub fn display_status(&self) -> EpDisplay {
282	        if self.status == EpStatus::Offline {
283	            return EpDisplay::Offline;
284	        }
285	        // UNBOUND (REQ-ENDPOINT-UNBOUND-ATTACH): a live session, not yet bound —
286	        // hollow, attachable. Checked before the type-gate/amber logic (an unbound
287	        // perch is pre-bind, so controllability is not yet meaningful). A node
288	        // driving it → the hollow-controlled variant, paralleling Controlled.
289	        if self.is_unbound {
290	            return if self.driven_by.is_some() {
291	                EpDisplay::UnboundControlled
292	            } else {
293	                EpDisplay::Unbound
294	            };
295	        }
296	        if self.driven_by.is_some() {
297	            return EpDisplay::Controlled;
298	        }
299	        // amber is LiveAgent-only — a gateway/shell online is plain green.
300	        if self.endpoint_type != "live_agent" {
301	            return EpDisplay::Online;
302	        }
303	        match self.controllable {
304	            Some(true) => EpDisplay::Online,
305	            // Some(false) = harness-hosted; None = legacy default (self-corrects
306	            // next bind) — both render amber for a LiveAgent.
307	            _ => EpDisplay::HarnessOnly,
308	        }
309	    }
310	}
311
312	/// Which screen the picker is on.
313	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314	pub enum Screen {
315	    /// Layer 1 — "Create new | Pick existing".
316	    Kind,
317	    /// Create-new L2 — pick an adapter:profile from the tree.
318	    CreateAdapter,
319	    /// Create-new L3 — type the endpoint id.
320	    CreateId,
321	    /// Create-new L4 — pick the home subnet (MULTI-subnet nodes only; inserted
322	    /// between CreateId and the terminal create outcome). REQ-RUN-PICKER-HOME.
323	    CreateHome,
324	    /// Pick-existing L2 — category + grouped endpoint list + description pane.
325	    PickExisting,
326	    /// Pick-existing L3 — confirm + status-dependent options.
327	    Confirm,
328	    /// Resume-from-history sub-screen — pick a prior session from the ledger.
329	    Resume,
330	}
331
332	/// Layer-1 kind choices.
333	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334	pub enum Kind {
335	    CreateNew,
336	    PickExisting,
337	}
338
339	/// A confirm-layer option. Resume/Fork/Instantiate/ChangeAdapter availability is
340	/// status-dependent (see [`PickerModel::confirm_options`]).
341	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342	pub enum ConfirmOption {
343	    Attach,
344	    Start,
345	    View,
346	    /// Online + CONTROLLED only — kick the current controller and take control
347	    /// (Take intent; a loud notice to the displaced operator — REQ-KICK-1).
348	    Kick,
349	    /// Offline + LOCAL only — enumerate the session ledger.
350	    Resume,
351	    /// Offline only — re-enter create-new to change the harness adapter.
352	    ChangeAdapter,
353	    /// Remote only — re-enter create-new to bring a copy up here.
354	    Instantiate,
355	    /// Always available — cross-subnet clone to a new id.
356	    Fork,
357	    /// The `s` keybind — build/update the `spt-<id>` shortcut.
358	    Shortcut,
359	}
360
361	/// The terminal outcome of the picker — the single action enum every screen
362	/// converges on. `run()` dispatches each variant through the EXISTING bringup
363	/// fns (`cmd_endpoint_run` / `cmd_fork` / the rc pump) — no second bringup path
364	/// (the gate invariant).
365	#[derive(Debug, Clone, PartialEq, Eq)]
366	pub enum Outcome {
367	    /// Bring up (or resume) an endpoint then start/attach/view — routes through
368	    /// `cmd_endpoint_run` (the one bringup core).
369	    Run {
370	        adapter: String,
371	        id: String,
372	        resume: Option<String>,
373	        /// The project cwd to land the harness in. `Some` only on a Resume
374	        /// outcome (the selected ledger row's recorded cwd, REQ-SESSION-RESUME-TEMPLATE);
375	        /// `None` for fresh/headless bringups → `cmd_endpoint_run` resolves it.
376	        cwd: Option<String>,
377	        /// The picker-collected home subnet (REQ-RUN-PICKER-HOME). `Some` only on
378	        /// a multi-subnet create-new flow (the CreateHome pick); `None` otherwise
379	        /// → `cmd_endpoint_run` resolves home (sole-auto / existing-perch immutable).
380	        subnet: Option<String>,
381	        mode: RunMode,
382	    },
383	    /// Attach to an ALREADY-LIVE endpoint's PTY — routes through the rc pump
384	    /// (`rc::run_attach`), not a re-bringup. `intent` carries the
385	    /// status-conditional choice (Control / Viewer / Take), so View / Attach /
386	    /// Kick all ride the SAME dispatch (single-bringup-path — REQ-KICK-1).
387	    Attach {
388	        id: String,
389	        intent: spt_net::net::attach::AttachIntent,
390	    },
391	    /// Write/update a `spt-<id>` shortcut, then stay (re-enter the loop).
392	    Shortcut {
393	        adapter: String,
394	        id: String,
395	        resume: Option<String>,
396	        mode: RunMode,
397	    },
398	    /// User backed all the way out.
399	    Cancelled,
400	}
401
402	/// The terminal action a Run baked: start (return), attach (default), or view
403	/// (read-only). Maps 1:1 onto `cmd_endpoint_run`'s start/attach/view flags.
404	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405	pub enum RunMode {
406	    Start,
407	    Attach,
408	    View,
409	}
410
411	/// The whole picker state. One struct, mutated by the transition methods; the
412	/// view renders it; `run()` drives keys into it.
413	pub struct PickerModel {
414	    pub screen: Screen,
415	    /// The cwd project name (the Project category label).
416	    pub project_name: String,
417
418	    // Layer 1.
419	    pub kind_cursor: usize,
420
421	    // Create-new.
422	    pub adapter_rows: Vec<AdapterOption>,
423	    pub adapter_cursor: usize,
424	    pub id_buffer: String,
425	    /// Set when the id buffer fails the charset check (shown inline).
426	    pub id_error: Option<String>,
427
428	    // Pick-existing.
429	    pub category: Category,
430	    /// All gathered endpoints (every category); the view filters by category.
431	    pub endpoints: Vec<EndpointRow>,
432	    pub item_cursor: usize,
433	    /// The `/` type-to-filter query.
434	    pub query: String,
435	    pub filtering: bool,
436
437	    // Confirm.
438	    pub confirm_cursor: usize,
439
440	    // Resume sub-screen.
441	    pub resume_cursor: usize,
442
443	    // Create-new home-subnet layer (REQ-RUN-PICKER-HOME). MRU-ordered member
444	    // subnets; the layer is shown only when there are >= 2.
445	    pub home_subnets: Vec<String>,
446	    pub home_cursor: usize,
447
448	    /// A transient one-line status (e.g. the shortcut-written confirmation),
449	    /// rendered in the bottom row until the next action clears it.
450	    pub flash: Option<String>,
451	}
452
453	impl PickerModel {
454	    /// A fresh model on the kind screen. `prefill_adapter`/`prefill_id` come from
455	    /// a lone `--adapter`/`--id` on a bare-ish `spt endpoint run`.
456	    pub fn new(
457	        project_name: String,
458	        adapter_rows: Vec<AdapterOption>,
459	        endpoints: Vec<EndpointRow>,
460	        prefill_adapter: Option<String>,
461	        prefill_id: Option<String>,
462	        home_subnets: Vec<String>,
463	    ) -> Self {
464	        // A lone --adapter pre-selects its row; a lone --id pre-fills the id and
465	        // jumps straight into create-new.
466	        let adapter_cursor = prefill_adapter
467	            .as_deref()
468	            .and_then(|a| adapter_rows.iter().position(|r| r.address() == a || r.adapter == a))
469	            .unwrap_or(0);
470	        // W4 UX: a bare picker OPENS on Pick-existing (the common case — most runs
471	        // re-attach an existing endpoint); `n` jumps to Create-new and Esc backs to
472	        // the Layer-1 Kind menu (now a jump-target, not the entry). A prefill still
473	        // jumps straight into create-new.
474	        // [impl->REQ-RUN-EMPTY-CREATE] a TOTALLY-empty scope (nothing attachable —
475	        // no local perch AND no subnet row) skips the blank Pick-existing list and
476	        // opens straight on the creation flow. A node with subnet-but-no-local rows
477	        // still has something to pick, so it stays on Pick-existing.
478	        let screen = if prefill_id.is_some() || prefill_adapter.is_some() || endpoints.is_empty() {
479	            Screen::CreateAdapter
480	        } else {
481	            Screen::PickExisting
482	        };
483	        Self {
484	            screen,
485	            project_name,
486	            kind_cursor: 0,
487	            adapter_rows,
488	            adapter_cursor,
489	            id_buffer: prefill_id.unwrap_or_default(),
490	            id_error: None,
491	            category: Category::Project,
492	            endpoints,
493	            item_cursor: 0,
494	            query: String::new(),
495	            filtering: false,
496	            confirm_cursor: 0,
497	            resume_cursor: 0,
498	            home_subnets,
499	            home_cursor: 0,
500	            flash: None,
501	        }
502	    }
503
504	    // ── Kind layer ────────────────────────────────────────────────────────
505	    pub fn kind_at_cursor(&self) -> Kind {
506	        if self.kind_cursor == 0 {
507	            Kind::CreateNew
508	        } else {
509	            Kind::PickExisting
510	        }
511	    }
512
513	    /// Enter from the kind layer into the chosen branch.
514	    pub fn enter_kind(&mut self) {
515	        match self.kind_at_cursor() {
516	            Kind::CreateNew => self.screen = Screen::CreateAdapter,
517	            Kind::PickExisting => {
518	                self.screen = Screen::PickExisting;
519	                self.item_cursor = 0;
520	            }
521	        }
522	    }
523
524	    // ── Generic cursor windowing ─────────────────────────────────────────
525	    /// Move a cursor within `[0, len)`, clamping at the ends (no wrap — matches
526	    /// the searchMultiselect windowed cursor; wrap would disorient the two-pane
527	    /// description sync).
528	    fn step(cursor: usize, len: usize, delta: isize) -> usize {
529	        if len == 0 {
530	            return 0;
531	        }
532	        let max = len - 1;
533	        let next = cursor as isize + delta;
534	        next.clamp(0, max as isize) as usize
535	    }
536
537	    // ── Create-new: adapter tree ─────────────────────────────────────────
538	    pub fn move_adapter(&mut self, delta: isize) {
539	        self.adapter_cursor = Self::step(self.adapter_cursor, self.adapter_rows.len(), delta);
540	    }
541
542	    pub fn selected_adapter(&self) -> Option<&AdapterOption> {
543	        self.adapter_rows.get(self.adapter_cursor)
544	    }
545
546	    /// Advance from adapter selection into id entry.
547	    pub fn enter_adapter(&mut self) {
548	        if self.selected_adapter().is_some() {
549	            self.screen = Screen::CreateId;
550	        }
551	    }
552
553	    // ── Create-new: id entry ─────────────────────────────────────────────
554	    /// Append a typed char to the id, re-validating the charset live.
555	    pub fn id_push(&mut self, c: char) {
556	        self.id_buffer.push(c);
557	        self.revalidate_id();
558	    }
559
560	    pub fn id_backspace(&mut self) {
561	        self.id_buffer.pop();
562	        self.revalidate_id();
563	    }
564
565	    fn revalidate_id(&mut self) {
566	        self.id_error = match spt_proto::id::validate_endpoint_id(&self.id_buffer) {
567	            Ok(()) => None,
568	            Err(e) => Some(e.to_string()),
569	        };
570	    }
571
572	    /// The create-new outcome (bare adapter address + typed id, fresh session,
573	    /// attach). `None` if the id is empty/invalid or no adapter is selected.
574	    pub fn create_outcome(&self) -> Option<Outcome> {
575	        if self.id_buffer.is_empty() || self.id_error.is_some() {
576	            return None;
577	        }
578	        let adapter = self.selected_adapter()?.address();
579	        Some(Outcome::Run {
580	            adapter,
581	            id: self.id_buffer.clone(),
582	            resume: None,
583	            cwd: None,
584	            subnet: self.selected_home(),
585	            mode: RunMode::Attach,
586	        })
587	    }
588
589	    // ── Create-new: home-subnet layer (multi-subnet only) ─────────────────
590	    /// CreateId Enter: with a valid id, advance to the home-subnet layer on a
591	    /// MULTI-subnet node (>= 2 member subnets), else yield the create-new outcome
592	    /// directly (single-subnet / unpaired — `assign_home` auto-homes the sole
593	    /// subnet so `subnet=None` is correct). Create-new has no Confirm screen — it
594	    /// terminates by yielding the Run outcome; CreateHome inserts before that.
595	    /// `None` (no yield) means "stay on a screen" (invalid id, or moved to CreateHome).
596	    // [impl->REQ-RUN-PICKER-HOME]
597	    pub fn enter_id(&mut self) -> Option<Outcome> {
598	        if self.id_buffer.is_empty() || self.id_error.is_some() {
599	            return None;
600	        }
601	        if self.home_subnets.len() >= 2 {
602	            self.screen = Screen::CreateHome;
603	            self.home_cursor = 0;
604	            return None;
605	        }
606	        self.create_outcome()
607	    }
608
609	    /// The picked home subnet — `Some` ONLY when the layer was actually shown
610	    /// (>= 2 member subnets), so a single-subnet / unpaired node (layer skipped)
611	    /// yields `None` and `cmd_endpoint_run` auto-homes. REQ-RUN-PICKER-HOME.
612	    // [impl->REQ-RUN-PICKER-HOME]
613	    pub fn selected_home(&self) -> Option<String> {
614	        if self.home_subnets.len() >= 2 {
615	            self.home_subnets.get(self.home_cursor).cloned()
616	        } else {
617	            None
618	        }
619	    }
620
621	    pub fn move_home(&mut self, delta: isize) {
622	        self.home_cursor = Self::step(self.home_cursor, self.home_subnets.len(), delta);
623	    }
624
625	    // ── Pick-existing: category + list ───────────────────────────────────
626	    /// Cycle the category ring left (-1) or right (+1); resets the item cursor.
627	    pub fn move_category(&mut self, delta: isize) {
628	        let cur = Category::ALL.iter().position(|c| *c == self.category).unwrap_or(0);
629	        let len = Category::ALL.len() as isize;
630	        let next = ((cur as isize + delta) % len + len) % len; // category wraps
631	        self.category = Category::ALL[next as usize];
632	        self.item_cursor = 0;
633	    }
634
635	    /// Indices into `endpoints` visible in the current category, after the
636	    /// type-to-filter, ordered grouped-then-alpha (group asc, then id asc) with
637	    /// the fuzzy filter applied. The single source the view and the cursor share.
638	    // [impl->REQ-RUN-PICKER]
639	    pub fn visible_indices(&self) -> Vec<usize> {
640	        let want_local = self.category == Category::Local;
641	        // Project = local endpoints whose history names the cwd project; Local =
642	        // all local perches; Subnet = remote rows. (data.rs sets is_local +
643	        // group; the model just partitions.)
644	        let mut idx: Vec<usize> = self
645	            .endpoints
646	            .iter()
647	            .enumerate()
648	            .filter(|(_, e)| match self.category {
649	                Category::Local => e.is_local,
650	                Category::Subnet => !e.is_local,
651	                Category::Project => {
652	                    e.is_local && e.project_history.iter().any(|p| p == &self.project_name)
653	                }
654	            })
655	            .map(|(i, _)| i)
656	            .collect();
657	        let _ = want_local;
658	        // Grouped then alpha.
659	        idx.sort_by(|&a, &b| {
660	            let ea = &self.endpoints[a];
661	            let eb = &self.endpoints[b];
662	            ea.group.cmp(&eb.group).then(ea.id.cmp(&eb.id))
663	        });
664	        if self.query.is_empty() {
665	            return idx;
666	        }
667	        // Fuzzy-filter by id, preserving match-score order (nucleo).
668	        let mut matcher = Matcher::new(Config::DEFAULT);
669	        let pattern = Pattern::parse(&self.query, CaseMatching::Ignore, Normalization::Smart);
670	        let names: Vec<String> = idx.iter().map(|&i| self.endpoints[i].id.clone()).collect();
671	        let scored = pattern.match_list(names.iter().map(|s| s.as_str()), &mut matcher);
672	        // map matched names back to their endpoint indices, score order
673	        scored
674	            .into_iter()
675	            .filter_map(|(name, _)| {
676	                idx.iter().copied().find(|&i| self.endpoints[i].id == name)
677	            })
678	            .collect()
679	    }
680
681	    /// The endpoint under the item cursor (within the current visible set).
682	    pub fn selected_endpoint(&self) -> Option<&EndpointRow> {
683	        let vis = self.visible_indices();
684	        vis.get(self.item_cursor).map(|&i| &self.endpoints[i])
685	    }
686
687	    pub fn move_item(&mut self, delta: isize) {
688	        let len = self.visible_indices().len();
689	        self.item_cursor = Self::step(self.item_cursor, len, delta);
690	    }
691
692	    // ── Filter ───────────────────────────────────────────────────────────
693	    pub fn start_filter(&mut self) {
694	        self.filtering = true;
695	    }
696	    pub fn filter_push(&mut self, c: char) {
697	        self.query.push(c);
698	        self.item_cursor = 0;
699	    }
700	    pub fn filter_backspace(&mut self) {
701	        self.query.pop();
702	        self.item_cursor = 0;
703	    }
704	    pub fn end_filter(&mut self) {
705	        self.filtering = false;
706	    }
707
708	    /// Enter the confirm layer for the selected endpoint.
709	    pub fn enter_pick(&mut self) {
710	        if self.selected_endpoint().is_some() {
711	            self.screen = Screen::Confirm;
712	            self.confirm_cursor = 0;
713	        }
714	    }
715
716	    // ── Confirm layer ────────────────────────────────────────────────────
717	    /// The status-dependent option set for the selected endpoint (picker spec /
718	    /// doyle Q1/Q3). Order is stable so the cursor + the view agree.
719	    // [impl->REQ-RUN-PICKER]
720	    pub fn confirm_options(&self) -> Vec<ConfirmOption> {
721	        let Some(ep) = self.selected_endpoint() else {
722	            return Vec::new();
723	        };
724	        let offline = ep.status == EpStatus::Offline;
725	        // W2.5 status-conditional (REQ-RCVIEW-1 / REQ-KICK-1): an ONLINE endpoint a
726	        // DIFFERENT node already controls offers View + Kick ONLY — NO plain Attach
727	        // (it would hit the busy-refuse / risk a silent steal). A free / offline
728	        // endpoint keeps the full set (Attach = Control).
729	        let controlled = !offline && ep.driven_by.is_some();
730	        let mut opts = if controlled {
731	            vec![ConfirmOption::View, ConfirmOption::Kick]
732	        } else if offline {
733	            // Offline: nothing to attach to yet — bring it up ("Start now").
734	            vec![ConfirmOption::Start, ConfirmOption::View]
735	        } else {
736	            // Online + free: the PTY is live → Attach. NO "Start now" — it is
737	            // already running (an online endpoint must offer Attach, never Start).
738	            // [impl->REQ-PICKER-ONLINE-ACTION]
739	            vec![ConfirmOption::Attach, ConfirmOption::View]
740	        };
741	        // Resume-from-history: offline + LOCAL only, and only if the ledger has
742	        // rows (doyle Q3). A remote offline endpoint gets Instantiate instead.
743	        if offline && ep.is_local && !ep.resume_rows.is_empty() {
744	            opts.push(ConfirmOption::Resume);
745	        }
746	        // Change-harness-adapter: offline only.
747	        if offline {
748	            opts.push(ConfirmOption::ChangeAdapter);
749	        }
750	        // Instantiate-locally: a remote endpoint copied up here.
751	        if !ep.is_local {
752	            opts.push(ConfirmOption::Instantiate);
753	        }
754	        // Fork: always available.
755	        opts.push(ConfirmOption::Fork);
756	        // The `s` shortcut is always available (a keybind, listed last).
757	        opts.push(ConfirmOption::Shortcut);
758	        opts
759	    }
760
761	    pub fn move_confirm(&mut self, delta: isize) {
762	        let len = self.confirm_options().len();
763	        self.confirm_cursor = Self::step(self.confirm_cursor, len, delta);
764	    }
765
766	    pub fn selected_confirm(&self) -> Option<ConfirmOption> {
767	        self.confirm_options().get(self.confirm_cursor).copied()
768	    }
769
770	    /// The terminal [`Outcome`] a confirm option yields IMMEDIATELY (no further
771	    /// screen). Online endpoints attach via the rc pump; offline endpoints
772	    /// bring up via `cmd_endpoint_run` with the endpoint's recorded adapter.
773	    /// `Shortcut` bakes the current selection. The re-entrant options
774	    /// (Resume / ChangeAdapter / Instantiate / Fork) return `None` — they are
775	    /// SCREEN transitions, driven by [`enter_resume`](Self::enter_resume) /
776	    /// [`reenter_create`](Self::reenter_create). Every yielded variant routes
777	    /// through an existing bringup fn — no second bringup path.
778	    // [impl->REQ-RUN-PICKER]
779	    pub fn confirm_terminal(&self, opt: ConfirmOption) -> Option<Outcome> {
780	        let ep = self.selected_endpoint()?;
781	        let online = ep.status == EpStatus::Online;
782	        // A confirm-layer bringup targets an EXISTING perch (offline local) → its
783	        // home is immutable, so no picker home pick rides here (REQ-RUN-PICKER-HOME:
784	        // the home layer is a create-new-only concern; resolve_home_and_write_skeleton
785	        // early-returns for an existing perch).
786	        let bringup = |mode| Outcome::Run {
787	            adapter: ep.adapter_profile.clone(),
788	            id: ep.id.clone(),
789	            resume: None,
790	            cwd: None,
791	            subnet: None,
792	            mode,
793	        };
794	        use spt_net::net::attach::AttachIntent;
795	        match opt {
796	            // An online endpoint's PTY is live → attach the rc pump; an offline
797	            // one is brought up then ATTACHED by default (W4 UX: Start-now lands you
798	            // in the live session, not detached with no stdout — the `h` keybind is
799	            // the headless escape, see [`start_headless_outcome`](Self::start_headless_outcome)).
800	            // Attach = Control intent.
801	            ConfirmOption::Attach | ConfirmOption::Start => Some(if online {
802	                Outcome::Attach { id: ep.id.clone(), intent: AttachIntent::Control }
803	            } else {
804	                bringup(RunMode::Attach)
805	            }),
806	            ConfirmOption::View => Some(if online {
807	                Outcome::Attach { id: ep.id.clone(), intent: AttachIntent::Viewer }
808	            } else {
809	                bringup(RunMode::View)
810	            }),
811	            // Kick = Take intent: only offered on an online, controlled endpoint.
812	            ConfirmOption::Kick => Some(Outcome::Attach {
813	                id: ep.id.clone(),
814	                intent: AttachIntent::Take,
815	            }),
816	            ConfirmOption::Shortcut => Some(Outcome::Shortcut {
817	                adapter: ep.adapter_profile.clone(),
818	                id: ep.id.clone(),
819	                resume: None,
820	                mode: RunMode::Attach,
821	            }),
822	            // Screen transitions, not immediate outcomes.
823	            ConfirmOption::Resume
824	            | ConfirmOption::ChangeAdapter
825	            | ConfirmOption::Instantiate
826	            | ConfirmOption::Fork => None,
827	        }
828	    }
829
830	    /// The HEADLESS bringup (start, no attach) for the selected endpoint — the
831	    /// `h` escape on the Confirm screen (W4 UX). Only meaningful for an OFFLINE
832	    /// endpoint (an online one is already up); returns `None` otherwise so the key
833	    /// is a no-op. Mirrors the default attach bringup but bakes `RunMode::Start` so
834	    /// the session comes up detached — routes through `cmd_endpoint_run` like every
835	    /// other bringup (no second path).
836	    // [impl->REQ-RUN-PICKER]
837	    pub fn start_headless_outcome(&self) -> Option<Outcome> {
838	        let ep = self.selected_endpoint()?;
839	        if ep.status != EpStatus::Offline {
840	            return None;
841	        }
842	        Some(Outcome::Run {
843	            adapter: ep.adapter_profile.clone(),
844	            id: ep.id.clone(),
845	            resume: None,
846	            cwd: None,
847	            subnet: None, // existing perch — home immutable (REQ-RUN-PICKER-HOME)
848	            mode: RunMode::Start,
849	        })
850	    }
851
852	    // ── Resume sub-screen (offline+local only) ───────────────────────────
853	    pub fn enter_resume(&mut self) {
854	        self.screen = Screen::Resume;
855	        self.resume_cursor = 0;
856	    }
857
858	    pub fn resume_rows(&self) -> &[ResumeRow] {
859	        self.selected_endpoint().map(|e| e.resume_rows.as_slice()).unwrap_or(&[])
860	    }
861
862	    pub fn move_resume(&mut self, delta: isize) {
863	        let len = self.resume_rows().len();
864	        self.resume_cursor = Self::step(self.resume_cursor, len, delta);
865	    }
866
867	    pub fn selected_resume(&self) -> Option<&ResumeRow> {
868	        self.resume_rows().get(self.resume_cursor)
869	    }
870
871	    /// Resume-from-history outcome: bring the endpoint up against the chosen
872	    /// prior session id (`cmd_endpoint_run --resume`). `headless` (the `h` keybind,
873	    /// W4 UX) brings it up detached (`RunMode::Start`); the default ATTACHES
874	    /// (`RunMode::Attach`) so a resume lands you back in the live session.
875	    // [impl->REQ-RUN-PICKER]
876	    pub fn resume_outcome(&self, headless: bool) -> Option<Outcome> {
877	        let row = self.selected_resume()?;
878	        let session_id = row.session_id.clone();
879	        // Thread the row's recorded cwd so the resumed harness lands in the
880	        // session's project dir — CC resolves a transcript by session_id + cwd
881	        // (REQ-SESSION-RESUME-TEMPLATE). An old row with no cwd resolves through
882	        // cmd_endpoint_run's fallback chain (info.cwd → current_dir).
883	        let cwd = row.cwd.clone();
884	        let ep = self.selected_endpoint()?;
885	        Some(Outcome::Run {
886	            adapter: ep.adapter_profile.clone(),
887	            id: ep.id.clone(),
888	            resume: Some(session_id),
889	            cwd,
890	            subnet: None, // existing perch — home immutable (REQ-RUN-PICKER-HOME)
891	            mode: if headless { RunMode::Start } else { RunMode::Attach },
892	        })
893	    }
894
895	    /// Re-enter the create-new adapter screen, the spec's "L2@create-new"
896	    /// path shared by Change-harness-adapter / Instantiate-locally (keep the id)
897	    /// and Fork (fresh id). All three converge on `cmd_endpoint_run` — one
898	    /// bringup core. `keep_id` carries the existing endpoint's id forward.
899	    pub fn reenter_create(&mut self, keep_id: bool) {
900	        let id = if keep_id {
901	            self.selected_endpoint().map(|e| e.id.clone()).unwrap_or_default()
902	        } else {
903	            String::new()
904	        };
905	        self.id_buffer = id;
906	        self.revalidate_id();
907	        self.adapter_cursor = 0;
908	        self.screen = Screen::CreateAdapter;
909	    }
910
911	    /// Back out one screen; returns `true` if it backed out of the whole picker
912	    /// (the caller then yields `Outcome::Cancelled`).
913	    pub fn back(&mut self) -> bool {
914	        match self.screen {
915	            Screen::Kind => true,
916	            Screen::CreateAdapter => {
917	                self.screen = Screen::Kind;
918	                false
919	            }
920	            Screen::CreateId => {
921	                self.screen = Screen::CreateAdapter;
922	                false
923	            }
924	            Screen::CreateHome => {
925	                self.screen = Screen::CreateId;
926	                false
927	            }
928	            Screen::PickExisting => {
929	                self.screen = Screen::Kind;
930	                false
931	            }
932	            Screen::Confirm => {
933	                self.screen = Screen::PickExisting;
934	                false
935	            }
936	            Screen::Resume => {
937	                self.screen = Screen::Confirm;
938	                false
939	            }
940	        }
941	    }
942	}
943
944	#[cfg(test)]
945	mod tests {
946	    use super::*;
947
948	    fn info(name: &str, shipped: &[&str], local: &[&str]) -> AdapterInfo {
949	        AdapterInfo {
950	            name: name.to_string(),
951	            shipped: shipped.iter().map(|s| s.to_string()).collect(),
952	            local: local.iter().map(|s| s.to_string()).collect(),
953	        }
954	    }
955
956	    fn ep(id: &str, group: &str, status: EpStatus, is_local: bool) -> EndpointRow {
957	        EndpointRow {
958	            id: id.to_string(),
959	            group: group.to_string(),
960	            node: "n".to_string(),
961	            status,
962	            is_local,
963	            adapter_profile: "claude-spt".to_string(),
964	            project_history: vec![],
965	            description: String::new(),
966	            resume_rows: vec![],
967	            driven_by: None,
968	            viewer_count: 0,
969	            controllable: None,
970	            endpoint_type: "live_agent".to_string(),
971	            is_unbound: false,
972	        }
973	    }
974
975	    // [unit->REQ-RUN-PICKER] the status→square mapping is the W2 duality (no blue).
976	    #[test]
977	    fn status_square_is_online_offline_only() {
978	        assert_eq!(EpStatus::Online.square(), glyph::ONLINE);
979	        assert_eq!(EpStatus::Offline.square(), glyph::OFFLINE);
980	    }
981
982	    // [unit->REQ-PICKER-1] the four-state DISPLAY derivation is type-gated: amber
983	    // "harness-only" is LiveAgent-only; offline→gray, controlled→blue, a non-live
984	    // online endpoint→green regardless of controllability, a live agent that isn't
985	    // controllable (Some(false) OR a legacy None)→amber, a controllable live→green.
986	    #[test]
987	    fn display_status_four_state_type_gated() {
988	        let mut e = ep("a", "g", EpStatus::Offline, true);
989	        assert_eq!(e.display_status(), EpDisplay::Offline, "offline → gray");
990
991	        // Online harness-hosted live agent → amber.
992	        e.status = EpStatus::Online;
993	        e.endpoint_type = "live_agent".into();
994	        e.controllable = Some(false);
995	        assert_eq!(e.display_status(), EpDisplay::HarnessOnly);
996	        // A legacy live agent (controllable unknown) is also amber (self-corrects).
997	        e.controllable = None;
998	        assert_eq!(e.display_status(), EpDisplay::HarnessOnly, "legacy None → amber");
999	        // A controllable (spt-hosted) live agent → green.
1000	        e.controllable = Some(true);
1001	        assert_eq!(e.display_status(), EpDisplay::Online);
1002
1003	        // A controlled endpoint → blue, outranking harness-only.
1004	        e.controllable = Some(false);
1005	        e.driven_by = Some("cafe".into());
1006	        assert_eq!(e.display_status(), EpDisplay::Controlled, "driven_by → blue");
1007
1008	        // A NON-live online endpoint (gateway) is never amber — green, even with
1009	        // controllable == Some(false)/None.
1010	        let mut gw = ep("gw", "g", EpStatus::Online, true);
1011	        gw.endpoint_type = "gateway".into();
1012	        gw.controllable = None;
1013	        assert_eq!(gw.display_status(), EpDisplay::Online, "gateway never amber");
1014	        gw.controllable = Some(false);
1015	        assert_eq!(gw.display_status(), EpDisplay::Online);
1016
1017	        // The labels match the spec.
1018	        assert_eq!(EpDisplay::Offline.label(), "OFFLINE");
1019	        assert_eq!(EpDisplay::Online.label(), "ONLINE");
1020	        assert_eq!(EpDisplay::HarnessOnly.label(), "ONLINE - HARNESS ONLY");
1021	        assert_eq!(EpDisplay::Controlled.label(), "ONLINE + CONTROLLED");
1022	    }
1023
1024	    // [unit->REQ-ENDPOINT-UNBOUND-ATTACH] the UNBOUND display LOGIC: a live
1025	    // pre-bind session reads status ONLINE (see data::row_status — an unbound perch
1026	    // counts as online at the raw level) + is_unbound → hollow Unbound; + driven_by
1027	    // → UnboundControlled. NOTE the real (status, is_unbound) pairing is exercised
1028	    // by `data::tests::row_status_drives_unbound_display` — here the inputs are set
1029	    // directly to test the display branch in isolation.
1030	    #[test]
1031	    fn display_status_unbound_is_hollow_and_attachable() {
1032	        // Live unbound (status online, is_unbound) → Unbound, regardless of the
1033	        // controllability that would otherwise drive amber.
1034	        let mut e = ep("u", "g", EpStatus::Online, true);
1035	        e.is_unbound = true;
1036	        e.controllable = Some(false); // would be HarnessOnly if not unbound
1037	        assert_eq!(e.display_status(), EpDisplay::Unbound, "unbound → hollow, not amber");
1038
1039	        // A node driving the unbound endpoint → the hollow-controlled variant.
1040	        e.driven_by = Some("cafe".into());
1041	        assert_eq!(
1042	            e.display_status(),
1043	            EpDisplay::UnboundControlled,
1044	            "unbound + driven_by → hollow-controlled"
1045	        );
1046
1047	        // A REAL offline perch (is_unbound=false — B2 clears the unbound status when
1048	        // it stamps a real offline, so status==Offline && is_unbound never co-occur)
1049	        // → Offline. (The old test asserted Offline for status=Offline+is_unbound,
1050	        // which encoded an impossible combo AND the masking bug — removed.)
1051	        let dead = ep("d", "g", EpStatus::Offline, true); // is_unbound defaults false
1052	        assert_eq!(dead.display_status(), EpDisplay::Offline, "a real offline → gray");
1053
1054	        // A plain online row with the unbound flag clear is unaffected.
1055	        let plain = ep("p", "g", EpStatus::Online, true);
1056	        assert_ne!(plain.display_status(), EpDisplay::Unbound);
1057
1058	        assert_eq!(EpDisplay::Unbound.label(), "UNBOUND");
1059	        assert_eq!(EpDisplay::UnboundControlled.label(), "UNBOUND + CONTROLLED");
1060	    }
1061
1062	    // [unit->REQ-RUN-PICKER] the adapter tree nests profiles under each adapter,
1063	    // bare-root first then shipped then local, each composite-addressable.
1064	    #[test]
1065	    fn adapter_tree_nests_profiles() {
1066	        let rows = build_adapter_tree(&[
1067	            info("claude-spt", &["fast"], &["mine"]),
1068	            info("ccs", &[], &[]),
1069	        ]);
1070	        let addrs: Vec<String> = rows.iter().map(|r| r.address()).collect();
1071	        assert_eq!(
1072	            addrs,
1073	            vec!["claude-spt", "claude-spt:fast", "claude-spt:mine", "ccs"]
1074	        );
1075	        assert!(!rows[0].is_leaf, "bare-adapter root is not a leaf");
1076	        assert!(rows[1].is_leaf, "a profile is a leaf");
1077	    }
1078
1079	    // [unit->REQ-SESSION-RESUME-TEMPLATE] resume titles render
1080	    // `<project|trigger> - <local_time> (…id5)` (design D): ` - ` separator, a
1081	    // local-time stamp (`HH:MM<AM|PM> <offset> MM-DD-YYYY`), the trailing `…id5`.
1082	    // The time portion is machine-LOCAL, so assert the SHAPE, not an exact clock.
1083	    #[test]
1084	    fn resume_title_render() {
1085	        let with_proj = ResumeRow {
1086	            session_id: "abcdef12345".to_string(),
1087	            ts: "2026-06-14T10:00:00Z".to_string(),
1088	            trigger: "clear".to_string(),
1089	            project: "spt-core".to_string(),
1090	            cwd: None,
1091	        };
1092	        let t = with_proj.title();
1093	        assert!(t.starts_with("spt-core - "), "project head + ` - ` sep: {t}");
1094	        assert!(t.ends_with(" (…12345)"), "trailing …id5: {t}");
1095	        assert!(
1096	            t.contains("AM") || t.contains("PM"),
1097	            "12-hour AM/PM local-time stamp: {t}"
1098	        );
1099	        assert!(!t.contains(" @ "), "old `@ <rfc3339>` form is gone: {t}");
1100
1101	        // Parse failure degrades to the raw ts (never panics). Short id (<5) takes
1102	        // the whole thing; no project falls back to the trigger.
1103	        let no_proj = ResumeRow {
1104	            session_id: "xy".to_string(),
1105	            ts: "T".to_string(),
1106	            trigger: "boot".to_string(),
1107	            project: String::new(),
1108	            cwd: None,
1109	        };
1110	        assert_eq!(no_proj.title(), "boot - T (…xy)");
1111	    }
1112
1113	    // [unit->REQ-RUN-PICKER] a bare picker OPENS on Pick-existing (W4 UX); the Kind
1114	    // layer is a jump-target (reached via Esc/back) that still routes into the right
1115	    // branch.
1116	    #[test]
1117	    fn kind_routes() {
1118	        // A NON-empty scope opens on Pick-existing (REQ-RUN-EMPTY-CREATE: only a
1119	        // totally-empty scope routes to create — see empty_scope_opens_create).
1120	        let mut m = PickerModel::new(
1121	            "p".into(),
1122	            vec![],
1123	            vec![ep("a", "g", EpStatus::Online, true)],
1124	            None,
1125	            None,
1126	            vec![],
1127	        );
1128	        assert_eq!(m.screen, Screen::PickExisting, "bare picker opens on Pick-existing");
1129	        m.screen = Screen::Kind; // jump back to the Layer-1 menu
1130	        m.kind_cursor = 0;
1131	        m.enter_kind();
1132	        assert_eq!(m.screen, Screen::CreateAdapter);
1133	        m.back();
1134	        m.kind_cursor = 1;
1135	        m.enter_kind();
1136	        assert_eq!(m.screen, Screen::PickExisting);
1137	    }
1138
1139	    // [unit->REQ-RUN-EMPTY-CREATE] a TOTALLY-empty scope (no local AND no subnet
1140	    // endpoint) opens straight on the creation flow, not a blank Pick-existing
1141	    // list; a subnet-only scope (nothing local, but something to pick) stays on
1142	    // Pick-existing.
1143	    #[test]
1144	    fn empty_scope_opens_create() {
1145	        let empty = PickerModel::new("p".into(), vec![], vec![], None, None, vec![]);
1146	        assert_eq!(
1147	            empty.screen,
1148	            Screen::CreateAdapter,
1149	            "an empty scope routes to creation"
1150	        );
1151
1152	        // A remote (subnet) endpoint, nothing local → still has something to pick.
1153	        let subnet_only = PickerModel::new(
1154	            "p".into(),
1155	            vec![],
1156	            vec![ep("r", "g", EpStatus::Online, false)],
1157	            None,
1158	            None,
1159	            vec![],
1160	        );
1161	        assert_eq!(
1162	            subnet_only.screen,
1163	            Screen::PickExisting,
1164	            "subnet-but-no-local still picks"
1165	        );
1166	    }
1167
1168	    // [unit->REQ-RUN-PICKER] cursor windowing clamps (no wrap) at both ends.
1169	    #[test]
1170	    fn cursor_clamps() {
1171	        assert_eq!(PickerModel::step(0, 3, -1), 0);
1172	        assert_eq!(PickerModel::step(2, 3, 1), 2);
1173	        assert_eq!(PickerModel::step(1, 3, 1), 2);
1174	        assert_eq!(PickerModel::step(0, 0, 1), 0);
1175	    }
1176
1177	    // [unit->REQ-RUN-PICKER] category nav wraps the ring + resets the item cursor.
1178	    #[test]
1179	    fn category_ring_wraps() {
1180	        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None, vec![]);
1181	        assert_eq!(m.category, Category::Project);
1182	        m.move_category(-1);
1183	        assert_eq!(m.category, Category::Subnet, "left from first wraps to last");
1184	        m.move_category(1);
1185	        assert_eq!(m.category, Category::Project);
1186	    }
1187
1188	    // [unit->REQ-RUN-PICKER] visible_indices partitions by category, grouped+alpha.
1189	    #[test]
1190	    fn visible_partitions_and_sorts() {
1191	        let eps = vec![
1192	            ep("zeta", "g1", EpStatus::Online, true),
1193	            ep("alpha", "g1", EpStatus::Offline, true),
1194	            ep("remote", "subnet:n", EpStatus::Online, false),
1195	        ];
1196	        let mut m = PickerModel::new("p".into(), vec![], eps, None, None, vec![]);
1197	        m.category = Category::Local;
1198	        let vis = m.visible_indices();
1199	        let ids: Vec<&str> = vis.iter().map(|&i| m.endpoints[i].id.as_str()).collect();
1200	        assert_eq!(ids, vec!["alpha", "zeta"], "local, alpha within group");
1201	        m.category = Category::Subnet;
1202	        let ids: Vec<&str> = m
1203	            .visible_indices()
1204	            .iter()
1205	            .map(|&i| m.endpoints[i].id.as_str())
1206	            .collect();
1207	        assert_eq!(ids, vec!["remote"]);
1208	    }
1209
1210	    // [unit->REQ-RUN-PICKER] type-to-filter narrows the visible set.
1211	    #[test]
1212	    fn filter_narrows() {
1213	        let eps = vec![
1214	            ep("doyle", "g", EpStatus::Online, true),
1215	            ep("todlando", "g", EpStatus::Online, true),
1216	        ];
1217	        let mut m = PickerModel::new("p".into(), vec![], eps, None, None, vec![]);
1218	        m.category = Category::Local;
1219	        assert_eq!(m.visible_indices().len(), 2);
1220	        m.start_filter();
1221	        for c in "doy".chars() {
1222	            m.filter_push(c);
1223	        }
1224	        let vis = m.visible_indices();
1225	        assert_eq!(vis.len(), 1);
1226	        assert_eq!(m.endpoints[vis[0]].id, "doyle");
1227	    }
1228
1229	    // [unit->REQ-RUN-PICKER] confirm options are status-dependent: offline+local
1230	    // with ledger rows gets Resume; online does not; remote gets Instantiate.
1231	    #[test]
1232	    fn confirm_options_status_dependent() {
1233	        let mut offline_local = ep("a", "g", EpStatus::Offline, true);
1234	        offline_local.resume_rows = vec![ResumeRow {
1235	            session_id: "s".into(),
1236	            ts: "t".into(),
1237	            trigger: "boot".into(),
1238	            project: String::new(),
1239	            cwd: None,
1240	        }];
1241	        let mut m = PickerModel::new("p".into(), vec![], vec![offline_local], None, None, vec![]);

//! The picker **state model** — pure data + transitions, no terminal I/O.
//!
//! Everything testable about the picker lives here: the screen graph, cursor
//! windowing, category navigation, type-to-filter narrowing, the status→square
//! mapping, the adapter→profile tree build, the ledger→resume-title render, and
//! the confirm-layer option set. The live key loop ([`super::run`]) is a thin
//! driver over these transitions; `view.rs` renders a `&PickerModel` to a
//! ratatui `Buffer`. Keeping this layer pure is what lets a future tap-mode
//! (phone PTY) reuse the exact same model without re-coupling to keybinds
//! (the phone caveat).
// [impl->REQ-RUN-PICKER]

use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};

/// The recognizable glyph set, lifted from `vercel-labs/skills`
/// (`src/prompts/search-multiselect.ts`) for look/feel parity.
pub mod glyph {
    /// Online endpoint — filled square (rendered green in the view).
    pub const ONLINE: &str = "■";
    /// Offline endpoint — hollow square (rendered gray in the view).
    pub const OFFLINE: &str = "▢";
    /// The cursor caret.
    pub const CURSOR: &str = "❯";
    /// A tree branch into a nested profile.
    pub const BRANCH: &str = "└─";
}

/// Online / offline — the W2 status duality. The blue "attached" tri-state is
/// DEFERRED (doyle ruling Q1: it needs a broker attach-presence query + a
/// force-detach op, its own slice). Derived from the registry `Status` in
/// `data.rs`; the model only ever sees this reduced form.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EpStatus {
    Online,
    Offline,
}

impl EpStatus {
    /// The status square glyph (color is applied in the view).
    pub fn square(self) -> &'static str {
        match self {
            EpStatus::Online => glyph::ONLINE,
            EpStatus::Offline => glyph::OFFLINE,
        }
    }
}

/// The **four-state display status** the picker renders — the list-item square's
/// colour and the pick-existing details STATUS line (REQ-PICKER-1). Derived from
/// the raw online/offline ([`EpStatus`]) plus broker-PTY-controllability, the
/// remote-control marker, and the endpoint type. Distinct from [`EpStatus`] (the
/// raw duality the confirm-layer logic keys on).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EpDisplay {
    /// Gray hollow ▢ — offline.
    Offline,
    /// Green filled ■ — online + broker-PTY-controllable (spt-hosted), free.
    Online,
    /// Amber filled ■ — online LiveAgent that is NOT broker-PTY-controllable
    /// (harness-hosted, no broker PTY seat).
    HarnessOnly,
    /// Blue filled ■ — online + a node currently controls it (`driven_by`).
    Controlled,
    /// Green HOLLOW ▢ — UNBOUND (REQ-ENDPOINT-UNBOUND-ATTACH): a live broker
    /// session exists but the perch has not bound yet, so it is ATTACHABLE but
    /// not message-addressable. Hollow (the bound/unbound fill duality) in a live
    /// colour — the opposite of amber (amber = online-but-not-controllable; an
    /// unbound endpoint IS broker-attachable).
    Unbound,
    /// Blue HOLLOW ▢ — an UNBOUND endpoint a node is currently driving
    /// (`driven_by`). Parallels filled Online→Controlled for the unbound state.
    UnboundControlled,
}

impl EpDisplay {
    /// The STATUS-line / legend label.
    pub fn label(self) -> &'static str {
        match self {
            EpDisplay::Offline => "OFFLINE",
            EpDisplay::Online => "ONLINE",
            EpDisplay::HarnessOnly => "ONLINE - HARNESS ONLY",
            EpDisplay::Controlled => "ONLINE + CONTROLLED",
            EpDisplay::Unbound => "UNBOUND",
            EpDisplay::UnboundControlled => "UNBOUND + CONTROLLED",
        }
    }
}

/// One selectable adapter option in the create-new tree: a registered
/// `kind="harness"` adapter, optionally narrowed to one of its profiles. A
/// `None` profile = the bare adapter (its default view). The composite
/// `<adapter>[:profile]` address is what `cmd_endpoint_run` resolves.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterOption {
    pub adapter: String,
    pub profile: Option<String>,
    /// `true` for a profile leaf, `false` for the bare-adapter root row — drives
    /// the tree indentation in the view.
    pub is_leaf: bool,
}

impl AdapterOption {
    /// The composite `<adapter>` or `<adapter>:<profile>` address.
    pub fn address(&self) -> String {
        match &self.profile {
            Some(p) => format!("{}:{}", self.adapter, p),
            None => self.adapter.clone(),
        }
    }
}

/// The shipped+local profiles known for one adapter (gathered in `data.rs`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterInfo {
    pub name: String,
    /// Shipped profile names (from the parent manifest), sorted.
    pub shipped: Vec<String>,
    /// Local overlay profile names (beside the adapter), sorted.
    pub local: Vec<String>,
}

/// Flatten the gathered adapters into the tree-nested selectable row list: for
/// each adapter, a bare-adapter root row followed by one leaf row per profile
/// (shipped then local), each addressable as `<adapter>:<profile>`.
// [impl->REQ-RUN-PICKER]
pub fn build_adapter_tree(adapters: &[AdapterInfo]) -> Vec<AdapterOption> {
    let mut rows = Vec::new();
    for a in adapters {
        rows.push(AdapterOption {
            adapter: a.name.clone(),
            profile: None,
            is_leaf: false,
        });
        for p in a.shipped.iter().chain(a.local.iter()) {
            rows.push(AdapterOption {
                adapter: a.name.clone(),
                profile: Some(p.clone()),
                is_leaf: true,
            });
        }
    }
    rows
}

/// One prior session from the per-endpoint ledger, rendered as a Resume option.
/// Built from `spt_store::sessions::SessionEntry` in `data.rs`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResumeRow {
    pub session_id: String,
    pub ts: String,
    /// The boundary token (`boot`/`clear`/`compact`) — the title fallback when
    /// no project association exists (the ledger row carries no project).
    pub trigger: String,
    /// Best-effort project name (empty if none derivable).
    pub project: String,
    /// The session's recorded project cwd (ledger `cwd`, REQ-SESSION-RESUME-TEMPLATE).
    /// `None` for a pre-migration row → resume falls back to info.cwd/current_dir.
    pub cwd: Option<String>,
}

impl ResumeRow {
    /// The option title: `<project|trigger> - <local_time> (…<id5>)` (picker
    /// spec + design D). `local_time` renders the stored RFC3339-UTC `ts` in the
    /// operator's LOCAL timezone for human legibility.
    pub fn title(&self) -> String {
        let head = if self.project.is_empty() {
            &self.trigger
        } else {
            &self.project
        };
        let id5: String = {
            let s = &self.session_id;
            let n = s.chars().count();
            s.chars().skip(n.saturating_sub(5)).collect()
        };
        format!("{head} - {} (…{id5})", Self::local_time(&self.ts))
    }

    /// Render an RFC3339-UTC `ts` as a local-time human stamp
    /// `HH:MM<AM|PM> MM-DD-YYYY` (e.g. `03:45PM 06-19-2026`). The ts is converted
    /// to the operator's LOCAL timezone, so no tz token is shown (operator ruling,
    /// design D resolve: the local conversion already conveys the zone; a short
    /// abbreviation is unreliable cross-platform and the numeric offset is noise).
    /// A parse failure degrades to the raw `ts` — the picker degrades, never
    /// panics.
    // [impl->REQ-SESSION-RESUME-TEMPLATE]
    fn local_time(ts: &str) -> String {
        match chrono::DateTime::parse_from_rfc3339(ts) {
            Ok(dt) => dt
                .with_timezone(&chrono::Local)
                .format("%I:%M%p %m-%d-%Y")
                .to_string(),
            Err(_) => ts.to_string(),
        }
    }
}

/// A pick-existing category. Left/right cycles through these.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
    /// Endpoints scoped to the cwd project.
    Project,
    /// This node's local perches.
    Local,
    /// Subnet endpoints.
    Subnet,
}

impl Category {
    /// Display label (the project category carries the cwd project name).
    pub fn label(self, project_name: &str) -> String {
        match self {
            Category::Project => format!("{project_name} project"),
            Category::Local => "Local node".to_string(),
            Category::Subnet => "Subnet".to_string(),
        }
    }

    /// The ordered category ring for left/right navigation.
    pub const ALL: [Category; 3] = [Category::Project, Category::Local, Category::Subnet];
}

/// One discovered endpoint row (gathered in `data.rs`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointRow {
    pub id: String,
    /// Grouping label within the category (e.g. "local", "subnet:node",
    /// project name) — rows render grouped then alpha-sorted.
    pub group: String,
    pub node: String,
    pub status: EpStatus,
    /// `true` if this endpoint lives on THIS node (gates Resume-from-history,
    /// which is offline+LOCAL only).
    pub is_local: bool,
    /// The right-pane description: harness `adapter:profile`.
    pub adapter_profile: String,
    /// Project history newest→oldest (best-effort; empty if none).
    pub project_history: Vec<String>,
    /// The `spt endpoint description` blurb (empty if none).
    pub description: String,
    /// Prior sessions for Resume-from-history (only populated for offline+local).
    pub resume_rows: Vec<ResumeRow>,
    /// The node currently controlling this endpoint's session (its broker-owned
    /// `driven_by`), if any — drives the W2.5 status-conditional confirm options
    /// (REQ-RCVIEW-1 / REQ-KICK-1): a CONTROLLED endpoint offers View + Kick, not
    /// a plain Attach. Local rows read it off `info.json` and render it through the
    /// canonical `node_label_display` so the "controlled by" pin shows the NODE NAME
    /// (`LABEL (keyprefix…)`), not raw key-hex (REQ-PICKER-4); subnet rows leave it
    /// `None` (the wire busy-refuse + client guidance still protect a remote one).
    /// Only ever read as `is_some()` (the controlled gate) or rendered verbatim.
    pub driven_by: Option<String>,
    /// Read-only viewers currently attached (broker-owned `viewer_count`), for the
    /// `controlled by <node> (+N viewing)` pin. `0` ⇒ none.
    pub viewer_count: u32,
    /// Broker-PTY-controllability (`info.json.controllable`, REQ-PICKER-1): `Some(true)`
    /// = spt-hosted (broker PTY, attachable); `Some(false)` = harness-hosted LiveAgent
    /// (no broker PTY); `None` = unknown (pre-v0.10.0 record / non-live / adapterless).
    /// Local rows read it off `info.json`; subnet rows leave it `None`.
    pub controllable: Option<bool>,
    /// The endpoint type tag (`info.json.state`, e.g. `live_agent`/`gateway`) — the
    /// type-gate for the amber "harness-only" display state (amber is LiveAgent-only).
    pub endpoint_type: String,
    /// `true` when this perch's on-disk status is `STATUS_UNBOUND` (a live broker
    /// session, not yet bound — REQ-ENDPOINT-UNBOUND-ATTACH). Drives the hollow
    /// `Unbound`/`UnboundControlled` display. Local rows read it off `info.json`;
    /// subnet/remote rows leave it `false` (a remote node's pre-bind state isn't
    /// observable here — they render plain, the existing controllability limit).
    pub is_unbound: bool,
}

impl EndpointRow {
    /// The four-state DISPLAY status (REQ-PICKER-1), type-gated so amber
    /// "harness-only" is a **LiveAgent-only** state: a gateway/shell — never a
    /// PTY-attachable agent — renders green when online regardless of
    /// controllability. Precedence: offline → gray; a controlled endpoint → blue;
    /// a non-LiveAgent → green; a LiveAgent that is not controllable (`Some(false)`
    /// or a legacy `None`, which self-corrects on the next bind) → amber; else
    /// (a controllable LiveAgent) → green.
    // [impl->REQ-PICKER-1]
    pub fn display_status(&self) -> EpDisplay {
        if self.status == EpStatus::Offline {
            return EpDisplay::Offline;
        }
        // UNBOUND (REQ-ENDPOINT-UNBOUND-ATTACH): a live session, not yet bound —
        // hollow, attachable. Checked before the type-gate/amber logic (an unbound
        // perch is pre-bind, so controllability is not yet meaningful). A node
        // driving it → the hollow-controlled variant, paralleling Controlled.
        if self.is_unbound {
            return if self.driven_by.is_some() {
                EpDisplay::UnboundControlled
            } else {
                EpDisplay::Unbound
            };
        }
        if self.driven_by.is_some() {
            return EpDisplay::Controlled;
        }
        // amber is LiveAgent-only — a gateway/shell online is plain green.
        if self.endpoint_type != "live_agent" {
            return EpDisplay::Online;
        }
        match self.controllable {
            Some(true) => EpDisplay::Online,
            // Some(false) = harness-hosted; None = legacy default (self-corrects
            // next bind) — both render amber for a LiveAgent.
            _ => EpDisplay::HarnessOnly,
        }
    }
}

/// Which screen the picker is on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
    /// Layer 1 — "Create new | Pick existing".
    Kind,
    /// Create-new L2 — pick an adapter:profile from the tree.
    CreateAdapter,
    /// Create-new L3 — type the endpoint id.
    CreateId,
    /// Create-new L4 — pick the home subnet (MULTI-subnet nodes only; inserted
    /// between CreateId and the terminal create outcome). REQ-RUN-PICKER-HOME.
    CreateHome,
    /// Pick-existing L2 — category + grouped endpoint list + description pane.
    PickExisting,
    /// Pick-existing L3 — confirm + status-dependent options.
    Confirm,
    /// Resume-from-history sub-screen — pick a prior session from the ledger.
    Resume,
}

/// Layer-1 kind choices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    CreateNew,
    PickExisting,
}

/// A confirm-layer option. Resume/Fork/Instantiate/ChangeAdapter availability is
/// status-dependent (see [`PickerModel::confirm_options`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmOption {
    Attach,
    Start,
    View,
    /// Online + CONTROLLED only — kick the current controller and take control
    /// (Take intent; a loud notice to the displaced operator — REQ-KICK-1).
    Kick,
    /// Offline + LOCAL only — enumerate the session ledger.
    Resume,
    /// Offline only — re-enter create-new to change the harness adapter.
    ChangeAdapter,
    /// Remote only — re-enter create-new to bring a copy up here.
    Instantiate,
    /// Always available — cross-subnet clone to a new id.
    Fork,
    /// The `s` keybind — build/update the `spt-<id>` shortcut.
    Shortcut,
}

/// The terminal outcome of the picker — the single action enum every screen
/// converges on. `run()` dispatches each variant through the EXISTING bringup
/// fns (`cmd_endpoint_run` / `cmd_fork` / the rc pump) — no second bringup path
/// (the gate invariant).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
    /// Bring up (or resume) an endpoint then start/attach/view — routes through
    /// `cmd_endpoint_run` (the one bringup core).
    Run {
        adapter: String,
        id: String,
        resume: Option<String>,
        /// The project cwd to land the harness in. `Some` only on a Resume
        /// outcome (the selected ledger row's recorded cwd, REQ-SESSION-RESUME-TEMPLATE);
        /// `None` for fresh/headless bringups → `cmd_endpoint_run` resolves it.
        cwd: Option<String>,
        /// The picker-collected home subnet (REQ-RUN-PICKER-HOME). `Some` only on
        /// a multi-subnet create-new flow (the CreateHome pick); `None` otherwise
        /// → `cmd_endpoint_run` resolves home (sole-auto / existing-perch immutable).
        subnet: Option<String>,
        mode: RunMode,
    },
    /// Attach to an ALREADY-LIVE endpoint's PTY — routes through the rc pump
    /// (`rc::run_attach`), not a re-bringup. `intent` carries the
    /// status-conditional choice (Control / Viewer / Take), so View / Attach /
    /// Kick all ride the SAME dispatch (single-bringup-path — REQ-KICK-1).
    Attach {
        id: String,
        intent: spt_net::net::attach::AttachIntent,
    },
    /// Write/update a `spt-<id>` shortcut, then stay (re-enter the loop).
    Shortcut {
        adapter: String,
        id: String,
        resume: Option<String>,
        mode: RunMode,
    },
    /// User backed all the way out.
    Cancelled,
}

/// The terminal action a Run baked: start (return), attach (default), or view
/// (read-only). Maps 1:1 onto `cmd_endpoint_run`'s start/attach/view flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
    Start,
    Attach,
    View,
}

/// The whole picker state. One struct, mutated by the transition methods; the
/// view renders it; `run()` drives keys into it.
pub struct PickerModel {
    pub screen: Screen,
    /// The cwd project name (the Project category label).
    pub project_name: String,

    // Layer 1.
    pub kind_cursor: usize,

    // Create-new.
    pub adapter_rows: Vec<AdapterOption>,
    pub adapter_cursor: usize,
    pub id_buffer: String,
    /// Set when the id buffer fails the charset check (shown inline).
    pub id_error: Option<String>,

    // Pick-existing.
    pub category: Category,
    /// All gathered endpoints (every category); the view filters by category.
    pub endpoints: Vec<EndpointRow>,
    pub item_cursor: usize,
    /// The `/` type-to-filter query.
    pub query: String,
    pub filtering: bool,

    // Confirm.
    pub confirm_cursor: usize,

    // Resume sub-screen.
    pub resume_cursor: usize,

    // Create-new home-subnet layer (REQ-RUN-PICKER-HOME). MRU-ordered member
    // subnets; the layer is shown only when there are >= 2.
    pub home_subnets: Vec<String>,
    pub home_cursor: usize,

    /// A transient one-line status (e.g. the shortcut-written confirmation),
    /// rendered in the bottom row until the next action clears it.
    pub flash: Option<String>,
}

impl PickerModel {
    /// A fresh model on the kind screen. `prefill_adapter`/`prefill_id` come from
    /// a lone `--adapter`/`--id` on a bare-ish `spt endpoint run`.
    pub fn new(
        project_name: String,
        adapter_rows: Vec<AdapterOption>,
        endpoints: Vec<EndpointRow>,
        prefill_adapter: Option<String>,
        prefill_id: Option<String>,
        home_subnets: Vec<String>,
    ) -> Self {
        // A lone --adapter pre-selects its row; a lone --id pre-fills the id and
        // jumps straight into create-new.
        let adapter_cursor = prefill_adapter
            .as_deref()
            .and_then(|a| adapter_rows.iter().position(|r| r.address() == a || r.adapter == a))
            .unwrap_or(0);
        // W4 UX: a bare picker OPENS on Pick-existing (the common case — most runs
        // re-attach an existing endpoint); `n` jumps to Create-new and Esc backs to
        // the Layer-1 Kind menu (now a jump-target, not the entry). A prefill still
        // jumps straight into create-new.
        // [impl->REQ-RUN-EMPTY-CREATE] a TOTALLY-empty scope (nothing attachable —
        // no local perch AND no subnet row) skips the blank Pick-existing list and
        // opens straight on the creation flow. A node with subnet-but-no-local rows
        // still has something to pick, so it stays on Pick-existing.
        let screen = if prefill_id.is_some() || prefill_adapter.is_some() || endpoints.is_empty() {
            Screen::CreateAdapter
        } else {
            Screen::PickExisting
        };
        Self {
            screen,
            project_name,
            kind_cursor: 0,
            adapter_rows,
            adapter_cursor,
            id_buffer: prefill_id.unwrap_or_default(),
            id_error: None,
            category: Category::Project,
            endpoints,
            item_cursor: 0,
            query: String::new(),
            filtering: false,
            confirm_cursor: 0,
            resume_cursor: 0,
            home_subnets,
            home_cursor: 0,
            flash: None,
        }
    }

    // ── Kind layer ────────────────────────────────────────────────────────
    pub fn kind_at_cursor(&self) -> Kind {
        if self.kind_cursor == 0 {
            Kind::CreateNew
        } else {
            Kind::PickExisting
        }
    }

    /// Enter from the kind layer into the chosen branch.
    pub fn enter_kind(&mut self) {
        match self.kind_at_cursor() {
            Kind::CreateNew => self.screen = Screen::CreateAdapter,
            Kind::PickExisting => {
                self.screen = Screen::PickExisting;
                self.item_cursor = 0;
            }
        }
    }

    // ── Generic cursor windowing ─────────────────────────────────────────
    /// Move a cursor within `[0, len)`, clamping at the ends (no wrap — matches
    /// the searchMultiselect windowed cursor; wrap would disorient the two-pane
    /// description sync).
    fn step(cursor: usize, len: usize, delta: isize) -> usize {
        if len == 0 {
            return 0;
        }
        let max = len - 1;
        let next = cursor as isize + delta;
        next.clamp(0, max as isize) as usize
    }

    // ── Create-new: adapter tree ─────────────────────────────────────────
    pub fn move_adapter(&mut self, delta: isize) {
        self.adapter_cursor = Self::step(self.adapter_cursor, self.adapter_rows.len(), delta);
    }

    pub fn selected_adapter(&self) -> Option<&AdapterOption> {
        self.adapter_rows.get(self.adapter_cursor)
    }

    /// Advance from adapter selection into id entry.
    pub fn enter_adapter(&mut self) {
        if self.selected_adapter().is_some() {
            self.screen = Screen::CreateId;
        }
    }

    // ── Create-new: id entry ─────────────────────────────────────────────
    /// Append a typed char to the id, re-validating the charset live.
    pub fn id_push(&mut self, c: char) {
        self.id_buffer.push(c);
        self.revalidate_id();
    }

    pub fn id_backspace(&mut self) {
        self.id_buffer.pop();
        self.revalidate_id();
    }

    fn revalidate_id(&mut self) {
        self.id_error = match spt_proto::id::validate_endpoint_id(&self.id_buffer) {
            Ok(()) => None,
            Err(e) => Some(e.to_string()),
        };
    }

    /// The create-new outcome (bare adapter address + typed id, fresh session,
    /// attach). `None` if the id is empty/invalid or no adapter is selected.
    pub fn create_outcome(&self) -> Option<Outcome> {
        if self.id_buffer.is_empty() || self.id_error.is_some() {
            return None;
        }
        let adapter = self.selected_adapter()?.address();
        Some(Outcome::Run {
            adapter,
            id: self.id_buffer.clone(),
            resume: None,
            cwd: None,
            subnet: self.selected_home(),
            mode: RunMode::Attach,
        })
    }

    // ── Create-new: home-subnet layer (multi-subnet only) ─────────────────
    /// CreateId Enter: with a valid id, advance to the home-subnet layer on a
    /// MULTI-subnet node (>= 2 member subnets), else yield the create-new outcome
    /// directly (single-subnet / unpaired — `assign_home` auto-homes the sole
    /// subnet so `subnet=None` is correct). Create-new has no Confirm screen — it
    /// terminates by yielding the Run outcome; CreateHome inserts before that.
    /// `None` (no yield) means "stay on a screen" (invalid id, or moved to CreateHome).
    // [impl->REQ-RUN-PICKER-HOME]
    pub fn enter_id(&mut self) -> Option<Outcome> {
        if self.id_buffer.is_empty() || self.id_error.is_some() {
            return None;
        }
        if self.home_subnets.len() >= 2 {
            self.screen = Screen::CreateHome;
            self.home_cursor = 0;
            return None;
        }
        self.create_outcome()
    }

    /// The picked home subnet — `Some` ONLY when the layer was actually shown
    /// (>= 2 member subnets), so a single-subnet / unpaired node (layer skipped)
    /// yields `None` and `cmd_endpoint_run` auto-homes. REQ-RUN-PICKER-HOME.
    // [impl->REQ-RUN-PICKER-HOME]
    pub fn selected_home(&self) -> Option<String> {
        if self.home_subnets.len() >= 2 {
            self.home_subnets.get(self.home_cursor).cloned()
        } else {
            None
        }
    }

    pub fn move_home(&mut self, delta: isize) {
        self.home_cursor = Self::step(self.home_cursor, self.home_subnets.len(), delta);
    }

    // ── Pick-existing: category + list ───────────────────────────────────
    /// Cycle the category ring left (-1) or right (+1); resets the item cursor.
    pub fn move_category(&mut self, delta: isize) {
        let cur = Category::ALL.iter().position(|c| *c == self.category).unwrap_or(0);
        let len = Category::ALL.len() as isize;
        let next = ((cur as isize + delta) % len + len) % len; // category wraps
        self.category = Category::ALL[next as usize];
        self.item_cursor = 0;
    }

    /// Indices into `endpoints` visible in the current category, after the
    /// type-to-filter, ordered grouped-then-alpha (group asc, then id asc) with
    /// the fuzzy filter applied. The single source the view and the cursor share.
    // [impl->REQ-RUN-PICKER]
    pub fn visible_indices(&self) -> Vec<usize> {
        let want_local = self.category == Category::Local;
        // Project = local endpoints whose history names the cwd project; Local =
        // all local perches; Subnet = remote rows. (data.rs sets is_local +
        // group; the model just partitions.)
        let mut idx: Vec<usize> = self
            .endpoints
            .iter()
            .enumerate()
            .filter(|(_, e)| match self.category {
                Category::Local => e.is_local,
                Category::Subnet => !e.is_local,
                Category::Project => {
                    e.is_local && e.project_history.iter().any(|p| p == &self.project_name)
                }
            })
            .map(|(i, _)| i)
            .collect();
        let _ = want_local;
        // Grouped then alpha.
        idx.sort_by(|&a, &b| {
            let ea = &self.endpoints[a];
            let eb = &self.endpoints[b];
            ea.group.cmp(&eb.group).then(ea.id.cmp(&eb.id))
        });
        if self.query.is_empty() {
            return idx;
        }
        // Fuzzy-filter by id, preserving match-score order (nucleo).
        let mut matcher = Matcher::new(Config::DEFAULT);
        let pattern = Pattern::parse(&self.query, CaseMatching::Ignore, Normalization::Smart);
        let names: Vec<String> = idx.iter().map(|&i| self.endpoints[i].id.clone()).collect();
        let scored = pattern.match_list(names.iter().map(|s| s.as_str()), &mut matcher);
        // map matched names back to their endpoint indices, score order
        scored
            .into_iter()
            .filter_map(|(name, _)| {
                idx.iter().copied().find(|&i| self.endpoints[i].id == name)
            })
            .collect()
    }

    /// The endpoint under the item cursor (within the current visible set).
    pub fn selected_endpoint(&self) -> Option<&EndpointRow> {
        let vis = self.visible_indices();
        vis.get(self.item_cursor).map(|&i| &self.endpoints[i])
    }

    pub fn move_item(&mut self, delta: isize) {
        let len = self.visible_indices().len();
        self.item_cursor = Self::step(self.item_cursor, len, delta);
    }

    // ── Filter ───────────────────────────────────────────────────────────
    pub fn start_filter(&mut self) {
        self.filtering = true;
    }
    pub fn filter_push(&mut self, c: char) {
        self.query.push(c);
        self.item_cursor = 0;
    }
    pub fn filter_backspace(&mut self) {
        self.query.pop();
        self.item_cursor = 0;
    }
    pub fn end_filter(&mut self) {
        self.filtering = false;
    }

    /// Enter the confirm layer for the selected endpoint.
    pub fn enter_pick(&mut self) {
        if self.selected_endpoint().is_some() {
            self.screen = Screen::Confirm;
            self.confirm_cursor = 0;
        }
    }

    // ── Confirm layer ────────────────────────────────────────────────────
    /// The status-dependent option set for the selected endpoint (picker spec /
    /// doyle Q1/Q3). Order is stable so the cursor + the view agree.
    // [impl->REQ-RUN-PICKER]
    pub fn confirm_options(&self) -> Vec<ConfirmOption> {
        let Some(ep) = self.selected_endpoint() else {
            return Vec::new();
        };
        let offline = ep.status == EpStatus::Offline;
        // W2.5 status-conditional (REQ-RCVIEW-1 / REQ-KICK-1): an ONLINE endpoint a
        // DIFFERENT node already controls offers View + Kick ONLY — NO plain Attach
        // (it would hit the busy-refuse / risk a silent steal). A free / offline
        // endpoint keeps the full set (Attach = Control).
        let controlled = !offline && ep.driven_by.is_some();
        let mut opts = if controlled {
            vec![ConfirmOption::View, ConfirmOption::Kick]
        } else if offline {
            // Offline: nothing to attach to yet — bring it up ("Start now").
            vec![ConfirmOption::Start, ConfirmOption::View]
        } else {
            // Online + free: the PTY is live → Attach. NO "Start now" — it is
            // already running (an online endpoint must offer Attach, never Start).
            // [impl->REQ-PICKER-ONLINE-ACTION]
            vec![ConfirmOption::Attach, ConfirmOption::View]
        };
        // Resume-from-history: offline + LOCAL only, and only if the ledger has
        // rows (doyle Q3). A remote offline endpoint gets Instantiate instead.
        if offline && ep.is_local && !ep.resume_rows.is_empty() {
            opts.push(ConfirmOption::Resume);
        }
        // Change-harness-adapter: offline only.
        if offline {
            opts.push(ConfirmOption::ChangeAdapter);
        }
        // Instantiate-locally: a remote endpoint copied up here.
        if !ep.is_local {
            opts.push(ConfirmOption::Instantiate);
        }
        // Fork: always available.
        opts.push(ConfirmOption::Fork);
        // The `s` shortcut is always available (a keybind, listed last).
        opts.push(ConfirmOption::Shortcut);
        opts
    }

    pub fn move_confirm(&mut self, delta: isize) {
        let len = self.confirm_options().len();
        self.confirm_cursor = Self::step(self.confirm_cursor, len, delta);
    }

    pub fn selected_confirm(&self) -> Option<ConfirmOption> {
        self.confirm_options().get(self.confirm_cursor).copied()
    }

    /// The terminal [`Outcome`] a confirm option yields IMMEDIATELY (no further
    /// screen). Online endpoints attach via the rc pump; offline endpoints
    /// bring up via `cmd_endpoint_run` with the endpoint's recorded adapter.
    /// `Shortcut` bakes the current selection. The re-entrant options
    /// (Resume / ChangeAdapter / Instantiate / Fork) return `None` — they are
    /// SCREEN transitions, driven by [`enter_resume`](Self::enter_resume) /
    /// [`reenter_create`](Self::reenter_create). Every yielded variant routes
    /// through an existing bringup fn — no second bringup path.
    // [impl->REQ-RUN-PICKER]
    pub fn confirm_terminal(&self, opt: ConfirmOption) -> Option<Outcome> {
        let ep = self.selected_endpoint()?;
        let online = ep.status == EpStatus::Online;
        // A confirm-layer bringup targets an EXISTING perch (offline local) → its
        // home is immutable, so no picker home pick rides here (REQ-RUN-PICKER-HOME:
        // the home layer is a create-new-only concern; resolve_home_and_write_skeleton
        // early-returns for an existing perch).
        let bringup = |mode| Outcome::Run {
            adapter: ep.adapter_profile.clone(),
            id: ep.id.clone(),
            resume: None,
            cwd: None,
            subnet: None,
            mode,
        };
        use spt_net::net::attach::AttachIntent;
        match opt {
            // An online endpoint's PTY is live → attach the rc pump; an offline
            // one is brought up then ATTACHED by default (W4 UX: Start-now lands you
            // in the live session, not detached with no stdout — the `h` keybind is
            // the headless escape, see [`start_headless_outcome`](Self::start_headless_outcome)).
            // Attach = Control intent.
            ConfirmOption::Attach | ConfirmOption::Start => Some(if online {
                Outcome::Attach { id: ep.id.clone(), intent: AttachIntent::Control }
            } else {
                bringup(RunMode::Attach)
            }),
            ConfirmOption::View => Some(if online {
                Outcome::Attach { id: ep.id.clone(), intent: AttachIntent::Viewer }
            } else {
                bringup(RunMode::View)
            }),
            // Kick = Take intent: only offered on an online, controlled endpoint.
            ConfirmOption::Kick => Some(Outcome::Attach {
                id: ep.id.clone(),
                intent: AttachIntent::Take,
            }),
            ConfirmOption::Shortcut => Some(Outcome::Shortcut {
                adapter: ep.adapter_profile.clone(),
                id: ep.id.clone(),
                resume: None,
                mode: RunMode::Attach,
            }),
            // Screen transitions, not immediate outcomes.
            ConfirmOption::Resume
            | ConfirmOption::ChangeAdapter
            | ConfirmOption::Instantiate
            | ConfirmOption::Fork => None,
        }
    }

    /// The HEADLESS bringup (start, no attach) for the selected endpoint — the
    /// `h` escape on the Confirm screen (W4 UX). Only meaningful for an OFFLINE
    /// endpoint (an online one is already up); returns `None` otherwise so the key
    /// is a no-op. Mirrors the default attach bringup but bakes `RunMode::Start` so
    /// the session comes up detached — routes through `cmd_endpoint_run` like every
    /// other bringup (no second path).
    // [impl->REQ-RUN-PICKER]
    pub fn start_headless_outcome(&self) -> Option<Outcome> {
        let ep = self.selected_endpoint()?;
        if ep.status != EpStatus::Offline {
            return None;
        }
        Some(Outcome::Run {
            adapter: ep.adapter_profile.clone(),
            id: ep.id.clone(),
            resume: None,
            cwd: None,
            subnet: None, // existing perch — home immutable (REQ-RUN-PICKER-HOME)
            mode: RunMode::Start,
        })
    }

    // ── Resume sub-screen (offline+local only) ───────────────────────────
    pub fn enter_resume(&mut self) {
        self.screen = Screen::Resume;
        self.resume_cursor = 0;
    }

    pub fn resume_rows(&self) -> &[ResumeRow] {
        self.selected_endpoint().map(|e| e.resume_rows.as_slice()).unwrap_or(&[])
    }

    pub fn move_resume(&mut self, delta: isize) {
        let len = self.resume_rows().len();
        self.resume_cursor = Self::step(self.resume_cursor, len, delta);
    }

    pub fn selected_resume(&self) -> Option<&ResumeRow> {
        self.resume_rows().get(self.resume_cursor)
    }

    /// Resume-from-history outcome: bring the endpoint up against the chosen
    /// prior session id (`cmd_endpoint_run --resume`). `headless` (the `h` keybind,
    /// W4 UX) brings it up detached (`RunMode::Start`); the default ATTACHES
    /// (`RunMode::Attach`) so a resume lands you back in the live session.
    // [impl->REQ-RUN-PICKER]
    pub fn resume_outcome(&self, headless: bool) -> Option<Outcome> {
        let row = self.selected_resume()?;
        let session_id = row.session_id.clone();
        // Thread the row's recorded cwd so the resumed harness lands in the
        // session's project dir — CC resolves a transcript by session_id + cwd
        // (REQ-SESSION-RESUME-TEMPLATE). An old row with no cwd resolves through
        // cmd_endpoint_run's fallback chain (info.cwd → current_dir).
        let cwd = row.cwd.clone();
        let ep = self.selected_endpoint()?;
        Some(Outcome::Run {
            adapter: ep.adapter_profile.clone(),
            id: ep.id.clone(),
            resume: Some(session_id),
            cwd,
            subnet: None, // existing perch — home immutable (REQ-RUN-PICKER-HOME)
            mode: if headless { RunMode::Start } else { RunMode::Attach },
        })
    }

    /// Re-enter the create-new adapter screen, the spec's "L2@create-new"
    /// path shared by Change-harness-adapter / Instantiate-locally (keep the id)
    /// and Fork (fresh id). All three converge on `cmd_endpoint_run` — one
    /// bringup core. `keep_id` carries the existing endpoint's id forward.
    pub fn reenter_create(&mut self, keep_id: bool) {
        let id = if keep_id {
            self.selected_endpoint().map(|e| e.id.clone()).unwrap_or_default()
        } else {
            String::new()
        };
        self.id_buffer = id;
        self.revalidate_id();
        self.adapter_cursor = 0;
        self.screen = Screen::CreateAdapter;
    }

    /// Back out one screen; returns `true` if it backed out of the whole picker
    /// (the caller then yields `Outcome::Cancelled`).
    pub fn back(&mut self) -> bool {
        match self.screen {
            Screen::Kind => true,
            Screen::CreateAdapter => {
                self.screen = Screen::Kind;
                false
            }
            Screen::CreateId => {
                self.screen = Screen::CreateAdapter;
                false
            }
            Screen::CreateHome => {
                self.screen = Screen::CreateId;
                false
            }
            Screen::PickExisting => {
                self.screen = Screen::Kind;
                false
            }
            Screen::Confirm => {
                self.screen = Screen::PickExisting;
                false
            }
            Screen::Resume => {
                self.screen = Screen::Confirm;
                false
            }
        }
    }
}

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

    fn info(name: &str, shipped: &[&str], local: &[&str]) -> AdapterInfo {
        AdapterInfo {
            name: name.to_string(),
            shipped: shipped.iter().map(|s| s.to_string()).collect(),
            local: local.iter().map(|s| s.to_string()).collect(),
        }
    }

    fn ep(id: &str, group: &str, status: EpStatus, is_local: bool) -> EndpointRow {
        EndpointRow {
            id: id.to_string(),
            group: group.to_string(),
            node: "n".to_string(),
            status,
            is_local,
            adapter_profile: "claude-spt".to_string(),
            project_history: vec![],
            description: String::new(),
            resume_rows: vec![],
            driven_by: None,
            viewer_count: 0,
            controllable: None,
            endpoint_type: "live_agent".to_string(),
            is_unbound: false,
        }
    }

    // [unit->REQ-RUN-PICKER] the status→square mapping is the W2 duality (no blue).
    #[test]
    fn status_square_is_online_offline_only() {
        assert_eq!(EpStatus::Online.square(), glyph::ONLINE);
        assert_eq!(EpStatus::Offline.square(), glyph::OFFLINE);
    }

    // [unit->REQ-PICKER-1] the four-state DISPLAY derivation is type-gated: amber
    // "harness-only" is LiveAgent-only; offline→gray, controlled→blue, a non-live
    // online endpoint→green regardless of controllability, a live agent that isn't
    // controllable (Some(false) OR a legacy None)→amber, a controllable live→green.
    #[test]
    fn display_status_four_state_type_gated() {
        let mut e = ep("a", "g", EpStatus::Offline, true);
        assert_eq!(e.display_status(), EpDisplay::Offline, "offline → gray");

        // Online harness-hosted live agent → amber.
        e.status = EpStatus::Online;
        e.endpoint_type = "live_agent".into();
        e.controllable = Some(false);
        assert_eq!(e.display_status(), EpDisplay::HarnessOnly);
        // A legacy live agent (controllable unknown) is also amber (self-corrects).
        e.controllable = None;
        assert_eq!(e.display_status(), EpDisplay::HarnessOnly, "legacy None → amber");
        // A controllable (spt-hosted) live agent → green.
        e.controllable = Some(true);
        assert_eq!(e.display_status(), EpDisplay::Online);

        // A controlled endpoint → blue, outranking harness-only.
        e.controllable = Some(false);
        e.driven_by = Some("cafe".into());
        assert_eq!(e.display_status(), EpDisplay::Controlled, "driven_by → blue");

        // A NON-live online endpoint (gateway) is never amber — green, even with
        // controllable == Some(false)/None.
        let mut gw = ep("gw", "g", EpStatus::Online, true);
        gw.endpoint_type = "gateway".into();
        gw.controllable = None;
        assert_eq!(gw.display_status(), EpDisplay::Online, "gateway never amber");
        gw.controllable = Some(false);
        assert_eq!(gw.display_status(), EpDisplay::Online);

        // The labels match the spec.
        assert_eq!(EpDisplay::Offline.label(), "OFFLINE");
        assert_eq!(EpDisplay::Online.label(), "ONLINE");
        assert_eq!(EpDisplay::HarnessOnly.label(), "ONLINE - HARNESS ONLY");
        assert_eq!(EpDisplay::Controlled.label(), "ONLINE + CONTROLLED");
    }

    // [unit->REQ-ENDPOINT-UNBOUND-ATTACH] the UNBOUND display LOGIC: a live
    // pre-bind session reads status ONLINE (see data::row_status — an unbound perch
    // counts as online at the raw level) + is_unbound → hollow Unbound; + driven_by
    // → UnboundControlled. NOTE the real (status, is_unbound) pairing is exercised
    // by `data::tests::row_status_drives_unbound_display` — here the inputs are set
    // directly to test the display branch in isolation.
    #[test]
    fn display_status_unbound_is_hollow_and_attachable() {
        // Live unbound (status online, is_unbound) → Unbound, regardless of the
        // controllability that would otherwise drive amber.
        let mut e = ep("u", "g", EpStatus::Online, true);
        e.is_unbound = true;
        e.controllable = Some(false); // would be HarnessOnly if not unbound
        assert_eq!(e.display_status(), EpDisplay::Unbound, "unbound → hollow, not amber");

        // A node driving the unbound endpoint → the hollow-controlled variant.
        e.driven_by = Some("cafe".into());
        assert_eq!(
            e.display_status(),
            EpDisplay::UnboundControlled,
            "unbound + driven_by → hollow-controlled"
        );

        // A REAL offline perch (is_unbound=false — B2 clears the unbound status when
        // it stamps a real offline, so status==Offline && is_unbound never co-occur)
        // → Offline. (The old test asserted Offline for status=Offline+is_unbound,
        // which encoded an impossible combo AND the masking bug — removed.)
        let dead = ep("d", "g", EpStatus::Offline, true); // is_unbound defaults false
        assert_eq!(dead.display_status(), EpDisplay::Offline, "a real offline → gray");

        // A plain online row with the unbound flag clear is unaffected.
        let plain = ep("p", "g", EpStatus::Online, true);
        assert_ne!(plain.display_status(), EpDisplay::Unbound);

        assert_eq!(EpDisplay::Unbound.label(), "UNBOUND");
        assert_eq!(EpDisplay::UnboundControlled.label(), "UNBOUND + CONTROLLED");
    }

    // [unit->REQ-RUN-PICKER] the adapter tree nests profiles under each adapter,
    // bare-root first then shipped then local, each composite-addressable.
    #[test]
    fn adapter_tree_nests_profiles() {
        let rows = build_adapter_tree(&[
            info("claude-spt", &["fast"], &["mine"]),
            info("ccs", &[], &[]),
        ]);
        let addrs: Vec<String> = rows.iter().map(|r| r.address()).collect();
        assert_eq!(
            addrs,
            vec!["claude-spt", "claude-spt:fast", "claude-spt:mine", "ccs"]
        );
        assert!(!rows[0].is_leaf, "bare-adapter root is not a leaf");
        assert!(rows[1].is_leaf, "a profile is a leaf");
    }

    // [unit->REQ-SESSION-RESUME-TEMPLATE] resume titles render
    // `<project|trigger> - <local_time> (…id5)` (design D): ` - ` separator, a
    // local-time stamp (`HH:MM<AM|PM> <offset> MM-DD-YYYY`), the trailing `…id5`.
    // The time portion is machine-LOCAL, so assert the SHAPE, not an exact clock.
    #[test]
    fn resume_title_render() {
        let with_proj = ResumeRow {
            session_id: "abcdef12345".to_string(),
            ts: "2026-06-14T10:00:00Z".to_string(),
            trigger: "clear".to_string(),
            project: "spt-core".to_string(),
            cwd: None,
        };
        let t = with_proj.title();
        assert!(t.starts_with("spt-core - "), "project head + ` - ` sep: {t}");
        assert!(t.ends_with(" (…12345)"), "trailing …id5: {t}");
        assert!(
            t.contains("AM") || t.contains("PM"),
            "12-hour AM/PM local-time stamp: {t}"
        );
        assert!(!t.contains(" @ "), "old `@ <rfc3339>` form is gone: {t}");

        // Parse failure degrades to the raw ts (never panics). Short id (<5) takes
        // the whole thing; no project falls back to the trigger.
        let no_proj = ResumeRow {
            session_id: "xy".to_string(),
            ts: "T".to_string(),
            trigger: "boot".to_string(),
            project: String::new(),
            cwd: None,
        };
        assert_eq!(no_proj.title(), "boot - T (…xy)");
    }

    // [unit->REQ-RUN-PICKER] a bare picker OPENS on Pick-existing (W4 UX); the Kind
    // layer is a jump-target (reached via Esc/back) that still routes into the right
    // branch.
    #[test]
    fn kind_routes() {
        // A NON-empty scope opens on Pick-existing (REQ-RUN-EMPTY-CREATE: only a
        // totally-empty scope routes to create — see empty_scope_opens_create).
        let mut m = PickerModel::new(
            "p".into(),
            vec![],
            vec![ep("a", "g", EpStatus::Online, true)],
            None,
            None,
            vec![],
        );
        assert_eq!(m.screen, Screen::PickExisting, "bare picker opens on Pick-existing");
        m.screen = Screen::Kind; // jump back to the Layer-1 menu
        m.kind_cursor = 0;
        m.enter_kind();
        assert_eq!(m.screen, Screen::CreateAdapter);
        m.back();
        m.kind_cursor = 1;
        m.enter_kind();
        assert_eq!(m.screen, Screen::PickExisting);
    }

    // [unit->REQ-RUN-EMPTY-CREATE] a TOTALLY-empty scope (no local AND no subnet
    // endpoint) opens straight on the creation flow, not a blank Pick-existing
    // list; a subnet-only scope (nothing local, but something to pick) stays on
    // Pick-existing.
    #[test]
    fn empty_scope_opens_create() {
        let empty = PickerModel::new("p".into(), vec![], vec![], None, None, vec![]);
        assert_eq!(
            empty.screen,
            Screen::CreateAdapter,
            "an empty scope routes to creation"
        );

        // A remote (subnet) endpoint, nothing local → still has something to pick.
        let subnet_only = PickerModel::new(
            "p".into(),
            vec![],
            vec![ep("r", "g", EpStatus::Online, false)],
            None,
            None,
            vec![],
        );
        assert_eq!(
            subnet_only.screen,
            Screen::PickExisting,
            "subnet-but-no-local still picks"
        );
    }

    // [unit->REQ-RUN-PICKER] cursor windowing clamps (no wrap) at both ends.
    #[test]
    fn cursor_clamps() {
        assert_eq!(PickerModel::step(0, 3, -1), 0);
        assert_eq!(PickerModel::step(2, 3, 1), 2);
        assert_eq!(PickerModel::step(1, 3, 1), 2);
        assert_eq!(PickerModel::step(0, 0, 1), 0);
    }

    // [unit->REQ-RUN-PICKER] category nav wraps the ring + resets the item cursor.
    #[test]
    fn category_ring_wraps() {
        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None, vec![]);
        assert_eq!(m.category, Category::Project);
        m.move_category(-1);
        assert_eq!(m.category, Category::Subnet, "left from first wraps to last");
        m.move_category(1);
        assert_eq!(m.category, Category::Project);
    }

    // [unit->REQ-RUN-PICKER] visible_indices partitions by category, grouped+alpha.
    #[test]
    fn visible_partitions_and_sorts() {
        let eps = vec![
            ep("zeta", "g1", EpStatus::Online, true),
            ep("alpha", "g1", EpStatus::Offline, true),
            ep("remote", "subnet:n", EpStatus::Online, false),
        ];
        let mut m = PickerModel::new("p".into(), vec![], eps, None, None, vec![]);
        m.category = Category::Local;
        let vis = m.visible_indices();
        let ids: Vec<&str> = vis.iter().map(|&i| m.endpoints[i].id.as_str()).collect();
        assert_eq!(ids, vec!["alpha", "zeta"], "local, alpha within group");
        m.category = Category::Subnet;
        let ids: Vec<&str> = m
            .visible_indices()
            .iter()
            .map(|&i| m.endpoints[i].id.as_str())
            .collect();
        assert_eq!(ids, vec!["remote"]);
    }

    // [unit->REQ-RUN-PICKER] type-to-filter narrows the visible set.
    #[test]
    fn filter_narrows() {
        let eps = vec![
            ep("doyle", "g", EpStatus::Online, true),
            ep("todlando", "g", EpStatus::Online, true),
        ];
        let mut m = PickerModel::new("p".into(), vec![], eps, None, None, vec![]);
        m.category = Category::Local;
        assert_eq!(m.visible_indices().len(), 2);
        m.start_filter();
        for c in "doy".chars() {
            m.filter_push(c);
        }
        let vis = m.visible_indices();
        assert_eq!(vis.len(), 1);
        assert_eq!(m.endpoints[vis[0]].id, "doyle");
    }

    // [unit->REQ-RUN-PICKER] confirm options are status-dependent: offline+local
    // with ledger rows gets Resume; online does not; remote gets Instantiate.
    #[test]
    fn confirm_options_status_dependent() {
        let mut offline_local = ep("a", "g", EpStatus::Offline, true);
        offline_local.resume_rows = vec![ResumeRow {
            session_id: "s".into(),
            ts: "t".into(),
            trigger: "boot".into(),
            project: String::new(),
            cwd: None,
        }];
        let mut m = PickerModel::new("p".into(), vec![], vec![offline_local], None, None, vec![]);