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

//! 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 — offline.
    Offline,
    /// Green — online + broker-PTY-controllable (spt-hosted), free.
    Online,
    /// Amber — online LiveAgent that is NOT broker-PTY-controllable (harness-hosted,
    /// no broker PTY seat).
    HarnessOnly,
    /// Blue — online + a node currently controls it (`driven_by`).
    Controlled,
}

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",
        }
    }
}

/// 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,
}

impl ResumeRow {
    /// The option title: `<project|trigger> @ <ts> (…<id5>)` (picker spec).
    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.ts)
    }
}

/// 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`; subnet rows leave it
    /// `None` (the wire busy-refuse + client guidance still protect a remote one).
    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,
}

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;
        }
        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,
    /// 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>,
        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,

    /// 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>,
    ) -> 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);
        let screen = if prefill_id.is_some() || prefill_adapter.is_some() {
            Screen::CreateAdapter
        } else {
            Screen::Kind
        };
        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,
            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,
            mode: RunMode::Attach,
        })
    }

    // ── 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;
        let bringup = |mode| Outcome::Run {
            adapter: ep.adapter_profile.clone(),
            id: ep.id.clone(),
            resume: None,
            mode,
        };
        use spt_net::net::attach::AttachIntent;
        match opt {
            // An online endpoint's PTY is live → attach the rc pump; an offline
            // one must be brought up first. Attach = Control intent.
            ConfirmOption::Attach | ConfirmOption::Start => Some(if online {
                Outcome::Attach { id: ep.id.clone(), intent: AttachIntent::Control }
            } else {
                bringup(match opt {
                    ConfirmOption::Start => RunMode::Start,
                    _ => 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,
        }
    }

    // ── 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`).
    pub fn resume_outcome(&self) -> Option<Outcome> {
        let session_id = self.selected_resume()?.session_id.clone();
        let ep = self.selected_endpoint()?;
        Some(Outcome::Run {
            adapter: ep.adapter_profile.clone(),
            id: ep.id.clone(),
            resume: Some(session_id),
            mode: 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::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(),
        }
    }

    // [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-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-RUN-PICKER] resume titles render `<project|trigger> @ ts (…id5)`.
    #[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(),
        };
        assert_eq!(with_proj.title(), "spt-core @ 2026-06-14T10:00:00Z (…12345)");
        let no_proj = ResumeRow {
            session_id: "xy".to_string(),
            ts: "T".to_string(),
            trigger: "boot".to_string(),
            project: String::new(),
        };
        // short id (<5) takes the whole thing; no project falls back to trigger.
        assert_eq!(no_proj.title(), "boot @ T (…xy)");
    }

    // [unit->REQ-RUN-PICKER] kind layer routes into the right branch.
    #[test]
    fn kind_routes() {
        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None);
        assert_eq!(m.screen, Screen::Kind);
        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-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);
        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);
        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);
        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(),
        }];
        let mut m = PickerModel::new("p".into(), vec![], vec![offline_local], None, None);
        m.category = Category::Local;
        m.enter_pick();
        let opts = m.confirm_options();
        assert!(opts.contains(&ConfirmOption::Resume), "offline+local+ledger ⇒ Resume");
        assert!(opts.contains(&ConfirmOption::ChangeAdapter), "offline ⇒ ChangeAdapter");
        assert!(!opts.contains(&ConfirmOption::Instantiate), "local ⇒ no Instantiate");
        assert!(opts.contains(&ConfirmOption::Fork) && opts.contains(&ConfirmOption::Shortcut));
        // Offline ⇒ Start (bring up), never a bare Attach (nothing live yet).
        // [unit->REQ-PICKER-ONLINE-ACTION]
        assert!(opts.contains(&ConfirmOption::Start), "offline ⇒ Start");
        assert!(!opts.contains(&ConfirmOption::Attach), "offline ⇒ no bare Attach");

        // online local: no Resume, no ChangeAdapter.
        let online = ep("b", "g", EpStatus::Online, true);
        let mut m2 = PickerModel::new("p".into(), vec![], vec![online], None, None);
        m2.category = Category::Local;
        m2.enter_pick();
        let opts2 = m2.confirm_options();
        assert!(!opts2.contains(&ConfirmOption::Resume));
        assert!(!opts2.contains(&ConfirmOption::ChangeAdapter));
        // REQ-PICKER-ONLINE-ACTION: an already-online endpoint offers Attach, NEVER
        // "Start now" (it is already running).
        // [unit->REQ-PICKER-ONLINE-ACTION]
        assert!(opts2.contains(&ConfirmOption::Attach), "online ⇒ Attach");
        assert!(
            !opts2.contains(&ConfirmOption::Start),
            "online ⇒ NO Start now (already running)"
        );

        // remote: Instantiate present.
        let remote = ep("c", "subnet:n", EpStatus::Online, false);
        let mut m3 = PickerModel::new("p".into(), vec![], vec![remote], None, None);
        m3.category = Category::Subnet;
        m3.enter_pick();
        assert!(m3.confirm_options().contains(&ConfirmOption::Instantiate));
    }

    // [unit->REQ-RUN-PICKER] create-new outcome bakes adapter+id, fresh, attach;
    // refuses an empty/invalid id.
    #[test]
    fn create_outcome_bakes_run() {
        let rows = build_adapter_tree(&[info("claude-spt", &["fast"], &[])]);
        let mut m = PickerModel::new("p".into(), rows, vec![], None, None);
        m.screen = Screen::CreateAdapter;
        m.adapter_cursor = 1; // claude-spt:fast
        m.enter_adapter();
        assert_eq!(m.screen, Screen::CreateId);
        assert!(m.create_outcome().is_none(), "empty id ⇒ no outcome");
        for c in "doyle".chars() {
            m.id_push(c);
        }
        match m.create_outcome().unwrap() {
            Outcome::Run { adapter, id, resume, mode } => {
                assert_eq!(adapter, "claude-spt:fast");
                assert_eq!(id, "doyle");
                assert_eq!(resume, None);
                assert_eq!(mode, RunMode::Attach);
            }
            other => panic!("expected Run, got {other:?}"),
        }
        // an invalid char sets the error and blocks the outcome.
        m.id_push(' ');
        assert!(m.id_error.is_some());
        assert!(m.create_outcome().is_none());
    }

    // [unit->REQ-RUN-PICKER] back navigation unwinds the screen graph, signalling
    // cancellation only from the kind layer.
    #[test]
    fn back_unwinds() {
        let mut m = PickerModel::new("p".into(), vec![], vec![], None, None);
        m.screen = Screen::Confirm;
        assert!(!m.back());
        assert_eq!(m.screen, Screen::PickExisting);
        assert!(!m.back());
        assert_eq!(m.screen, Screen::Kind);
        assert!(m.back(), "back from kind cancels the picker");
    }

    // [unit->REQ-RUN-PICKER] confirm terminal actions route correctly: an online
    // endpoint attaches via the rc pump; an offline one brings up via Run with
    // its recorded adapter; Shortcut bakes the selection.
    #[test]
    fn confirm_terminal_routes() {
        let online = ep("live", "g", EpStatus::Online, true);
        let mut m = PickerModel::new("p".into(), vec![], vec![online], None, None);
        m.category = Category::Local;
        m.enter_pick();
        use spt_net::net::attach::AttachIntent;
        assert_eq!(
            m.confirm_terminal(ConfirmOption::Attach),
            Some(Outcome::Attach { id: "live".into(), intent: AttachIntent::Control })
        );
        assert_eq!(
            m.confirm_terminal(ConfirmOption::View),
            Some(Outcome::Attach { id: "live".into(), intent: AttachIntent::Viewer })
        );
        match m.confirm_terminal(ConfirmOption::Shortcut).unwrap() {
            Outcome::Shortcut { adapter, id, .. } => {
                assert_eq!((adapter.as_str(), id.as_str()), ("claude-spt", "live"));
            }
            o => panic!("expected Shortcut, got {o:?}"),
        }

        // offline → bringup (Run) with the endpoint's adapter.
        let off = ep("cold", "g", EpStatus::Offline, true);
        let mut m2 = PickerModel::new("p".into(), vec![], vec![off], None, None);
        m2.category = Category::Local;
        m2.enter_pick();
        assert_eq!(
            m2.confirm_terminal(ConfirmOption::Start),
            Some(Outcome::Run {
                adapter: "claude-spt".into(),
                id: "cold".into(),
                resume: None,
                mode: RunMode::Start,
            })
        );
        // the re-entrant options yield no immediate outcome.
        assert_eq!(m2.confirm_terminal(ConfirmOption::Fork), None);
        assert_eq!(m2.confirm_terminal(ConfirmOption::ChangeAdapter), None);
    }

    // [unit->REQ-RUN-PICKER] resume picks a prior session and bakes --resume.
    #[test]
    fn resume_outcome_bakes_session() {
        let mut e = ep("cold", "g", EpStatus::Offline, true);
        e.resume_rows = vec![
            ResumeRow { session_id: "old".into(), ts: "t1".into(), trigger: "boot".into(), project: String::new() },
            ResumeRow { session_id: "new".into(), ts: "t2".into(), trigger: "clear".into(), project: String::new() },
        ];
        let mut m = PickerModel::new("p".into(), vec![], vec![e], None, None);
        m.category = Category::Local;
        m.enter_pick();
        m.enter_resume();
        assert_eq!(m.screen, Screen::Resume);
        m.move_resume(1);
        assert_eq!(m.selected_resume().unwrap().session_id, "new");
        assert_eq!(
            m.resume_outcome(),
            Some(Outcome::Run {
                adapter: "claude-spt".into(),
                id: "cold".into(),
                resume: Some("new".into()),
                mode: RunMode::Attach,
            })
        );
        assert!(!m.back() && m.screen == Screen::Confirm, "back → confirm");
    }

    // [unit->REQ-RUN-PICKER] Change-adapter / Instantiate re-enter create keeping
    // the id; Fork re-enters with a fresh id — all converging on cmd_endpoint_run.
    #[test]
    fn reenter_create_keeps_or_clears_id() {
        let rows = build_adapter_tree(&[info("claude-spt", &[], &[])]);
        let off = ep("cold", "g", EpStatus::Offline, true);
        let mut m = PickerModel::new("p".into(), rows, vec![off], None, None);
        m.category = Category::Local;
        m.enter_pick();
        m.reenter_create(true);
        assert_eq!(m.screen, Screen::CreateAdapter);
        assert_eq!(m.id_buffer, "cold", "change-adapter/instantiate keep the id");
        m.reenter_create(false);
        assert_eq!(m.id_buffer, "", "fork starts a fresh id");
    }

    // [unit->REQ-RUN-PICKER] a lone --id prefills and jumps into create-new.
    #[test]
    fn prefill_jumps_to_create() {
        let rows = build_adapter_tree(&[info("claude-spt", &[], &[])]);
        let m = PickerModel::new("p".into(), rows, vec![], None, Some("doyle".into()));
        assert_eq!(m.screen, Screen::CreateAdapter);
        assert_eq!(m.id_buffer, "doyle");
    }
}


Picker model understood. Items 1 & 2 hinge on `cmd_endpoint_run` semantics + the `node_label_display`/pid-leak sites. Read cli.rs.


Grep

fn cmd_endpoint_run|node_label_display|pid=|binds its perch|harness binds|fn node_label


249:    /// harness binds to exactly it. This is the non-interactive core (the
1258:pub(crate) fn cmd_endpoint_run(
1268:    // id charset (the harness binds to exactly this id via argv).
1321:                "ENDPOINT_RUN:{id} adapter={adapter} pid={:?} session={session_id} \
1322:                 (harness binds its perch on startup)",
1698:/// ([`node_label_display`](spt_net::net::registry::node_label_display)) now that
1706:    use spt_net::net::registry::node_label_display;
1713:                node_label_display(&r.node, r.node_label.as_deref()),
6241:                    eprintln!("SHELL_SPAWNED:{id} owner={owner} pid={pid} status=offline (online at bind)");
6641:                    eprintln!("SHELL_RELINKED:{id} owner={owner} pid={pid} status=offline (online at bind)");


pid=|binds its perch|harness binds|pid=Some|controlled by|controlled_by


crates\spt\src\rc.rs:240:            println!("'{endpoint_id}' is controlled by {node}.");
crates\spt\src\cli.rs:249:    /// harness binds to exactly it. This is the non-interactive core (the
crates\spt\src\cli.rs:1268:    // id charset (the harness binds to exactly this id via argv).
crates\spt\src\cli.rs:1321:                "ENDPOINT_RUN:{id} adapter={adapter} pid={:?} session={session_id} \
crates\spt\src\cli.rs:1322:                 (harness binds its perch on startup)",
crates\spt\src\cli.rs:6241:                    eprintln!("SHELL_SPAWNED:{id} owner={owner} pid={pid} status=offline (online at bind)");
crates\spt\src\cli.rs:6641:                    eprintln!("SHELL_RELINKED:{id} owner={owner} pid={pid} status=offline (online at bind)");
crates\spt\src\picker\model.rs:217:    /// `controlled by <node> (+N viewing)` pin. `0` ⇒ none.
crates\spt\src\picker\view.rs:342:        // The W2.5 controlled-by pin (REQ-RCVIEW-1): `controlled by <node>
crates\spt\src\picker\view.rs:346:                format!("controlled by {node} (+{} viewing)", ep.viewer_count)
crates\spt\src\picker\view.rs:348:            Some(node) => format!("controlled by {node}"),
crates\spt\src\api\reporting.rs:154:                            Ok(pid) => eprintln!("NOTIF_COMMAND:{notif_id} pid={pid}"),