From 90f55423f6bf1acaa4f6a6396d5325f981a30f52 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Mon, 10 Aug 2026 15:31:55 +0000 Subject: [PATCH 1/4] =?UTF-8?q?Replace=20the=20wizard-replay=20=E2=8C=A5s?= =?UTF-8?q?=20with=20a=20proper=20settings=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌥s used to replay the whole first-run wizard, and ⌥t cycled the theme blind — two chords, one of them a four-step flow, to change one answer. ⌥s now opens a settings card showing every preference from agent-prefs.json with its current value: coding agent, default project, skills sync, and theme. Values with a handful of options cycle in place with ←/→ and save on every change (merged over the file on disk, so the skills exclude list survives). The theme applies to the whole screen as it cycles, exactly like the wizard's theme step, which is why ⌥t retires. The default project opens the wizard's project question as a sub-card — including "create a project" and "decide later" — and comes straight back. A last row replays first-run setup for anyone who wants the guided walk. The wizard itself is unchanged and remains the first-run flow. --- src/commands/cloud_agent/mod.rs | 2 + src/commands/cloud_agent/tui/app.rs | 253 ++++++++-- src/commands/cloud_agent/tui/mod.rs | 74 ++- src/commands/cloud_agent/tui/settings.rs | 559 +++++++++++++++++++++++ src/commands/cloud_agent/tui/ui.rs | 164 ++++++- src/commands/cloud_agent/tui/wizard.rs | 43 +- 6 files changed, 1029 insertions(+), 66 deletions(-) create mode 100644 src/commands/cloud_agent/tui/settings.rs 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..698020199 100644 --- a/src/commands/cloud_agent/tui/app.rs +++ b/src/commands/cloud_agent/tui/app.rs @@ -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() { @@ -1605,7 +1650,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 +1701,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 +1744,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 +1752,52 @@ 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. /// @@ -2442,19 +2534,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' => { @@ -3305,7 +3393,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 +3405,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 +3951,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 +4194,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 +4298,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::Project + ); + } + + /// 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(); diff --git a/src/commands/cloud_agent/tui/mod.rs b/src/commands/cloud_agent/tui/mod.rs index c4d203b5d..1729143de 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 } diff --git a/src/commands/cloud_agent/tui/settings.rs b/src/commands/cloud_agent/tui/settings.rs new file mode 100644 index 000000000..91a4f509d --- /dev/null +++ b/src/commands/cloud_agent/tui/settings.rs @@ -0,0 +1,559 @@ +//! 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 { + 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(); + let outcome = saved(s.right()); + assert_eq!(outcome.agent, "codex"); + 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, "claude"); + 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, "codex"); + } + + /// 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()); + assert_eq!(s.select(), Action::CreateProject); + 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..3dd9de40a 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"), ], }; @@ -1155,7 +1157,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 +1229,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 +2057,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 +2131,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); @@ -2817,6 +2920,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![ ( From 146d320a06f0dbd91822bc3ac549bef1fcaefd5b Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Mon, 10 Aug 2026 15:45:21 +0000 Subject: [PATCH 2/4] =?UTF-8?q?Release=20a=20focused=20session=20with=20?= =?UTF-8?q?=E2=8C=A5esc=20instead=20of=20shift+esc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One modifier family for the TUI's own chords: ⌥f, ⌥], ⌥s, and now ⌥esc to stop typing in a session. The trade is symmetric — both spellings need the enhanced keyboard protocol to be reportable at all, and both degrade to a bare Escape without it, which is why `^]` and `^o` keep working everywhere. --- src/commands/cloud_agent/tui/app.rs | 17 +++++++++-------- src/commands/cloud_agent/tui/mod.rs | 2 +- src/commands/cloud_agent/tui/ui.rs | 9 +++------ 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/commands/cloud_agent/tui/app.rs b/src/commands/cloud_agent/tui/app.rs index 698020199..61d2afde6 100644 --- a/src/commands/cloud_agent/tui/app.rs +++ b/src/commands/cloud_agent/tui/app.rs @@ -46,7 +46,7 @@ pub const KEY_HELP: &[(&str, &[(&str, &str)])] = &[ ("⌥f", "give it the whole screen · again to restore"), ("shift+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"), @@ -1634,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; } @@ -5347,12 +5348,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), ] { diff --git a/src/commands/cloud_agent/tui/mod.rs b/src/commands/cloud_agent/tui/mod.rs index 1729143de..076b158fb 100644 --- a/src/commands/cloud_agent/tui/mod.rs +++ b/src/commands/cloud_agent/tui/mod.rs @@ -1405,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/ui.rs b/src/commands/cloud_agent/tui/ui.rs index 3dd9de40a..8168a0293 100644 --- a/src/commands/cloud_agent/tui/ui.rs +++ b/src/commands/cloud_agent/tui/ui.rs @@ -977,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()) { @@ -2753,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")); } From 548de4186b2c8fbd342452893d887ad748629640 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Mon, 10 Aug 2026 23:54:34 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Full-screen=20a=20session=20with=20?= =?UTF-8?q?=E2=8C=A5enter,=20freeing=20shift+enter=20for=20the=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shift+enter is the newline every text field gives you, and the harnesses running in a session bind it that way. Claiming it for "leave the TUI and connect full screen" meant the TUI took a key the agent needed. Full screen moves to ⌥enter, joining the ⌥ family the other chords now use. `f` stays as the fallback for terminals that send no modifier with Enter — and ⌥enter needs one less than shift+enter did: a terminal with Option-as-Meta sends ESC CR, which parses as Alt+Enter without the enhanced keyboard protocol shift+enter required. The binding only ever fired with the tree focused, so this is about which key the TUI reserves rather than what a focused pane forwards; a test now pins shift+enter as untouched from either focus. --- src/commands/cloud_agent/tui/app.rs | 43 +++++++++++++++++++++++++---- src/commands/cloud_agent/tui/ui.rs | 2 +- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/commands/cloud_agent/tui/app.rs b/src/commands/cloud_agent/tui/app.rs index 61d2afde6..f876b12c2 100644 --- a/src/commands/cloud_agent/tui/app.rs +++ b/src/commands/cloud_agent/tui/app.rs @@ -44,7 +44,7 @@ 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"), ("⌥esc / ^]", "stop typing in it"), ("wheel", "scroll its output"), @@ -2251,8 +2251,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. @@ -2800,7 +2805,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(), @@ -6074,7 +6079,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() { @@ -6082,12 +6087,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"); }; @@ -6100,6 +6105,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/ui.rs b/src/commands/cloud_agent/tui/ui.rs index 8168a0293..5b38aa8cb 100644 --- a/src/commands/cloud_agent/tui/ui.rs +++ b/src/commands/cloud_agent/tui/ui.rs @@ -991,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 { From 2007c25484a3388ab714dec4d99e33acd0e01869 Mon Sep 17 00:00:00 2001 From: Cody De Arkland Date: Tue, 11 Aug 2026 04:47:03 +0000 Subject: [PATCH 4/4] Integrate the settings card with the workspace-first target step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacking #1069 on #1072 crosses three changes: harness_blurb gains the railway arm. The extracted helper had `_ => "xAI's Grok"` as its catch-all, and with railway now leading HARNESSES that fallback would have labelled Railway's own agent as Grok. Every harness is named, and the catch-all says nothing rather than guessing. The settings card's "create a project" row carries a workspace, since CreateDefaultProject now takes one. It lists one row per workspace — identical wording with a single workspace, named with several — rather than defaulting to the first, which is the bug #1072 set out to fix. The menu footer drops both `^t` (onto the target line, #1072) and `⌥t` (onto the settings card, #1069), leaving `⌥s settings`. --- src/commands/cloud_agent/tui/app.rs | 6 ++---- src/commands/cloud_agent/tui/settings.rs | 13 +++++++++---- src/commands/cloud_agent/tui/ui.rs | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/commands/cloud_agent/tui/app.rs b/src/commands/cloud_agent/tui/app.rs index f876b12c2..cdaec544a 100644 --- a/src/commands/cloud_agent/tui/app.rs +++ b/src/commands/cloud_agent/tui/app.rs @@ -1784,9 +1784,7 @@ impl App { match action { Action::None | Action::Redraw => None, Action::Save(outcome) => Some(Effect::SaveSettings(outcome)), - Action::CreateProject(workspace_id) => { - Some(Effect::CreateDefaultProject(workspace_id)) - } + Action::CreateProject(workspace_id) => Some(Effect::CreateDefaultProject(workspace_id)), Action::RunSetup => { self.settings = None; self.start_wizard(false); @@ -4381,7 +4379,7 @@ mod tests { assert!(a.settings.is_none(), "the card handed over"); assert_eq!( a.wizard.as_ref().unwrap().step, - crate::commands::cloud_agent::tui::wizard::Step::Project + crate::commands::cloud_agent::tui::wizard::Step::Target ); } diff --git a/src/commands/cloud_agent/tui/settings.rs b/src/commands/cloud_agent/tui/settings.rs index 91a4f509d..b3940a811 100644 --- a/src/commands/cloud_agent/tui/settings.rs +++ b/src/commands/cloud_agent/tui/settings.rs @@ -359,6 +359,7 @@ mod tests { fn tree() -> Vec { vec![WorkspaceNode { + id: "ws1".into(), name: "Railway".into(), expanded: true, projects: vec![ @@ -416,14 +417,16 @@ mod tests { #[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, "codex"); + 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, "claude"); + assert_eq!(saved(s.left()).agent, "railway"); assert_eq!(saved(s.left()).agent, "grok"); } @@ -432,7 +435,7 @@ mod tests { #[test] fn enter_cycles_too() { let mut s = settings(); - assert_eq!(saved(s.select()).agent, "codex"); + assert_eq!(saved(s.select()).agent, "claude"); } /// The theme row cycles and the card previews it immediately. @@ -503,7 +506,9 @@ mod tests { s.cursor = 1; s.select(); s.pick = Some(s.projects.len()); - assert_eq!(s.select(), Action::CreateProject); + // 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); diff --git a/src/commands/cloud_agent/tui/ui.rs b/src/commands/cloud_agent/tui/ui.rs index 5b38aa8cb..8a93eda46 100644 --- a/src/commands/cloud_agent/tui/ui.rs +++ b/src/commands/cloud_agent/tui/ui.rs @@ -2201,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"),