diff --git a/src/commands/cloud_agent/mod.rs b/src/commands/cloud_agent/mod.rs index 82ac0aec5..ba1cf7bf8 100644 --- a/src/commands/cloud_agent/mod.rs +++ b/src/commands/cloud_agent/mod.rs @@ -221,6 +221,8 @@ async fn browse() -> Result<()> { app.skills_source = skills_sync::populated_sources(&home) .first() .map(|(source, _)| source.slug.to_string()); + // Mirrored so the ⌥s settings card opens showing the saved answer. + app.skills_enabled = saved.skills.enabled; // No preferences yet: ask whether to set them up, rather than dropping // someone in front of a prompt whose target, agent and skills are all // unanswered. Choosing Setup from the menu skips that question — they have diff --git a/src/commands/cloud_agent/tui/app.rs b/src/commands/cloud_agent/tui/app.rs index 2b7572e19..cdaec544a 100644 --- a/src/commands/cloud_agent/tui/app.rs +++ b/src/commands/cloud_agent/tui/app.rs @@ -44,9 +44,9 @@ pub const KEY_HELP: &[(&str, &[(&str, &str)])] = &[ &[ ("enter", "connect and type in it"), ("⌥f", "give it the whole screen · again to restore"), - ("shift+enter / f", "leave the TUI and connect full screen"), + ("⌥enter / f", "leave the TUI and connect full screen"), ("c", "copy an ssh command for it"), - ("shift+esc / ^]", "stop typing in it"), + ("⌥esc / ^]", "stop typing in it"), ("wheel", "scroll its output"), ("click a link", "open it in your browser"), ("shift+pgup/pgdn", "scroll without the mouse"), @@ -96,8 +96,8 @@ pub const CARDS: &[(&str, &str)] = &[ ]; /// The setup card, offered only until there are preferences to show. After -/// that it is ⌥s — a thing you go back to occasionally, not a third of the -/// menu. +/// that the answers live on the ⌥s settings card — a thing you go back to +/// occasionally, not a third of the menu. pub const SETUP_CARD: (&str, &str) = ("Setup", "Default agent, skills, and theme"); #[derive(Clone, Debug, PartialEq, Eq)] @@ -261,6 +261,9 @@ pub enum Screen { AgentPick, /// First-run setup, over the menu. Setup, + /// The ⌥s settings card, over the menu: every preference setup collects, + /// changeable after the fact. + Settings, /// Choosing where the prompt lands, over the menu. The same card list the /// setup flow asks with — picking a target is the same question, so it /// should not send anyone through the whole management tree to answer it. @@ -655,6 +658,9 @@ pub enum Effect { CreateDefaultProject(String), /// Persist what first-run setup collected. SaveSetup(Box), + /// Persist a change made on the settings card. The same snapshot shape as + /// setup, but merged over the file on disk rather than replacing it. + SaveSettings(Box), /// Remember the default project chosen from the target card. SaveDefaultProject(Box), /// Open a link that was double-clicked in a session. @@ -707,7 +713,7 @@ pub struct App { /// tree and separated from the rest. pub default_project: Option, /// Whether preferences exist yet. Decides whether the menu carries a Setup - /// card or leaves setup to ⌥s. + /// card or leaves changing things to the ⌥s settings card. pub configured: bool, /// Environments this machine has launched an agent in, from the CLI's own /// records. Loaded eagerly, because an agent you made is one you expect to @@ -748,8 +754,13 @@ pub struct App { pub ending: std::collections::HashSet, /// First-run setup, when there are no preferences yet. pub wizard: Option, + /// The ⌥s settings card, while it is open. + pub settings: Option, /// Which local directory skills would come from, if any. pub skills_source: Option, + /// Whether the skills preference is on, mirrored from the file so the + /// settings card opens showing the truth. + pub skills_enabled: bool, /// The key overlay is open. pub keys_open: bool, /// A drag in progress or a completed selection. @@ -811,7 +822,9 @@ impl App { panes: PaneRects::default(), ending: std::collections::HashSet::new(), wizard: None, + settings: None, skills_source: None, + skills_enabled: false, keys_open: false, selection: None, last_click: None, @@ -1052,6 +1065,38 @@ impl App { self.screen = Screen::Menu; } + /// Open the ⌥s settings card over the menu, seeded with what is saved. + /// + /// The default project's names come from the target, which holds the + /// saved default whenever one exists — the tree would only have the id. + pub fn start_settings(&mut self) { + let project = self + .default_project + .as_ref() + .and(self.target.as_ref()) + .map(|t| super::wizard::ProjectOption { + project_id: t.project_id.clone(), + project_name: t.project_name.clone(), + environment_id: t.environment_id.clone(), + environment_name: t.environment_name.clone(), + }); + self.settings = Some(super::settings::Settings::new( + &self.tree, + project, + self.harness, + self.skills_enabled, + self.skills_source.clone(), + self.theme, + )); + self.screen = Screen::Settings; + } + + /// Close it; every change was already saved on the way. + pub fn end_settings(&mut self) { + self.settings = None; + self.screen = Screen::Menu; + } + /// Adopt a theme slug; an unknown one leaves the current theme alone. pub fn set_theme(&mut self, slug: Option<&str>) { if slug.is_some() { @@ -1589,14 +1634,15 @@ impl App { if self.focus == ManageFocus::Session && self.screen != Screen::Menu { // Three ways out, because terminals disagree about what they will // report. `^]` is the classic escape chord and works everywhere; - // shift+esc needs the enhanced keyboard protocol, without which it - // arrives as a bare Escape meant for the agent; `^o` stays for - // anyone who learned it. + // ⌥esc — the ⌥ family's release, matching every other chord here — + // needs the enhanced keyboard protocol, without which it arrives + // as a bare Escape meant for the agent; `^o` stays for anyone who + // learned it. let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - let shift_esc = key.code == KeyCode::Esc && key.modifiers.contains(KeyModifiers::SHIFT); + let alt_esc = key.code == KeyCode::Esc && key.modifiers.contains(KeyModifiers::ALT); let ctrl_bracket = ctrl && key.code == KeyCode::Char(']'); let ctrl_o = ctrl && matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')); - if shift_esc || ctrl_bracket || ctrl_o { + if alt_esc || ctrl_bracket || ctrl_o { self.focus = ManageFocus::Tree; return None; } @@ -1605,7 +1651,7 @@ impl App { // to the next pane. ⌥f costs Meta-f (forward-word) in a shell; // readline leaves Meta-] unbound, and `^]` (character-search) is // untouched because only the Meta form is claimed. Nothing else is - // intercepted — ⌥s and ⌥t still reach the agent from here. + // intercepted — ⌥s still reaches the agent from here. if let Some(chord) = alt_chord(&key) && matches!(chord, 'f' | ']') { @@ -1656,6 +1702,7 @@ impl App { self.status.clear(); match self.screen { Screen::Setup => self.on_key_wizard(key), + Screen::Settings => self.on_key_settings(key), Screen::TargetPick => self.on_key_target_pick(key), Screen::AgentPick => self.on_key_agent_pick(key), Screen::Menu => self.on_key_menu(key), @@ -1698,7 +1745,7 @@ impl App { } Action::Finish(outcome) => { // There are preferences now, so the menu drops the Setup card - // and setup becomes ⌥s. + // and the answers move to the ⌥s settings card. self.configured = true; self.end_wizard(); Some(Effect::SaveSetup(outcome)) @@ -1706,6 +1753,50 @@ impl App { } } + fn on_key_settings(&mut self, key: KeyEvent) -> Option { + use super::settings::Action; + let settings = self.settings.as_mut()?; + // The picker creating a project owns the keyboard until it is done. + if settings.busy.is_some() { + return None; + } + let action = match key.code { + KeyCode::Up | KeyCode::Char('k') => { + settings.up(); + Action::Redraw + } + KeyCode::Down | KeyCode::Char('j') => { + settings.down(); + Action::Redraw + } + KeyCode::Left | KeyCode::Char('h') => settings.left(), + KeyCode::Right | KeyCode::Char('l') => settings.right(), + KeyCode::Enter => settings.select(), + KeyCode::Esc => settings.back(), + _ => Action::None, + }; + // The theme applies as it cycles, so the whole screen follows. + self.theme = self + .settings + .as_ref() + .map(|s| s.current_theme()) + .unwrap_or(self.theme); + match action { + Action::None | Action::Redraw => None, + Action::Save(outcome) => Some(Effect::SaveSettings(outcome)), + Action::CreateProject(workspace_id) => Some(Effect::CreateDefaultProject(workspace_id)), + Action::RunSetup => { + self.settings = None; + self.start_wizard(false); + None + } + Action::Close => { + self.end_settings(); + None + } + } + } + /// The menu is a form: the prompt takes the keyboard when you click it, and /// a card is a button. /// @@ -2158,8 +2249,13 @@ impl App { /// Hand the whole terminal to the session under the cursor. /// - /// Shift-Enter is the intended chord, but plenty of terminals do not send a + /// ⌥enter is the intended chord, but plenty of terminals do not send a /// modifier with Enter at all, so `f` does the same thing. + /// + /// Deliberately not shift+enter: that one belongs to the harness, where it + /// is the newline every text field gives you. This binding only ever fires + /// with the tree focused, so ⌥enter inside a session still reaches the + /// agent as `ESC CR` — which is the other newline chord harnesses take. fn full_screen_current(&mut self) -> Option { // The row under the cursor if it names a session, else whatever the // pane is showing. @@ -2442,19 +2538,15 @@ impl App { None } - /// The chords that work everywhere. Only two: a theme is worth a key - /// because it is a thing you flick through, and setup is worth one because - /// it left the menu once it had been answered. + /// The chords that work everywhere. Settings is worth one because it left + /// the menu once first-run setup had been answered — and it is where the + /// theme now cycles, which is why there is no ⌥t any more: two chords to + /// the same preference was how they drifted apart. fn alt_action(&mut self, action: char) -> Option { self.status.clear(); match action { - 't' => { - self.theme = self.theme.next(); - self.status = format!("Theme: {}", self.theme.label); - None - } 's' => { - self.start_wizard(false); + self.start_settings(); None } 'f' => { @@ -2711,7 +2803,7 @@ impl App { KeyCode::Up | KeyCode::Char('k') => self.move_cursor(-1), KeyCode::Down | KeyCode::Char('j') => self.move_cursor(1), // Full screen, before the plain Enter arm below claims the key. - KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => { + KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => { self.full_screen_current() } KeyCode::Char('f') => self.full_screen_current(), @@ -3305,7 +3397,7 @@ fn project_count_note(project: &ProjectNode) -> String { /// from a cursor key. `ESC ]` is OSC, which terminals effectively never send, /// so the forward direction is safe on its own. fn alt_chord(key: &KeyEvent) -> Option { - const ACTIONS: &[char] = &['f', 's', 't', ']']; + const ACTIONS: &[char] = &['f', 's', ']']; if key.modifiers.contains(KeyModifiers::ALT) { if let KeyCode::Char(c) = key.code { let c = c.to_ascii_lowercase(); @@ -3317,7 +3409,6 @@ fn alt_chord(key: &KeyEvent) -> Option { match key.code { KeyCode::Char('ƒ') => Some('f'), KeyCode::Char('ß') => Some('s'), - KeyCode::Char('†') => Some('t'), // Option+] composes to a left curly quote, Option+shift+] to the right // one. Both are the same chord as far as anyone pressing it is // concerned, matching how the letters fold their shifted forms. @@ -3864,10 +3955,9 @@ mod tests { assert_eq!(a.on_key(alt('f')), None); assert!(a.maximized); - // ⌥t is not intercepted there — it belongs to whatever is running. - let theme = a.theme.slug; - a.on_key(alt('t')); - assert_eq!(a.theme.slug, theme); + // ⌥s is not intercepted there — it belongs to whatever is running. + a.on_key(alt('s')); + assert!(a.settings.is_none()); } fn with_sessions(names: &[&str]) -> App { @@ -4108,50 +4198,48 @@ mod tests { a.on_key(key(KeyCode::Char('s'))); assert_eq!(a.prompt, "s", "a bare letter is still text"); assert_eq!(a.on_key(alt('s')), None); - assert_eq!(a.screen, Screen::Setup, "⌥s opens setup in place"); + assert_eq!(a.screen, Screen::Settings, "⌥s opens settings in place"); assert_eq!(a.prompt, "s", "the chord must not touch the draft"); } /// Terminals that compose Option+letter instead of sending Meta still get - /// the two chords there are. + /// the chords there are. #[test] fn macos_composed_option_characters_are_accepted() { let mut a = app(); assert_eq!(a.on_key(key(KeyCode::Char('ß'))), None); - assert_eq!(a.screen, Screen::Setup); + assert_eq!(a.screen, Screen::Settings); assert!(a.prompt.is_empty(), "a chord is not text"); - - let mut b = app(); - let theme = b.theme.slug; - b.on_key(key(KeyCode::Char('†'))); - assert_ne!(b.theme.slug, theme); - assert!(b.prompt.is_empty(), "a chord is not text"); } + /// ⌥t went with the theme chord: the theme now cycles on the settings + /// card, and the key falls through like any other unclaimed letter. #[test] - fn alt_t_cycles_the_theme_without_touching_the_prompt() { + fn alt_t_is_no_longer_a_chord() { let mut a = app(); - a.prompt = "keep me".into(); let first = a.theme.slug; assert_eq!(a.on_key(alt('t')), None); - assert_ne!(a.theme.slug, first); - assert!(a.status.starts_with("Theme:")); - assert_eq!(a.prompt, "keep me"); + assert_eq!(a.theme.slug, first, "the theme is ⌥s territory now"); + assert_eq!(a.screen, Screen::Menu); + + // And its composed form is plain text again, like any other + // Option-composed character the TUI has no claim on. + let mut b = app(); + let theme = b.theme.slug; + b.on_key(key(KeyCode::Char('†'))); + assert_eq!(b.theme.slug, theme); + assert_eq!(b.prompt, "†", "unclaimed, the character is text"); } - /// ^t and ⌥t are different chords on the same letter — target and theme - /// must not be reachable from each other. + /// ^t keeps the target picker to itself now that ⌥t is gone — the two + /// were different chords on the same letter. #[test] - fn ctrl_t_and_alt_t_do_different_things() { + fn ctrl_t_still_opens_the_target_picker() { let mut a = app(); let theme = a.theme.slug; a.on_key(ctrl('t')); assert_eq!(a.theme.slug, theme, "^t must not change the theme"); assert_eq!(a.screen, Screen::TargetPick); - - let mut b = app(); - b.on_key(alt('t')); - assert_eq!(b.screen, Screen::Menu, "⌥t must not open the picker"); } /// The two kinds of "new" are different things and say which is which: a @@ -4214,16 +4302,97 @@ mod tests { } } - /// Setup keeps its chord, from either focus and mid-prompt. + /// Settings keeps the chord setup had, from either focus and mid-prompt. #[test] - fn alt_s_opens_setup_from_anywhere() { + fn alt_s_opens_settings_from_anywhere() { let mut a = app(); a.prompt = "fix the tests".into(); assert_eq!(a.on_key(alt('s')), None); - assert_eq!(a.screen, Screen::Setup); + assert_eq!(a.screen, Screen::Settings); assert_eq!(a.prompt, "fix the tests", "the draft survives"); } + /// The settings card edits in place: changing the agent is one keypress + /// and one save, not a walk through the flow. + #[test] + fn settings_cycles_the_agent_and_saves() { + let mut a = app(); + a.on_key(alt('s')); + let Some(Effect::SaveSettings(outcome)) = a.on_key(key(KeyCode::Right)) else { + panic!("expected a save"); + }; + assert_eq!(outcome.agent, "codex"); + assert_eq!( + outcome.theme, a.theme.slug, + "the rest rides along unchanged" + ); + } + + /// The theme applies to the whole screen as it cycles — a colour scheme + /// is picked by looking at it, exactly like the wizard's theme step. + #[test] + fn settings_previews_the_theme_live() { + let mut a = app(); + a.on_key(alt('s')); + for _ in 0..3 { + a.on_key(key(KeyCode::Down)); // down to the theme row + } + let first = a.theme.slug; + let Some(Effect::SaveSettings(outcome)) = a.on_key(key(KeyCode::Right)) else { + panic!("expected a save"); + }; + assert_ne!(a.theme.slug, first, "the whole screen follows"); + assert_eq!(outcome.theme, a.theme.slug); + } + + /// The card opens showing the saved default project, not a blank. + #[test] + fn settings_opens_on_the_saved_default_project() { + let mut a = app(); + a.default_project = Some("proj_1".into()); + a.target = Some(Target { + project_id: "proj_1".into(), + project_name: "devtools".into(), + environment_id: "env_prod".into(), + environment_name: "production".into(), + }); + a.on_key(alt('s')); + let settings = a.settings.as_ref().unwrap(); + assert_eq!( + settings.project.as_ref().unwrap().project_name, + "devtools", + "seeded from the target, which holds the saved default" + ); + } + + /// The last row hands over to the wizard, skipping its intro — choosing + /// it has already answered "set up?". + #[test] + fn settings_can_replay_first_run_setup() { + let mut a = app(); + a.on_key(alt('s')); + for _ in 0..4 { + a.on_key(key(KeyCode::Down)); // down to the last row + } + assert_eq!(a.on_key(key(KeyCode::Enter)), None); + assert_eq!(a.screen, Screen::Setup); + assert!(a.settings.is_none(), "the card handed over"); + assert_eq!( + a.wizard.as_ref().unwrap().step, + crate::commands::cloud_agent::tui::wizard::Step::Target + ); + } + + /// Esc closes the card without ceremony: every change already saved. + #[test] + fn settings_escape_just_closes() { + let mut a = app(); + a.on_key(alt('s')); + assert_eq!(a.on_key(key(KeyCode::Esc)), None); + assert_eq!(a.screen, Screen::Menu); + assert!(a.settings.is_none()); + } + #[test] fn card_shortcuts_and_setup() { let mut a = app(); @@ -5182,12 +5351,12 @@ mod tests { } /// Three ways out of a focused session, because terminals disagree about - /// what they report: shift+esc only exists with the enhanced keyboard protocol, + /// what they report: ⌥esc only exists with the enhanced keyboard protocol, /// so `^]` and `^o` have to work without it. #[test] fn every_release_chord_works() { for release in [ - KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Esc, KeyModifiers::ALT), KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL), ] { @@ -5908,7 +6077,7 @@ mod tests { assert_eq!(a.refresh_agent_sessions("nope"), None); } - /// Shift-enter hands the whole terminal over; `f` does the same, because + /// ⌥enter hands the whole terminal over; `f` does the same, because /// plenty of terminals never send a modifier with Enter. #[test] fn full_screen_has_two_ways_in() { @@ -5916,12 +6085,12 @@ mod tests { a.attach_session(session("ca_1", "nimble-otter"), "ca_1".into()); a.focus = ManageFocus::Tree; - let shift_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + let alt_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT); let Some(Effect::FullScreen { agent_id, session_name, .. - }) = a.on_key(shift_enter) + }) = a.on_key(alt_enter) else { panic!("expected a full-screen request"); }; @@ -5934,6 +6103,32 @@ mod tests { )); } + /// shift+enter belongs to the harness, which reads it as the newline every + /// text field gives you. The TUI must not claim it from either focus. + #[test] + fn shift_enter_is_the_harnesss_to_keep() { + let shift_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + + // With the tree focused it is not the full-screen chord any more; the + // row under the cursor is a workspace, so a plain Enter would expand + // it and a claimed shift+enter would show up as something happening. + let mut a = loaded_app(); + a.attach_session(session("ca_1", "nimble-otter"), "ca_1".into()); + a.focus = ManageFocus::Tree; + assert!( + !matches!(a.on_key(shift_enter), Some(Effect::FullScreen { .. })), + "shift+enter must not hand the terminal over" + ); + + // And with the session focused it falls straight through to the agent, + // like every other key the pane does not reserve. + let mut b = loaded_app(); + b.attach_session(session("ca_1", "nimble-otter"), "ca_1".into()); + b.focus = ManageFocus::Session; + assert_eq!(b.on_key(shift_enter), None); + assert_eq!(b.focus, ManageFocus::Session, "it must not release either"); + } + /// Rows are named by the session, not by a truncated launch line. #[test] fn session_rows_show_the_session_name() { diff --git a/src/commands/cloud_agent/tui/mod.rs b/src/commands/cloud_agent/tui/mod.rs index c4d203b5d..076b158fb 100644 --- a/src/commands/cloud_agent/tui/mod.rs +++ b/src/commands/cloud_agent/tui/mod.rs @@ -17,6 +17,7 @@ pub mod app; pub mod session; +pub mod settings; pub mod theme; mod ui; pub mod wizard; @@ -113,6 +114,63 @@ fn save_setup( Ok(prefs) } +/// Write a change made on the ⌥s settings card. +/// +/// Merged over the file rather than built fresh: the card saves on every +/// change, and the skills exclude list — which no card edits — must survive +/// a stroll through the settings untouched. +fn save_settings( + outcome: &wizard::Outcome, +) -> Result { + use crate::commands::cloud_agent::prefs::{AgentPrefs, DefaultProject}; + + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("no home directory"))?; + let mut prefs = AgentPrefs::load_in(&home).unwrap_or_default(); + prefs.version = crate::commands::cloud_agent::prefs::CURRENT_VERSION; + prefs.agent = Some(outcome.agent.clone()); + prefs.skills.enabled = outcome.skills; + prefs.skills.source = outcome.skills_source.clone(); + prefs.default_project = outcome.project.as_ref().map(|p| DefaultProject { + project_id: p.project_id.clone(), + project_name: p.project_name.clone(), + environment_id: p.environment_id.clone(), + environment_name: p.environment_name.clone(), + }); + prefs.theme = Some(outcome.theme.clone()); + prefs.save_in(&home)?; + Ok(prefs) +} + +/// Persist a settings-card change and bring the session along with it. +/// +/// Quiet on success — the card itself shows the new value, and a status line +/// per keypress while cycling a theme would be noise. Failure says so: a save +/// that silently didn't happen is the worst thing a settings card can do. +fn apply_settings(app: &mut App, outcome: &wizard::Outcome) { + match save_settings(outcome) { + // There are preferences now, whatever there was before. + Ok(_) => app.configured = true, + Err(err) => app.status = format!("Couldn't save your settings: {err:#}"), + } + app.set_harness(Some(&outcome.agent)); + app.set_theme(Some(&outcome.theme)); + app.skills_enabled = outcome.skills; + match &outcome.project { + Some(project) => { + app.default_project = Some(project.project_id.clone()); + app.target = Some(Target { + project_id: project.project_id.clone(), + project_name: project.project_name.clone(), + environment_id: project.environment_id.clone(), + environment_name: project.environment_name.clone(), + }); + } + // "Decide later": the default is gone, but the target stays aimed for + // this run — clearing the default is not pointing the prompt away. + None => app.default_project = None, + } +} + /// Shorten a string for a toast, keeping the front — the host and the start of /// the path are what identify a link. fn elide(text: &str, width: usize) -> String { @@ -467,9 +525,11 @@ pub async fn run( Some(message) = rx.recv() => handle_message(app, message, &tx, &client, &backboard, &stop_fetching), // Animate the loading screen. Only armed while it is showing, so an // idle TUI still blocks rather than spinning on a timer. - // The wizard borrows the same tick for its "creating…" spinner. + // The wizard and settings borrow the same tick for their + // "creating…" spinners. _ = tokio::time::sleep(SPINNER_TICK), if app.loading.active - || app.wizard.as_ref().is_some_and(|w| w.busy.is_some()) => { + || app.wizard.as_ref().is_some_and(|w| w.busy.is_some()) + || app.settings.as_ref().is_some_and(|s| s.busy.is_some()) => { app.tick(); None } @@ -623,6 +683,9 @@ pub async fn run( }); } } + Some(Effect::SaveSettings(outcome)) => { + apply_settings(app, &outcome); + } Some(Effect::ScanEverywhere) => { // A deliberate scan clears a previous rate-limit stop: the user // is asking again, and by now the window may have passed. @@ -908,6 +971,13 @@ fn handle_message( Message::ProjectCreated(result) => { if let Some(w) = app.wizard.as_mut() { w.project_created(result); + } else if let Some(outcome) = app + .settings + .as_mut() + .and_then(|s| s.project_created(result)) + { + // A create that stuck is a change; it saves like any other. + apply_settings(app, &outcome); } None } @@ -1335,7 +1405,7 @@ fn setup_terminal() -> Result>> { // TUI implements drag-to-copy itself. execute!(stdout(), EnterAlternateScreen, EnableMouseCapture, Hide)?; // Ask for the enhanced keyboard protocol, which is what makes a modifier on - // Escape reportable at all: a plain terminal sends shift+esc as a bare Escape, + // Escape reportable at all: a plain terminal sends ⌥esc as a bare Escape, // indistinguishable from the one meant for the agent. Terminals that do not // support it ignore the request, which is why `^]` and `^o` also release. if matches!( diff --git a/src/commands/cloud_agent/tui/settings.rs b/src/commands/cloud_agent/tui/settings.rs new file mode 100644 index 000000000..b3940a811 --- /dev/null +++ b/src/commands/cloud_agent/tui/settings.rs @@ -0,0 +1,564 @@ +//! The ⌥s settings card. +//! +//! Every preference first-run setup collects, on one card, changeable after +//! the fact. The wizard asks its questions once, in order, for someone who has +//! never answered them; this is where the answers live afterwards — each one +//! visible with its current value, and changeable without walking a flow. +//! +//! Values with a handful of options (agent, skills, theme) cycle in place with +//! ←/→ and save on every change, so escape is only ever "close" — there is no +//! dirty state to confirm away. The default project is the one exception: a +//! project list is too long to flick through blind, so its row opens the same +//! card the wizard's project step uses, and comes straight back here. + +use super::app::{HARNESSES, WorkspaceNode}; +use super::theme::{THEMES, Theme}; +use super::wizard::{Outcome, ProjectOption, harness_blurb, project_options}; + +/// One row of the card, top to bottom — the wizard's questions, in its order, +/// plus a way back into the flow itself. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Row { + Agent, + Project, + Skills, + Theme, + /// Replay first-run setup, for anyone who wants the guided walk. + Setup, +} + +const ROWS: &[Row] = &[ + Row::Agent, + Row::Project, + Row::Skills, + Row::Theme, + Row::Setup, +]; + +/// What a keypress asked the loop to do. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + None, + Redraw, + /// A value changed; persist this snapshot of every preference. + Save(Box), + /// Create the default project in this workspace, then call + /// [`Settings::project_created`]. + CreateProject(String), + /// The last row: replay the first-run flow. + RunSetup, + /// Escape from the top level; the card is done. + Close, +} + +pub struct Settings { + pub cursor: usize, + /// The project sub-picker's cursor, while it is open. + pub pick: Option, + /// Set while the picker is creating a project. + pub busy: Option, + /// Shown under the card when the create went wrong. + pub error: Option, + pub projects: Vec, + /// The workspaces a new project could be created in, as (id, name). One + /// create row each, so the card never has to guess which one a new + /// project lands in — the rule the wizard's target step follows too. + pub workspaces: Vec<(String, String)>, + pub project: Option, + pub agent: usize, + pub skills: bool, + pub skills_source: Option, + pub theme: usize, +} + +impl Settings { + pub fn new( + tree: &[WorkspaceNode], + project: Option, + agent: usize, + skills: bool, + skills_source: Option, + theme: &Theme, + ) -> Self { + Self { + cursor: 0, + pick: None, + busy: None, + error: None, + projects: project_options(tree), + workspaces: tree + .iter() + .map(|ws| (ws.id.clone(), ws.name.clone())) + .collect(), + project, + agent: agent.min(HARNESSES.len() - 1), + // "On" with nothing to sync is not a state; it reads as a promise. + skills: skills && skills_source.is_some(), + skills_source, + theme: theme.index(), + } + } + + /// The highlighted row of the main card. + pub fn row(&self) -> Row { + ROWS[self.cursor.min(ROWS.len() - 1)] + } + + /// Whether ←/→ changes the highlighted row in place — what tells the UI to + /// draw the value in cycle arrows. + pub fn cycles(&self) -> bool { + match self.row() { + Row::Agent | Row::Theme => true, + Row::Skills => self.skills_source.is_some(), + Row::Project | Row::Setup => false, + } + } + + /// The main card's rows: (label, current value, what it does). + pub fn options(&self) -> Vec<(String, String, String)> { + ROWS.iter() + .map(|row| match row { + Row::Agent => ( + "Coding agent".into(), + HARNESSES[self.agent].to_string(), + harness_blurb(HARNESSES[self.agent]).into(), + ), + Row::Project => ( + "Default project".into(), + self.project.as_ref().map_or("not set".into(), |p| { + format!("{} ({})", p.project_name, p.environment_name) + }), + "Where new cloud agents are created".into(), + ), + Row::Skills => ( + "Skills sync".into(), + match (&self.skills_source, self.skills) { + (Some(source), true) => format!("on · {source}"), + _ => "off".into(), + }, + match &self.skills_source { + Some(_) if self.skills => "Copied to the agent at launch".into(), + Some(_) => "Agents run with Railway's own skills only".into(), + None => "No skills found on this machine".into(), + }, + ), + Row::Theme => ( + "Theme".into(), + THEMES[self.theme].label.to_string(), + "Previews as you cycle".into(), + ), + Row::Setup => ( + "Run first-time setup again".into(), + String::new(), + String::new(), + ), + }) + .collect() + } + + /// The project sub-picker's rows: (label, tag, detail). + pub fn picker_options(&self) -> Vec<(String, String, String)> { + let mut rows: Vec<(String, String, String)> = self + .projects + .iter() + .map(|p| { + let current = self.project.as_ref().is_some_and(|c| { + c.project_id == p.project_id && c.environment_id == p.environment_id + }); + ( + format!("{} ({})", p.project_name, p.environment_name), + if current { + "current default".into() + } else { + String::new() + }, + String::new(), + ) + }) + .collect(); + // One row per workspace. With a single workspace there is nothing to + // disambiguate and it reads as it always did; with several, the row + // names where the project goes rather than picking one silently. + let single = self.workspaces.len() == 1; + for (_, name) in &self.workspaces { + rows.push(( + if single { + "Create a project".to_string() + } else { + format!("Create a project in {name}") + }, + String::new(), + "A new Railway project named \"Cloud Agents\" to keep them in".into(), + )); + } + rows.push(( + "Decide later".into(), + String::new(), + "Pick a target each time you launch".into(), + )); + rows + } + + /// The theme the whole screen should draw in right now. Applied live, like + /// the wizard's theme step — a colour scheme is picked by looking at it. + pub fn current_theme(&self) -> &'static Theme { + &THEMES[self.theme.min(THEMES.len() - 1)] + } + + pub fn up(&mut self) { + self.error = None; + match self.pick { + Some(p) => self.pick = Some(p.saturating_sub(1)), + None => self.cursor = self.cursor.saturating_sub(1), + } + } + + pub fn down(&mut self) { + self.error = None; + match self.pick { + Some(p) => self.pick = Some((p + 1).min(self.picker_options().len() - 1)), + None => self.cursor = (self.cursor + 1).min(ROWS.len() - 1), + } + } + + pub fn left(&mut self) -> Action { + self.cycle(false) + } + + pub fn right(&mut self) -> Action { + self.cycle(true) + } + + /// Enter on the highlighted row. For a value that cycles, enter is another + /// way to step it forward — a key that did nothing on a row that says + /// "change me" would read as broken. + pub fn select(&mut self) -> Action { + self.error = None; + if let Some(p) = self.pick { + return self.pick_select(p); + } + match self.row() { + Row::Project => self.open_picker(), + Row::Setup => Action::RunSetup, + _ => self.cycle(true), + } + } + + /// Escape: out of the picker, or out of the card. + pub fn back(&mut self) -> Action { + self.error = None; + if self.pick.is_some() { + self.pick = None; + return Action::Redraw; + } + Action::Close + } + + /// The project step finished, one way or the other. `Some` carries the + /// snapshot to persist when the new project stuck. + pub fn project_created( + &mut self, + result: Result, + ) -> Option> { + self.busy = None; + match result { + Ok(project) => { + self.project = Some(project); + self.pick = None; + match self.save() { + Action::Save(outcome) => Some(outcome), + _ => None, + } + } + Err(err) => { + self.error = Some(err); + None + } + } + } + + fn cycle(&mut self, forward: bool) -> Action { + self.error = None; + if self.pick.is_some() { + return Action::None; + } + match self.row() { + Row::Agent => { + self.agent = wrap(self.agent, HARNESSES.len(), forward); + self.save() + } + Row::Skills if self.skills_source.is_some() => { + self.skills = !self.skills; + self.save() + } + Row::Theme => { + self.theme = wrap(self.theme, THEMES.len(), forward); + self.save() + } + // → reads as "into"; ← on a row that opens a card does nothing. + Row::Project if forward => self.open_picker(), + _ => Action::None, + } + } + + fn open_picker(&mut self) -> Action { + // Open on the current default, so enter-enter changes nothing. + let current = self.project.as_ref().and_then(|c| { + self.projects + .iter() + .position(|p| p.project_id == c.project_id && p.environment_id == c.environment_id) + }); + self.pick = Some(current.unwrap_or(0)); + Action::Redraw + } + + fn pick_select(&mut self, p: usize) -> Action { + if let Some(project) = self.projects.get(p) { + self.project = Some(project.clone()); + self.pick = None; + return self.save(); + } + if let Some((id, name)) = self.workspaces.get(p - self.projects.len()) { + self.busy = Some(match self.workspaces.len() { + 1 => "Creating Cloud Agents…".to_string(), + _ => format!("Creating Cloud Agents in {name}…"), + }); + return Action::CreateProject(id.clone()); + } + // Decide later: clear the default, so every launch asks. + self.project = None; + self.pick = None; + self.save() + } + + /// The whole card as one snapshot — every save writes every preference, so + /// there is exactly one shape of write to reason about. + fn save(&self) -> Action { + Action::Save(Box::new(Outcome { + project: self.project.clone(), + agent: HARNESSES[self.agent].to_string(), + skills: self.skills, + skills_source: self.skills_source.clone(), + theme: THEMES[self.theme].slug.to_string(), + })) + } +} + +fn wrap(i: usize, len: usize, forward: bool) -> usize { + if forward { + (i + 1) % len + } else { + (i + len - 1) % len + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::cloud_agent::tui::app::{EnvNode, Load, ProjectNode}; + + fn tree() -> Vec { + vec![WorkspaceNode { + id: "ws1".into(), + name: "Railway".into(), + expanded: true, + projects: vec![ + ProjectNode { + id: "p1".into(), + name: "devtools".into(), + expanded: false, + envs: vec![EnvNode { + id: "e1".into(), + name: "production".into(), + expanded: false, + agents: Load::NotLoaded, + }], + }, + ProjectNode { + id: "p2".into(), + name: "mono".into(), + expanded: false, + envs: vec![EnvNode { + id: "e2".into(), + name: "staging".into(), + expanded: false, + agents: Load::NotLoaded, + }], + }, + ], + }] + } + + fn settings() -> Settings { + Settings::new( + &tree(), + Some(ProjectOption { + project_id: "p1".into(), + project_name: "devtools".into(), + environment_id: "e1".into(), + environment_name: "production".into(), + }), + 0, + true, + Some("claude".into()), + Theme::default_theme(), + ) + } + + fn saved(action: Action) -> Outcome { + match action { + Action::Save(outcome) => *outcome, + other => panic!("expected a save, got {other:?}"), + } + } + + /// Every change is a full snapshot, so one ← on the agent row already + /// carries every other preference unchanged. + #[test] + fn cycling_the_agent_saves_a_full_snapshot() { + let mut s = settings(); + // The list leads with `railway`, so one step forward from the start + // lands on claude. + let outcome = saved(s.right()); + assert_eq!(outcome.agent, "claude"); + assert_eq!(outcome.theme, "railway"); + assert!(outcome.skills); + assert_eq!(outcome.project.unwrap().project_id, "p1"); + + // And it wraps in both directions. + assert_eq!(saved(s.left()).agent, "railway"); + assert_eq!(saved(s.left()).agent, "grok"); + } + + /// Enter on a cycling row steps it forward — a row that says "change me" + /// must not have a dead enter key. + #[test] + fn enter_cycles_too() { + let mut s = settings(); + assert_eq!(saved(s.select()).agent, "claude"); + } + + /// The theme row cycles and the card previews it immediately. + #[test] + fn the_theme_cycles_and_previews() { + let mut s = settings(); + s.cursor = 3; + let first = s.current_theme().slug; + let outcome = saved(s.right()); + assert_ne!(s.current_theme().slug, first); + assert_eq!(outcome.theme, s.current_theme().slug); + } + + /// Skills toggles — but only when there is something to sync. With no + /// source on the machine the row is inert, and says so in its detail line. + #[test] + fn skills_toggle_needs_a_source() { + let mut s = settings(); + s.cursor = 2; + assert!(!saved(s.right()).skills, "on toggles off"); + assert!(saved(s.right()).skills, "and back on"); + + let mut bare = Settings::new(&tree(), None, 0, true, None, Theme::default_theme()); + assert!(!bare.skills, "enabled with no source is not a state"); + bare.cursor = 2; + assert_eq!(bare.right(), Action::None); + let (_, value, detail) = bare.options().remove(2); + assert_eq!(value, "off"); + assert_eq!(detail, "No skills found on this machine"); + } + + /// The project row opens the picker on the current default, so entering + /// and confirming changes nothing. + #[test] + fn the_project_picker_opens_on_the_current_default() { + let mut s = settings(); + s.cursor = 1; + assert_eq!(s.select(), Action::Redraw); + assert_eq!(s.pick, Some(0), "devtools is the default"); + let outcome = saved(s.select()); + assert_eq!(outcome.project.unwrap().project_id, "p1"); + assert_eq!(s.pick, None, "the picker closed"); + } + + /// Choosing another project saves it; "decide later" clears the default. + #[test] + fn picking_and_clearing_the_default_project() { + let mut s = settings(); + s.cursor = 1; + s.select(); + s.down(); + let outcome = saved(s.select()); + assert_eq!(outcome.project.unwrap().project_name, "mono"); + + s.select(); + s.pick = Some(s.picker_options().len() - 1); + let outcome = saved(s.select()); + assert!(outcome.project.is_none(), "decide later clears it"); + let (_, value, _) = s.options().remove(1); + assert_eq!(value, "not set"); + } + + /// Creating a project is the one slow step; it says so, and a failure + /// stays on the picker rather than pretending it worked. + #[test] + fn a_failed_create_keeps_the_picker() { + let mut s = settings(); + s.cursor = 1; + s.select(); + s.pick = Some(s.projects.len()); + // The create row carries the workspace it would create in, so the + // project never lands somewhere the card guessed at. + assert_eq!(s.select(), Action::CreateProject("ws1".into())); + assert!(s.busy.is_some()); + + assert_eq!(s.project_created(Err("no permission".into())), None); + assert!(s.busy.is_none()); + assert_eq!(s.error.as_deref(), Some("no permission")); + assert!(s.pick.is_some(), "still on the picker"); + + let outcome = s + .project_created(Ok(ProjectOption { + project_id: "new".into(), + project_name: "Cloud Agents".into(), + environment_id: "env".into(), + environment_name: "production".into(), + })) + .expect("a create that stuck is a change to save"); + assert_eq!(outcome.project.unwrap().project_name, "Cloud Agents"); + assert_eq!(s.pick, None); + } + + /// Escape is layered: out of the picker first, out of the card second. + #[test] + fn escape_walks_out() { + let mut s = settings(); + s.cursor = 1; + s.select(); + assert_eq!(s.back(), Action::Redraw); + assert_eq!(s.pick, None); + assert_eq!(s.back(), Action::Close); + } + + /// The last row hands over to the wizard. + #[test] + fn the_setup_row_replays_the_flow() { + let mut s = settings(); + s.cursor = 4; + assert_eq!(s.select(), Action::RunSetup); + } + + /// ←/→ on rows that do not cycle must not save anything: a no-op write + /// would still be a disk write per keypress. + #[test] + fn arrows_are_inert_where_nothing_cycles() { + let mut s = settings(); + s.cursor = 1; + assert!(!s.cycles()); + assert_eq!(s.left(), Action::None); + assert_eq!(s.right(), Action::Redraw, "→ opens the picker"); + s.back(); + s.cursor = 4; + assert_eq!(s.left(), Action::None); + assert_eq!(s.right(), Action::None); + } +} diff --git a/src/commands/cloud_agent/tui/ui.rs b/src/commands/cloud_agent/tui/ui.rs index e84158fb1..8a93eda46 100644 --- a/src/commands/cloud_agent/tui/ui.rs +++ b/src/commands/cloud_agent/tui/ui.rs @@ -168,6 +168,10 @@ fn render_screen(app: &App, f: &mut Frame, rects: &mut PaneRects) { render_menu(app, f, rects); render_wizard(app, f); } + Screen::Settings => { + render_menu(app, f, rects); + render_settings(app, f); + } Screen::Menu => render_menu(app, f, rects), Screen::Manage => render_manage(app, f, rects), Screen::TargetPick => { @@ -301,14 +305,12 @@ fn render_menu(app: &App, f: &mut Frame, rects: &mut PaneRects) { MenuFocus::Prompt => &[ ("enter", "launch"), ("shift+tab", "agent"), - ("⌥t", "theme"), - ("⌥s", "setup"), + ("⌥s", "settings"), ], MenuFocus::Cards => &[ ("↑↓", "select"), ("enter", "open"), - ("⌥t", "theme"), - ("⌥s", "setup"), + ("⌥s", "settings"), ("q", "quit"), ], }; @@ -975,12 +977,9 @@ fn render_manage_footer(app: &App, f: &mut Frame, area: Rect, rects: &PaneRects) .selected_agent_status() .is_some_and(|status| status != "running"); let hint: Vec<(&str, &str)> = if app.maximized { - vec![ - ("⌥f", "restore the tree"), - ("shift+esc / ^]", "stop typing"), - ] + vec![("⌥f", "restore the tree"), ("⌥esc / ^]", "stop typing")] } else if app.focus == ManageFocus::Session { - let mut keys = vec![("shift+esc / ^]", "stop typing"), ("⌥f", "maximize")]; + let mut keys = vec![("⌥esc / ^]", "stop typing"), ("⌥f", "maximize")]; // The agent is taking the clicks, so say how to take one back — this is // the terminal's own convention, but nobody guesses it. if app.active_session().is_some_and(|s| s.wants_mouse()) { @@ -992,7 +991,7 @@ fn render_manage_footer(app: &App, f: &mut Frame, area: Rect, rects: &PaneRects) Some(RowKind::Session(..)) => vec![ ("enter", "connect"), ("⌥f", "maximize"), - ("shift+enter", "full screen"), + ("⌥enter", "full screen"), ("c", "copy ssh"), ("x", "end session"), if sleeping { @@ -1155,7 +1154,9 @@ fn render_panel(f: &mut Frame, theme: &Theme, area: Rect, panel: Panel) { if !row.tag.is_empty() { spans.push(Span::styled( format!(" {}", row.tag), - Style::default().fg(theme.dim), + // The tag steps forward with its row: on the settings card it + // is the current value, which is the thing being changed. + Style::default().fg(if on { theme.fg } else { theme.dim }), )); } lines.push(Line::from(spans)); @@ -1225,6 +1226,98 @@ fn render_wizard(app: &App, f: &mut Frame) { ); } +/// The ⌥s settings card: every preference with its current value beside it, +/// or the project sub-picker while it is open. +fn render_settings(app: &App, f: &mut Frame) { + let Some(settings) = app.settings.as_ref() else { + return; + }; + let theme = app.theme; + + // The sub-picker replaces the card wholesale, like a wizard step. + if let Some(pick) = settings.pick { + let rows: Vec = settings + .picker_options() + .into_iter() + .map(|(label, tag, detail)| PanelRow { label, tag, detail }) + .collect(); + let footer = if let Some(busy) = settings.busy.as_deref() { + Line::from(vec![ + Span::styled( + format!("{} ", spinner_frame(app.loading.tick)), + Style::default().fg(theme.accent), + ), + Span::styled(busy.to_string(), Style::default().fg(theme.fg)), + ]) + } else if let Some(error) = settings.error.as_deref() { + Line::from(Span::styled( + format!(" {error}"), + Style::default().fg(theme.pending), + )) + } else { + Line::from(chord_spans( + theme, + &[("↑↓", "choose"), ("enter", "set default"), ("esc", "back")], + )) + }; + render_panel( + f, + theme, + f.area(), + Panel { + title: "settings", + heading: "Where should agents live?", + position: None, + rows: &rows, + cursor: pick, + footer, + }, + ); + return; + } + + let cycles = settings.cycles(); + let rows: Vec = settings + .options() + .into_iter() + .enumerate() + .map(|(i, (label, value, detail))| PanelRow { + // Padded so the values read as a column. + label: format!("{label:<19}"), + // The highlighted value grows arrows when ←/→ changes it in + // place — the hint that this row edits right here. + tag: if i == settings.cursor && cycles { + format!("‹ {value} ›") + } else { + value + }, + detail, + }) + .collect(); + let footer = Line::from(chord_spans( + theme, + &[ + ("↑↓", "choose"), + ("←→", "change"), + ("enter", "edit"), + ("esc", "close"), + ], + )); + render_panel( + f, + theme, + f.area(), + Panel { + title: "settings", + heading: "Cloud agent settings", + position: None, + rows: &rows, + cursor: settings.cursor, + footer, + }, + ); +} + /// Choosing which agent a new session goes on. Only drawn when there is more /// than one to choose between. fn render_agent_pick(app: &App, f: &mut Frame) { @@ -1961,8 +2054,11 @@ mod tests { .rfind(|l| l.contains("launch")) .expect("the menu footer"); assert!(footer.contains("enter"), "{footer}"); - assert!(footer.contains("theme"), "{footer}"); - assert!(footer.contains("setup"), "{footer}"); + assert!(footer.contains("settings"), "{footer}"); + assert!( + !footer.contains("theme"), + "the theme moved onto the settings card: {footer}" + ); // The target shortcut moved onto the target line itself — see // `target_shortcut_sits_on_its_own_line`. assert!(!footer.contains("target"), "{footer}"); @@ -2032,14 +2128,18 @@ mod tests { ); } - /// Setup is on the menu only while there is nothing set up; after that it - /// is the ⌥s in the footer, which is there either way. + /// Setup is on the menu only while there is nothing set up; after that + /// the answers live behind the ⌥s in the footer, which is there either + /// way. #[test] fn setup_is_a_card_only_on_a_first_run() { let mut app = app_with_tree(); let out = draw(&app, 100, 40); assert!(!out.contains("Default agent, skills"), "{out}"); - assert!(out.contains("setup"), "the chord is still offered:\n{out}"); + assert!( + out.contains("settings"), + "the chord is still offered:\n{out}" + ); app.configured = false; let out = draw(&app, 100, 40); @@ -2101,7 +2201,7 @@ mod tests { let footer = out .lines() - .rfind(|l| l.contains("theme") || l.contains("setup")) + .rfind(|l| l.contains("settings")) .expect("the menu footer"); assert!( !footer.contains("^t"), @@ -2650,7 +2750,7 @@ mod tests { let out = draw(&app, 100, 30); assert!(out.contains("keys")); assert!(out.contains("refresh"), "{out}"); - assert!(out.contains("shift+esc / ^]"), "{out}"); + assert!(out.contains("⌥esc / ^]"), "{out}"); assert!(out.contains("any key closes")); } @@ -2817,6 +2917,47 @@ mod tests { ); } + /// The settings card shows every value beside its name, and the + /// highlighted one wears the cycle arrows. + #[test] + fn the_settings_card_shows_values_in_place() { + let mut app = app_with_tree(); + app.skills_source = Some("claude".into()); + app.skills_enabled = true; + app.start_settings(); + + let out = draw(&app, 100, 40); + assert!(out.contains("Cloud agent settings"), "{out}"); + assert!( + out.contains("‹ claude ›"), + "the highlighted row cycles in place:\n{out}" + ); + assert!(out.contains("on · claude"), "{out}"); + assert!(out.contains("Railway"), "the theme's label:\n{out}"); + assert!(out.contains("Run first-time setup again"), "{out}"); + assert!( + out.contains("not set"), + "no default project reads as such:\n{out}" + ); + } + + /// The project row opens the wizard's question as a sub-card and comes + /// straight back, rather than walking the rest of a flow. + #[test] + fn the_settings_project_picker_is_the_setup_question() { + let mut app = app_with_tree(); + app.start_settings(); + if let Some(settings) = app.settings.as_mut() { + settings.down(); // the project row + settings.select(); // opens the picker + } + + let out = draw(&app, 100, 40); + assert!(out.contains("Where should agents live?"), "{out}"); + assert!(out.contains("devtools (production)"), "{out}"); + assert!(out.contains("Decide later"), "{out}"); + } + /// A tree with nothing in it must not panic the renderer. #[test] fn manage_survives_an_empty_tree() { diff --git a/src/commands/cloud_agent/tui/wizard.rs b/src/commands/cloud_agent/tui/wizard.rs index 5a6d58432..f5673869a 100644 --- a/src/commands/cloud_agent/tui/wizard.rs +++ b/src/commands/cloud_agent/tui/wizard.rs @@ -117,6 +117,38 @@ pub enum Action { Cancel, } +/// The projects a card can offer as the default, from the tree the TUI +/// already loaded. Shared with the settings card, which asks the same +/// question after the fact. +pub fn project_options(tree: &[WorkspaceNode]) -> Vec { + tree.iter() + .flat_map(|ws| ws.projects.iter()) + .filter_map(|project| { + let env = project.envs.first()?; + Some(ProjectOption { + project_id: project.id.clone(), + project_name: project.name.clone(), + environment_id: env.id.clone(), + environment_name: env.name.clone(), + }) + }) + .collect() +} + +/// What each harness is, for the cards that offer them. +pub fn harness_blurb(slug: &str) -> &'static str { + match slug { + "claude" => "Anthropic's Claude Code", + "codex" => "OpenAI's Codex", + "grok" => "xAI's Grok", + "railway" => "Railway's own agent — no sign-in needed", + // Named rather than folded into a catch-all: an unknown slug is a + // preferences file someone hand-edited, and labelling it as whichever + // harness happens to sit in the `_` arm is worse than saying nothing. + _ => "", + } +} + impl Wizard { /// Build the flow, taking the workspace tree from the loaded tree. The /// first workspace opens by default — mirroring the Manage screen, so the @@ -260,16 +292,7 @@ impl Wizard { .collect(), Step::Agent => super::app::HARNESSES .iter() - .map(|slug| { - let what = match *slug { - "claude" => "Anthropic's Claude Code", - "codex" => "OpenAI's Codex", - "grok" => "xAI's Grok", - "railway" => "Railway's own agent — no sign-in needed", - _ => "", - }; - ((*slug).to_string(), what.to_string()) - }) + .map(|slug| ((*slug).to_string(), harness_blurb(slug).to_string())) .collect(), Step::Skills => vec![ (