1	//! `spt endpoint run` interactive picker (M12-W2, REQ-RUN-PICKER).
2	//!
3	//! Bare `spt endpoint run` (no `--adapter`/`--id`) enters this in-process
4	//! ratatui picker; the flags-present form stays the REQ-HOST-RUN-1
5	//! non-interactive path, untouched. The picker is a PURE FRONT-END: every
6	//! terminal action converges on the [`model::Outcome`] enum, which [`dispatch`]
7	//! routes through the EXISTING bringup fns ([`crate::cli::cmd_endpoint_run`] /
8	//! the [`crate::rc`] pump) — there is **no second bringup path** (doyle's
9	//! binding gate invariant; same discipline as W1.5's one pump).
10	//!
11	//! Layering: `model` (pure state + transitions, fully unit-tested), `data`
12	//! (read-only gather over existing surfaces), `view` (renders a `&PickerModel`
13	//! to a testable ratatui `Buffer`), `shortcut` (`spt-<id>` generation). This file
14	//! is the thin live key loop — the manual-verify leg (like REQ-PAIR-6's OS
15	//! probe); the model/view/shortcut logic under it is unit-asserted.
16	// [impl->REQ-RUN-PICKER]
17
18	mod data;
19	mod model;
20	mod shortcut;
21	mod view;
22
23	use std::io::{self, Stdout};
24
25	use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
26	use crossterm::terminal::{
27	    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
28	};
29	use ratatui::backend::CrosstermBackend;
30	use ratatui::Terminal;
31
32	use model::{ConfirmOption, Outcome, PickerModel, RunMode, Screen};
33	use shortcut::WriteOutcome;
34
35	/// Entry point for `spt endpoint run` when it is NOT fully flagged. Gathers the
36	/// data, drives the TUI, and dispatches the chosen outcome through the existing
37	/// bringup fns. Returns the process exit code.
38	pub fn run(prefill_adapter: Option<String>, prefill_id: Option<String>) -> i32 {
39	    let project_name = data::project_name();
40	    let adapter_rows = data::adapter_options();
41	    let endpoints = data::gather_endpoints();
42	    let mut model = PickerModel::new(
43	        project_name,
44	        adapter_rows,
45	        endpoints,
46	        prefill_adapter,
47	        prefill_id,
48	    );
49
50	    let mut terminal = match setup_terminal() {
51	        Ok(t) => t,
52	        Err(e) => {
53	            eprintln!("PICKER_TTY_FAIL: {e}");
54	            return 1;
55	        }
56	    };
57	    let outcome = event_loop(&mut terminal, &mut model);
58	    // Restore the terminal BEFORE dispatching — cmd_endpoint_run / the rc pump
59	    // own raw mode themselves for the attach.
60	    restore_terminal(&mut terminal);
61
62	    match outcome {
63	        Ok(outcome) => dispatch(outcome),
64	        Err(e) => {
65	            eprintln!("PICKER_FAIL: {e}");
66	            1
67	        }
68	    }
69	}
70
71	type Term = Terminal<CrosstermBackend<Stdout>>;
72
73	fn setup_terminal() -> io::Result<Term> {
74	    enable_raw_mode()?;
75	    let mut stdout = io::stdout();
76	    crossterm::execute!(stdout, EnterAlternateScreen)?;
77	    Terminal::new(CrosstermBackend::new(stdout))
78	}
79
80	fn restore_terminal(terminal: &mut Term) {
81	    let _ = disable_raw_mode();
82	    let _ = crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen);
83	    let _ = terminal.show_cursor();
84	}
85
86	/// The live key loop. Returns the chosen [`Outcome`]; `Outcome::Shortcut` is
87	/// handled inline (write the file, flash, stay) so it never escapes the loop.
88	fn event_loop(terminal: &mut Term, model: &mut PickerModel) -> io::Result<Outcome> {
89	    loop {
90	        terminal.draw(|f| view::render(model, f))?;
91	        let Event::Key(key) = event::read()? else {
92	            continue;
93	        };
94	        // Windows fires Press AND Release; only act on Press.
95	        if key.kind != KeyEventKind::Press {
96	            continue;
97	        }
98	        // Ctrl-C always cancels.
99	        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
100	            return Ok(Outcome::Cancelled);
101	        }
102	        // Any key clears a stale flash.
103	        model.flash = None;
104
105	        if let Some(outcome) = handle_key(model, key.code) {
106	            match outcome {
107	                Outcome::Shortcut {
108	                    adapter,
109	                    id,
110	                    resume,
111	                    mode,
112	                } => {
113	                    model.flash = Some(write_shortcut_now(&adapter, &id, resume.as_deref(), mode));
114	                }
115	                done => return Ok(done),
116	            }
117	        }
118	    }
119	}
120
121	/// Translate one key into a model mutation and/or a yielded outcome. Pure
122	/// dispatch over the model's transition methods — the model owns the semantics.
123	fn handle_key(model: &mut PickerModel, code: KeyCode) -> Option<Outcome> {
124	    // Esc backs out one screen (out of filter mode first); from the kind layer
125	    // it cancels the whole picker.
126	    if code == KeyCode::Esc {
127	        if model.screen == Screen::PickExisting && model.filtering {
128	            model.end_filter();
129	            return None;
130	        }
131	        return model.back().then_some(Outcome::Cancelled);
132	    }
133
134	    match model.screen {
135	        Screen::Kind => match code {
136	            KeyCode::Up => model.kind_cursor = model.kind_cursor.saturating_sub(1),
137	            KeyCode::Down => model.kind_cursor = (model.kind_cursor + 1).min(1),
138	            KeyCode::Enter => model.enter_kind(),
139	            _ => {}
140	        },
141	        Screen::CreateAdapter => match code {
142	            KeyCode::Up => model.move_adapter(-1),
143	            KeyCode::Down => model.move_adapter(1),
144	            KeyCode::Enter => model.enter_adapter(),
145	            _ => {}
146	        },
147	        Screen::CreateId => match code {
148	            KeyCode::Char(c) => model.id_push(c),
149	            KeyCode::Backspace => model.id_backspace(),
150	            KeyCode::Enter => return model.create_outcome(),
151	            _ => {}
152	        },
153	        Screen::PickExisting => return handle_pick_key(model, code),
154	        Screen::Confirm => return handle_confirm_key(model, code),
155	        Screen::Resume => match code {
156	            KeyCode::Up => model.move_resume(-1),
157	            KeyCode::Down => model.move_resume(1),
158	            KeyCode::Enter => return model.resume_outcome(),
159	            _ => {}
160	        },
161	    }
162	    None
163	}
164
165	fn handle_pick_key(model: &mut PickerModel, code: KeyCode) -> Option<Outcome> {
166	    if model.filtering {
167	        match code {
168	            KeyCode::Char(c) => model.filter_push(c),
169	            KeyCode::Backspace => model.filter_backspace(),
170	            KeyCode::Enter => model.end_filter(),
171	            KeyCode::Up => model.move_item(-1),
172	            KeyCode::Down => model.move_item(1),
173	            _ => {}
174	        }
175	        return None;
176	    }
177	    match code {
178	        KeyCode::Left => model.move_category(-1),
179	        KeyCode::Right => model.move_category(1),
180	        KeyCode::Up => model.move_item(-1),
181	        KeyCode::Down => model.move_item(1),
182	        KeyCode::Char('/') => model.start_filter(),
183	        // 'n' = new endpoint → jump to create-new.
184	        KeyCode::Char('n') => model.reenter_create(false),
185	        KeyCode::Enter => model.enter_pick(),
186	        _ => {}
187	    }
188	    None
189	}
190
191	fn handle_confirm_key(model: &mut PickerModel, code: KeyCode) -> Option<Outcome> {
192	    match code {
193	        KeyCode::Up => model.move_confirm(-1),
194	        KeyCode::Down => model.move_confirm(1),
195	        // The `s` keybind = build/update the spt-<id> shortcut from every
196	        // pre-start options set (picker spec).
197	        KeyCode::Char('s') => return model.confirm_terminal(ConfirmOption::Shortcut),
198	        KeyCode::Enter => {
199	            let opt = model.selected_confirm()?;
200	            return match opt {
201	                // Immediate terminal actions (Attach/Start/View/Kick/Shortcut).
202	                ConfirmOption::Attach
203	                | ConfirmOption::Start
204	                | ConfirmOption::View
205	                | ConfirmOption::Kick
206	                | ConfirmOption::Shortcut => model.confirm_terminal(opt),
207	                // Screen transitions — all converge back on cmd_endpoint_run.
208	                ConfirmOption::Resume => {
209	                    model.enter_resume();
210	                    None
211	                }
212	                // Change-adapter / Instantiate keep the id; Fork starts fresh.
213	                ConfirmOption::ChangeAdapter | ConfirmOption::Instantiate => {
214	                    model.reenter_create(true);
215	                    None
216	                }
217	                ConfirmOption::Fork => {
218	                    model.reenter_create(false);
219	                    None
220	                }
221	            };
222	        }
223	        _ => {}
224	    }
225	    None
226	}
227
228	/// Write the `<basename>-<id>` shortcut at the project cwd, returning a one-line
229	/// status for the flash. (The file write is the only side effect; the TUI stays
230	/// up.) The basename comes from the selected adapter's RESOLVED manifest
231	/// (`[adapter] shortcut_basename`, REQ-MANIFEST-7), defaulting to `spt` — so an
232	/// adapter brands its shortcuts (`cc-<id>`) without spt-core hardcoding it.
233	fn write_shortcut_now(adapter: &str, id: &str, resume: Option<&str>, mode: RunMode) -> String {
234	    let dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
235	    let basename = data::shortcut_basename(adapter);
236	    match shortcut::write_shortcut(&dir, &basename, id, adapter, resume, mode) {
237	        Ok(WriteOutcome::Created(p)) => format!("✓ shortcut written: {}", p.display()),
238	        Ok(WriteOutcome::Updated(p)) => format!("✓ shortcut updated: {}", p.display()),
239	        Ok(WriteOutcome::RefusedForeign(p)) => format!(
240	            "! refused: {} exists and was not written by spt (not overwritten)",
241	            p.display()
242	        ),
243	        Err(e) => format!("! shortcut write failed: {e}"),
244	    }
245	}
246
247	/// Route a terminal outcome through the EXISTING bringup fns — the single
248	/// bringup core (the gate invariant). Returns the process exit code.
249	// [impl->REQ-RUN-PICKER]
250	fn dispatch(outcome: Outcome) -> i32 {
251	    match outcome {
252	        Outcome::Run {
253	            adapter,
254	            id,
255	            resume,
256	            mode,
257	        } => {
258	            let (start, view) = match mode {
259	                RunMode::Start => (true, false),
260	                RunMode::View => (false, true),
261	                RunMode::Attach => (false, false),
262	            };
263	            // attach is the default action (the `_attach` param is accepted for
264	            // flag symmetry; start/view are what branch).
265	            crate::cli::cmd_endpoint_run(&adapter, &id, resume, start, false, view)
266	        }
267	        // Attach to an already-live endpoint via the rc pump (not a re-bringup).
268	        // `intent` carries the status-conditional choice (View / Control /
269	        // Kick-and-attach), all through the SAME rc dispatch (single-bringup-path:
270	        // intent is a parameter, never a second attach path — REQ-KICK-1).
271	        Outcome::Attach { id, intent } => match crate::rc::run_attach(&id, intent) {
272	            Ok(()) => 0,
273	            Err(e) => {
274	                eprintln!("RC_FAIL:{id}: {e}");
275	                1
276	            }
277	        },
278	        // Shortcut is handled inline in the loop; reaching here means nothing to do.
279	        Outcome::Shortcut { .. } | Outcome::Cancelled => 0,
280	    }
281	}
282

//! `spt endpoint run` interactive picker (M12-W2, REQ-RUN-PICKER).
//!
//! Bare `spt endpoint run` (no `--adapter`/`--id`) enters this in-process
//! ratatui picker; the flags-present form stays the REQ-HOST-RUN-1
//! non-interactive path, untouched. The picker is a PURE FRONT-END: every
//! terminal action converges on the [`model::Outcome`] enum, which [`dispatch`]
//! routes through the EXISTING bringup fns ([`crate::cli::cmd_endpoint_run`] /
//! the [`crate::rc`] pump) — there is **no second bringup path** (doyle's
//! binding gate invariant; same discipline as W1.5's one pump).
//!
//! Layering: `model` (pure state + transitions, fully unit-tested), `data`
//! (read-only gather over existing surfaces), `view` (renders a `&PickerModel`
//! to a testable ratatui `Buffer`), `shortcut` (`spt-<id>` generation). This file
//! is the thin live key loop — the manual-verify leg (like REQ-PAIR-6's OS
//! probe); the model/view/shortcut logic under it is unit-asserted.
// [impl->REQ-RUN-PICKER]

mod data;
mod model;
mod shortcut;
mod view;

use std::io::{self, Stdout};

use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;

use model::{ConfirmOption, Outcome, PickerModel, RunMode, Screen};
use shortcut::WriteOutcome;

/// Entry point for `spt endpoint run` when it is NOT fully flagged. Gathers the
/// data, drives the TUI, and dispatches the chosen outcome through the existing
/// bringup fns. Returns the process exit code.
pub fn run(prefill_adapter: Option<String>, prefill_id: Option<String>) -> i32 {
    let project_name = data::project_name();
    let adapter_rows = data::adapter_options();
    let endpoints = data::gather_endpoints();
    let mut model = PickerModel::new(
        project_name,
        adapter_rows,
        endpoints,
        prefill_adapter,
        prefill_id,
    );

    let mut terminal = match setup_terminal() {
        Ok(t) => t,
        Err(e) => {
            eprintln!("PICKER_TTY_FAIL: {e}");
            return 1;
        }
    };
    let outcome = event_loop(&mut terminal, &mut model);
    // Restore the terminal BEFORE dispatching — cmd_endpoint_run / the rc pump
    // own raw mode themselves for the attach.
    restore_terminal(&mut terminal);

    match outcome {
        Ok(outcome) => dispatch(outcome),
        Err(e) => {
            eprintln!("PICKER_FAIL: {e}");
            1
        }
    }
}

type Term = Terminal<CrosstermBackend<Stdout>>;

fn setup_terminal() -> io::Result<Term> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    crossterm::execute!(stdout, EnterAlternateScreen)?;
    Terminal::new(CrosstermBackend::new(stdout))
}

fn restore_terminal(terminal: &mut Term) {
    let _ = disable_raw_mode();
    let _ = crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen);
    let _ = terminal.show_cursor();
}

/// The live key loop. Returns the chosen [`Outcome`]; `Outcome::Shortcut` is
/// handled inline (write the file, flash, stay) so it never escapes the loop.
fn event_loop(terminal: &mut Term, model: &mut PickerModel) -> io::Result<Outcome> {
    loop {
        terminal.draw(|f| view::render(model, f))?;
        let Event::Key(key) = event::read()? else {
            continue;
        };
        // Windows fires Press AND Release; only act on Press.
        if key.kind != KeyEventKind::Press {
            continue;
        }
        // Ctrl-C always cancels.
        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
            return Ok(Outcome::Cancelled);
        }
        // Any key clears a stale flash.
        model.flash = None;

        if let Some(outcome) = handle_key(model, key.code) {
            match outcome {
                Outcome::Shortcut {
                    adapter,
                    id,
                    resume,
                    mode,
                } => {
                    model.flash = Some(write_shortcut_now(&adapter, &id, resume.as_deref(), mode));
                }
                done => return Ok(done),
            }
        }
    }
}

/// Translate one key into a model mutation and/or a yielded outcome. Pure
/// dispatch over the model's transition methods — the model owns the semantics.
fn handle_key(model: &mut PickerModel, code: KeyCode) -> Option<Outcome> {
    // Esc backs out one screen (out of filter mode first); from the kind layer
    // it cancels the whole picker.
    if code == KeyCode::Esc {
        if model.screen == Screen::PickExisting && model.filtering {
            model.end_filter();
            return None;
        }
        return model.back().then_some(Outcome::Cancelled);
    }

    match model.screen {
        Screen::Kind => match code {
            KeyCode::Up => model.kind_cursor = model.kind_cursor.saturating_sub(1),
            KeyCode::Down => model.kind_cursor = (model.kind_cursor + 1).min(1),
            KeyCode::Enter => model.enter_kind(),
            _ => {}
        },
        Screen::CreateAdapter => match code {
            KeyCode::Up => model.move_adapter(-1),
            KeyCode::Down => model.move_adapter(1),
            KeyCode::Enter => model.enter_adapter(),
            _ => {}
        },
        Screen::CreateId => match code {
            KeyCode::Char(c) => model.id_push(c),
            KeyCode::Backspace => model.id_backspace(),
            KeyCode::Enter => return model.create_outcome(),
            _ => {}
        },
        Screen::PickExisting => return handle_pick_key(model, code),
        Screen::Confirm => return handle_confirm_key(model, code),
        Screen::Resume => match code {
            KeyCode::Up => model.move_resume(-1),
            KeyCode::Down => model.move_resume(1),
            KeyCode::Enter => return model.resume_outcome(),
            _ => {}
        },
    }
    None
}

fn handle_pick_key(model: &mut PickerModel, code: KeyCode) -> Option<Outcome> {
    if model.filtering {
        match code {
            KeyCode::Char(c) => model.filter_push(c),
            KeyCode::Backspace => model.filter_backspace(),
            KeyCode::Enter => model.end_filter(),
            KeyCode::Up => model.move_item(-1),
            KeyCode::Down => model.move_item(1),
            _ => {}
        }
        return None;
    }
    match code {
        KeyCode::Left => model.move_category(-1),
        KeyCode::Right => model.move_category(1),
        KeyCode::Up => model.move_item(-1),
        KeyCode::Down => model.move_item(1),
        KeyCode::Char('/') => model.start_filter(),
        // 'n' = new endpoint → jump to create-new.
        KeyCode::Char('n') => model.reenter_create(false),
        KeyCode::Enter => model.enter_pick(),
        _ => {}
    }
    None
}

fn handle_confirm_key(model: &mut PickerModel, code: KeyCode) -> Option<Outcome> {
    match code {
        KeyCode::Up => model.move_confirm(-1),
        KeyCode::Down => model.move_confirm(1),
        // The `s` keybind = build/update the spt-<id> shortcut from every
        // pre-start options set (picker spec).
        KeyCode::Char('s') => return model.confirm_terminal(ConfirmOption::Shortcut),
        KeyCode::Enter => {
            let opt = model.selected_confirm()?;
            return match opt {
                // Immediate terminal actions (Attach/Start/View/Kick/Shortcut).
                ConfirmOption::Attach
                | ConfirmOption::Start
                | ConfirmOption::View
                | ConfirmOption::Kick
                | ConfirmOption::Shortcut => model.confirm_terminal(opt),
                // Screen transitions — all converge back on cmd_endpoint_run.
                ConfirmOption::Resume => {
                    model.enter_resume();
                    None
                }
                // Change-adapter / Instantiate keep the id; Fork starts fresh.
                ConfirmOption::ChangeAdapter | ConfirmOption::Instantiate => {
                    model.reenter_create(true);
                    None
                }
                ConfirmOption::Fork => {
                    model.reenter_create(false);
                    None
                }
            };
        }
        _ => {}
    }
    None
}

/// Write the `<basename>-<id>` shortcut at the project cwd, returning a one-line
/// status for the flash. (The file write is the only side effect; the TUI stays
/// up.) The basename comes from the selected adapter's RESOLVED manifest
/// (`[adapter] shortcut_basename`, REQ-MANIFEST-7), defaulting to `spt` — so an
/// adapter brands its shortcuts (`cc-<id>`) without spt-core hardcoding it.
fn write_shortcut_now(adapter: &str, id: &str, resume: Option<&str>, mode: RunMode) -> String {
    let dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
    let basename = data::shortcut_basename(adapter);
    match shortcut::write_shortcut(&dir, &basename, id, adapter, resume, mode) {
        Ok(WriteOutcome::Created(p)) => format!("✓ shortcut written: {}", p.display()),
        Ok(WriteOutcome::Updated(p)) => format!("✓ shortcut updated: {}", p.display()),
        Ok(WriteOutcome::RefusedForeign(p)) => format!(
            "! refused: {} exists and was not written by spt (not overwritten)",
            p.display()
        ),
        Err(e) => format!("! shortcut write failed: {e}"),
    }
}

/// Route a terminal outcome through the EXISTING bringup fns — the single
/// bringup core (the gate invariant). Returns the process exit code.
// [impl->REQ-RUN-PICKER]
fn dispatch(outcome: Outcome) -> i32 {
    match outcome {
        Outcome::Run {
            adapter,
            id,
            resume,
            mode,
        } => {
            let (start, view) = match mode {
                RunMode::Start => (true, false),
                RunMode::View => (false, true),
                RunMode::Attach => (false, false),
            };
            // attach is the default action (the `_attach` param is accepted for
            // flag symmetry; start/view are what branch).
            crate::cli::cmd_endpoint_run(&adapter, &id, resume, start, false, view)
        }
        // Attach to an already-live endpoint via the rc pump (not a re-bringup).
        // `intent` carries the status-conditional choice (View / Control /
        // Kick-and-attach), all through the SAME rc dispatch (single-bringup-path:
        // intent is a parameter, never a second attach path — REQ-KICK-1).
        Outcome::Attach { id, intent } => match crate::rc::run_attach(&id, intent) {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("RC_FAIL:{id}: {e}");
                1
            }
        },
        // Shortcut is handled inline in the loop; reaching here means nothing to do.
        Outcome::Shortcut { .. } | Outcome::Cancelled => 0,
    }
}