diff --git a/Cargo.toml b/Cargo.toml index cf89cce..0f4c754 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://github.com/Dione-b/procyon" readme = "README.md" keywords = ["stellar", "soroban", "tui", "agent", "smart-contracts"] categories = ["command-line-utilities", "development-tools"] +exclude = [".github/", ".env.example", ".gitignore"] [dependencies] # UI e terminal diff --git a/README.md b/README.md index 811e5dc..a722f17 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ account keys and contract interfaces, and Node/`npx` for the [Caatinga](#caating ## Status Early. The core loop works — streaming, tool calling, workspace confinement, context compaction, -MCP, session persistence, twelve providers — with 369 tests and CI. What to expect: +MCP, session persistence, twelve providers — with 389 tests and CI. What to expect: - **No per-operation approval.** File writes are confined to the workspace but not individually confirmed. Mainnet is a switch you set once, not a prompt. See [Safety](#safety). @@ -227,7 +227,7 @@ to compact rather than corrupt the transcript. ## Development ```bash -cargo test # 369 tests, no network, no toolchain +cargo test # 389 tests, no network, no toolchain cargo test -- --ignored # 8 more, needing network or an installed toolchain cargo clippy --all-targets -- -D warnings cargo fmt --all --check diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index e67d634..5c210b0 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -80,7 +80,7 @@ pub async fn run_subagent( let max_output = subagent_config.max_tokens.unwrap_or(config.max_tokens) as usize; let envelope = crate::budget::price_envelope(Some(&subagent_config.system_prompt), &tool_defs); let ceiling = crate::budget::threshold_tokens(crate::budget::usable_window( - crate::budget::context_window(model), + crate::budget::context_window(config.provider, model), max_output, )); diff --git a/src/anthropic/stream.rs b/src/anthropic/stream.rs index 2d607cf..1cff786 100644 --- a/src/anthropic/stream.rs +++ b/src/anthropic/stream.rs @@ -110,7 +110,11 @@ impl EventSink for StreamState<'_> { Delta::TextDelta { text } => { // A delta for a block that was never opened still belongs somewhere: dropping // it would lose text the user has already been shown. - match self.blocks.entry(index).or_insert_with(|| Partial::Text(String::new())) { + match self + .blocks + .entry(index) + .or_insert_with(|| Partial::Text(String::new())) + { Partial::Text(buffer) => buffer.push_str(&text), // Text arriving on a tool block is not something this format produces; // appending it to the argument string would corrupt the call. diff --git a/src/app.rs b/src/app.rs index 5ec715e..41e6557 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,84 @@ +use std::path::PathBuf; + use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; use tokio::sync::mpsc; use crate::channels::{AgentUpdate, UserCommand}; use crate::config::Provider; +use crate::credentials::CredentialStore; + +pub struct SlashCommand { + pub name: &'static str, + pub description: &'static str, +} + +/// One suggestion in the autocomplete popup. Owned rather than an index into `SLASH_COMMANDS`, +/// since past the command name the candidates are generated on the fly — provider names, model +/// names, `/model`'s subcommands — and don't live in any static table. +#[derive(Clone, Debug, PartialEq)] +pub struct AutocompleteItem { + pub value: String, + pub description: String, +} + +const SLASH_COMMANDS: &[SlashCommand] = &[ + SlashCommand { + name: "/help", + description: "Show this help", + }, + SlashCommand { + name: "/clear", + description: "Clear chat history", + }, + SlashCommand { + name: "/status", + description: "Show connection status", + }, + SlashCommand { + name: "/project", + description: "Show project info", + }, + SlashCommand { + name: "/explain", + description: "Toggle explain mode", + }, + SlashCommand { + name: "/network", + description: "Switch network (local/testnet/mainnet)", + }, + SlashCommand { + name: "/model", + description: "Show model status and suggestions", + }, + SlashCommand { + name: "/model set", + description: "Switch provider and model", + }, + SlashCommand { + name: "/model provider", + description: "Switch provider only", + }, + SlashCommand { + name: "/model model", + description: "Switch model only", + }, + SlashCommand { + name: "/login", + description: "Save an API key for a provider", + }, + SlashCommand { + name: "/logout", + description: "Remove a stored API key", + }, + SlashCommand { + name: "/providers", + description: "Show credential status for every provider", + }, + SlashCommand { + name: "/install-stellar-build", + description: "Install the Stellar Build persona pack (third-party)", + }, +]; #[derive(Clone, Debug)] pub enum ChatMessage { @@ -54,6 +130,15 @@ pub struct AppState { /// Mirrors the agent's last `Ready` update, so `settle()` knows which resting state a turn /// returns to once it ends or fails. has_credential: bool, + /// Overrides where `/login`, `/logout` and `/providers` look for stored credentials. + /// `None` means the real `~/.config/procyon/credentials.toml`; tests point this at a tempdir + /// so they never touch the user's actual file. + credentials_path: Option, + // Autocomplete state + pub autocomplete_active: bool, + pub autocomplete_matches: Vec, + pub autocomplete_selected: usize, + pub autocomplete_prefix: String, } impl AppState { @@ -77,6 +162,11 @@ impl AppState { agent_streaming: false, explain_mode: false, has_credential: true, + credentials_path: None, + autocomplete_active: false, + autocomplete_matches: Vec::new(), + autocomplete_selected: 0, + autocomplete_prefix: String::new(), } } @@ -92,6 +182,154 @@ impl AppState { self.input.chars().count() } + /// Derives autocomplete state fresh from `self.input`, so it self-corrects on every + /// keystroke — including one that lands the cursor back inside a context that had earlier + /// closed the popup (e.g. backspacing out of a provider name once no matches were left). + fn sync_autocomplete(&mut self) { + if !self.input.starts_with('/') { + self.autocomplete_active = false; + self.autocomplete_matches.clear(); + return; + } + self.autocomplete_matches = self.compute_autocomplete_matches(); + self.autocomplete_selected = 0; + self.autocomplete_active = !self.autocomplete_matches.is_empty(); + } + + /// Suggestions for whatever is being typed right now: the command name up to the first + /// space, and past that whichever argument the command expects there — providers, models, or + /// `/model`'s own subcommands. Each is generated on the fly rather than read from a table, so + /// providers and models stay in sync with `Provider::ALL`/`suggested_models` with nothing to + /// duplicate or fall out of date. + fn compute_autocomplete_matches(&self) -> Vec { + if !self.input.contains(' ') { + return Self::filter_candidates( + SLASH_COMMANDS + .iter() + .map(|c| (c.name.to_string(), c.description.to_string())), + &self.input, + ); + } + + let tokens: Vec<&str> = self.input.split_whitespace().collect(); + let (fixed, partial) = if self.input.ends_with(' ') { + (tokens.as_slice(), "") + } else { + (&tokens[..tokens.len() - 1], *tokens.last().unwrap()) + }; + let fixed: Vec = fixed.iter().map(|t| t.to_lowercase()).collect(); + let fixed: Vec<&str> = fixed.iter().map(String::as_str).collect(); + + let model_names = |provider: &str| -> Vec<(String, String)> { + provider + .parse::() + .ok() + .into_iter() + .flat_map(|p| p.suggested_models()) + .map(|m| (m.to_string(), String::new())) + .collect() + }; + + let candidates: Vec<(String, String)> = match fixed.as_slice() { + ["/model"] => vec![ + ( + "status".to_string(), + "Show model status and suggestions".to_string(), + ), + ("set".to_string(), "Switch provider and model".to_string()), + ("provider".to_string(), "Switch provider only".to_string()), + ("model".to_string(), "Switch model only".to_string()), + ], + ["/model", "provider"] | ["/model", "set"] | ["/login"] | ["/logout"] => { + Self::provider_candidates() + } + ["/model", "model"] => model_names(&self.active_provider), + ["/model", "set", provider] => model_names(provider), + ["/network"] => vec![ + ("local".to_string(), String::new()), + ("testnet".to_string(), String::new()), + ("mainnet".to_string(), String::new()), + ], + ["/install-stellar-build"] => vec![( + "confirm".to_string(), + "Actually run the third-party installer".to_string(), + )], + _ => Vec::new(), + }; + Self::filter_candidates(candidates.into_iter(), partial) + } + + fn provider_candidates() -> Vec<(String, String)> { + Provider::ALL + .iter() + .map(|p| { + let hint = if p.is_local() { + "local, no credential needed" + } else { + "" + }; + (p.to_string(), hint.to_string()) + }) + .collect() + } + + fn filter_candidates( + candidates: impl Iterator, + partial: &str, + ) -> Vec { + let partial = partial.to_lowercase(); + candidates + .filter(|(value, _)| value.to_lowercase().starts_with(&partial)) + .map(|(value, description)| AutocompleteItem { value, description }) + .collect() + } + + /// Moves the highlight by `delta`, wrapping at both ends. + fn move_autocomplete_selection(&mut self, delta: isize) { + let len = self.autocomplete_matches.len(); + if len == 0 { + return; + } + let len_i = len as isize; + let next = (self.autocomplete_selected as isize + delta).rem_euclid(len_i); + self.autocomplete_selected = next as usize; + } + + fn accept_autocomplete(&mut self) { + if let Some(item) = self.autocomplete_matches.get(self.autocomplete_selected) { + let value = item.value.clone(); + // Completing a command name (no space typed yet) replaces the whole line; completing + // an argument replaces only the token in progress and leaves a trailing space, ready + // for the next one. + if self.input.contains(' ') { + let base = self.input.rfind(' ').map(|i| i + 1).unwrap_or(0); + self.input.truncate(base); + self.input.push_str(&value); + self.input.push(' '); + } else { + self.input = value; + } + self.input_cursor = self.input.chars().count(); + } + self.autocomplete_active = false; + self.autocomplete_matches.clear(); + } + + fn cancel_autocomplete(&mut self) { + self.input = self.autocomplete_prefix.clone(); + self.input_cursor = self.input.chars().count(); + self.autocomplete_active = false; + self.autocomplete_matches.clear(); + } + + // Only a test fixture now: real suggestion lookups go through `compute_autocomplete_matches`, + // which reads `SLASH_COMMANDS` directly. `#[cfg(test)]` keeps it from being dead code in a + // normal build now that ui.rs's tests are its only caller. + #[cfg(test)] + pub fn slash_commands() -> &'static [SlashCommand] { + SLASH_COMMANDS + } + // `chat_scroll` is the first visible line, anchored at the top: a reader who scrolled back // stays on the same content as new messages arrive. `chat_follow` re-pins to the newest line, // and is what makes an idle chat auto-scroll. @@ -150,6 +388,50 @@ impl AppState { .push(ChatMessage::System("Building project...".to_string())); let _ = user_tx.send(UserCommand::SendPrompt("build the project".to_string())); } + // Autocomplete navigation: Tab / Shift+Tab, and the arrow keys, which is where a hand + // reaches first. Down/Up have to be matched here so the chat scroll arms below do not + // swallow them while the popup is open. + (KeyModifiers::NONE, KeyCode::Tab | KeyCode::Down) if self.autocomplete_active => { + self.move_autocomplete_selection(1); + } + (KeyModifiers::NONE, KeyCode::BackTab | KeyCode::Up) + | (KeyModifiers::SHIFT, KeyCode::BackTab) + if self.autocomplete_active => + { + self.move_autocomplete_selection(-1); + } + // Accept the highlighted suggestion — unless what's typed already matches it exactly, + // in which case accepting would be a no-op and the user almost certainly means to + // submit (e.g. having typed "/model set anthropic" character by character until it + // stopped changing). Without this, Enter on an exact match would silently do nothing + // and need a second press. + (KeyModifiers::NONE, KeyCode::Enter) if self.autocomplete_active => { + let already_typed = self + .autocomplete_matches + .get(self.autocomplete_selected) + .is_some_and(|item| { + let current_token = if self.input.ends_with(' ') { + "" + } else { + self.input.rsplit(' ').next().unwrap_or(&self.input) + }; + current_token.eq_ignore_ascii_case(&item.value) + }); + if already_typed { + let msg = self.input.trim().to_string(); + self.handle_command(&msg, user_tx); + self.input.clear(); + self.input_cursor = 0; + self.autocomplete_active = false; + self.autocomplete_matches.clear(); + } else { + self.accept_autocomplete(); + } + } + // Cancel autocomplete + (KeyModifiers::NONE, KeyCode::Esc) if self.autocomplete_active => { + self.cancel_autocomplete(); + } (KeyModifiers::NONE, KeyCode::Enter) => { if !self.input.trim().is_empty() { let msg = self.input.trim().to_string(); @@ -163,24 +445,35 @@ impl AppState { self.input.clear(); self.input_cursor = 0; + self.autocomplete_active = false; + self.autocomplete_matches.clear(); } } (KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => { let at = self.cursor_byte_offset(); self.input.insert(at, c); self.input_cursor += 1; + + // Captured once, at the moment autocomplete opens, purely for Esc to revert to: + // suggestions themselves are recomputed from scratch below on every keystroke. + if c == '/' && self.input_cursor == 1 { + self.autocomplete_prefix = self.input.clone(); + } + self.sync_autocomplete(); } (KeyModifiers::NONE, KeyCode::Backspace) => { if self.input_cursor > 0 { self.input_cursor -= 1; let at = self.cursor_byte_offset(); self.input.remove(at); + self.sync_autocomplete(); } } (KeyModifiers::NONE, KeyCode::Delete) => { if self.input_cursor < self.input_char_count() { let at = self.cursor_byte_offset(); self.input.remove(at); + self.sync_autocomplete(); } } (KeyModifiers::NONE, KeyCode::Left) => { @@ -229,6 +522,13 @@ impl AppState { } } + fn open_credential_store(&self) -> color_eyre::Result { + match &self.credentials_path { + Some(path) => CredentialStore::load(path.clone()), + None => CredentialStore::load_default(), + } + } + fn handle_command(&mut self, cmd: &str, user_tx: &mpsc::UnboundedSender) { let parts: Vec<&str> = cmd.split_whitespace().collect(); let command = parts[0]; @@ -247,6 +547,11 @@ impl AppState { /model set - Switch provider and model\n\ /model provider - Switch provider only\n\ /model model - Switch model only\n\ + /login - Save an API key for a provider\n\ + /logout - Remove a stored API key\n\ + /providers - Show credential status for every provider\n\ + /install-stellar-build - Install the Stellar Build persona pack \ + (third-party)\n\ \n\ Keyboard shortcuts:\n\ Ctrl+C - Quit\n\ @@ -406,6 +711,151 @@ impl AppState { } } } + "/login" => { + match (parts.get(1), parts.get(2)) { + (Some(p), Some(_)) => match p.parse::() { + Ok(provider) => match self.open_credential_store() { + // The key itself never touches `self.messages`: it must not linger in + // the chat history that gets rendered and scrolled. + Ok(mut store) => { + // Joined rather than `parts[2]` alone: a key with an internal or + // trailing space (common after a clipboard paste) used to be + // silently truncated at the first token instead of stored whole. + let key = parts[2..].join(" "); + match store.set(&provider.to_string(), key) { + Ok(()) => { + self.messages.push(ChatMessage::System(format!( + "Saved credential for {}.", + provider + ))); + if provider.to_string() == self.active_provider { + self.request_switch( + user_tx, + provider, + self.active_model.clone(), + ); + } else { + self.messages.push(ChatMessage::System(format!( + "Run `/model provider {}` to switch to it.", + provider + ))); + } + } + Err(e) => { + self.messages.push(ChatMessage::System(format!( + "Failed to save credential: {}", + e + ))); + } + } + } + Err(e) => { + self.messages.push(ChatMessage::System(format!( + "Failed to open credential store: {}", + e + ))); + } + }, + Err(e) => { + self.messages.push(ChatMessage::System(e)); + } + }, + _ => { + self.messages.push(ChatMessage::System( + "Usage: /login ".to_string(), + )); + } + } + } + "/logout" => match parts.get(1) { + Some(p) => match p.parse::() { + Ok(provider) => match self.open_credential_store() { + Ok(mut store) => match store.remove(&provider.to_string()) { + Ok(true) => { + self.messages.push(ChatMessage::System(format!( + "Removed stored credential for {}.", + provider + ))); + } + Ok(false) => { + self.messages.push(ChatMessage::System(format!( + "No stored credential for {}.", + provider + ))); + } + Err(e) => { + self.messages.push(ChatMessage::System(format!( + "Failed to remove credential: {}", + e + ))); + } + }, + Err(e) => { + self.messages.push(ChatMessage::System(format!( + "Failed to open credential store: {}", + e + ))); + } + }, + Err(e) => { + self.messages.push(ChatMessage::System(e)); + } + }, + None => { + self.messages + .push(ChatMessage::System("Usage: /logout ".to_string())); + } + }, + "/providers" => { + let store = self.open_credential_store().ok(); + let with_keys: std::collections::HashSet<&str> = store + .as_ref() + .map(|s| s.providers_with_keys().collect()) + .unwrap_or_default(); + let mut msg = String::from("Provider credentials:"); + for provider in Provider::ALL { + let name = provider.to_string(); + let stored = with_keys.contains(name.as_str()); + let has_env = std::env::var(provider.default_key_env()) + .ok() + .filter(|k| !k.is_empty()) + .is_some(); + let status = if provider.is_local() { + "local, no credential needed" + } else if stored { + "stored" + } else if has_env { + "env var set" + } else { + "missing" + }; + msg.push_str(&format!("\n {:<12} {}", name, status)); + } + self.messages.push(ChatMessage::System(msg)); + } + "/install-stellar-build" => { + if parts.get(1).copied() == Some("confirm") { + if user_tx.send(UserCommand::InstallStellarBuild).is_err() { + self.messages.push(ChatMessage::System( + "The agent is no longer running, so Stellar Build cannot be \ + installed. Restart Procyon." + .to_string(), + )); + } + } else { + // Downloads and runs a shell script on the user's machine: this is not + // something to do on a bare `/install-stellar-build`, only once they've seen + // exactly what that means and typed the command again to mean it. + self.messages.push(ChatMessage::System(format!( + "This downloads and runs a shell script from a third party (not \ + maintained by Procyon):\n {}\n\nIt installs the Stellar Build persona \ + pack (Justin, Nicole, Kaan, Tyler, Elliot, Bri) that `talk_to` and \ + `party_mode` use. Unix/macOS only.\n\nRun `/install-stellar-build \ + confirm` to proceed.", + crate::channels::STELLAR_BUILD_INSTALL_URL + ))); + } + } _ => { self.messages.push(ChatMessage::System(format!( "Unknown command: {}. Type /help for available commands.", @@ -866,4 +1316,535 @@ mod tests { let msg = last_system_message(&state); assert!(msg.contains("Unknown provider"), "got: {}", msg); } + + // --- Autocomplete tests --- + + #[test] + fn typing_slash_activates_autocomplete() { + let mut state = AppState::new(); + let (_tx, _rx) = mpsc::unbounded_channel::(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + + assert!(state.autocomplete_active); + assert!(!state.autocomplete_matches.is_empty()); + } + + #[test] + fn autocomplete_filters_by_prefix() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "he", &tx); + + let matching: Vec<_> = state + .autocomplete_matches + .iter() + .map(|item| item.value.as_str()) + .collect(); + assert_eq!(matching, vec!["/help"]); + } + + #[test] + fn autocomplete_filters_model_subcommands() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "model", &tx); + + let matching: Vec<_> = state + .autocomplete_matches + .iter() + .map(|item| item.value.as_str()) + .collect(); + assert!( + matching.contains(&"/model"), + "expected /model in matches, got: {:?}", + matching + ); + assert!( + matching.contains(&"/model set"), + "expected /model set in matches, got: {:?}", + matching + ); + } + + // A space used to always close the popup, so nothing ever suggested provider or model names + // for `/login`, `/logout`, `/model provider`, `/model model` or `/model set`'s arguments. + #[test] + fn a_trailing_space_suggests_the_next_argument_instead_of_closing() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + type_str_with_tx(&mut state, "/model ", &tx); + + assert!(state.autocomplete_active); + let matching: Vec<_> = state + .autocomplete_matches + .iter() + .map(|item| item.value.as_str()) + .collect(); + assert_eq!(matching, vec!["status", "set", "provider", "model"]); + } + + #[test] + fn model_provider_suggests_provider_names() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + type_str_with_tx(&mut state, "/model provider anth", &tx); + + let matching: Vec<_> = state + .autocomplete_matches + .iter() + .map(|item| item.value.as_str()) + .collect(); + assert_eq!(matching, vec!["anthropic"]); + } + + #[test] + fn login_suggests_provider_names_with_local_ones_flagged() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + type_str_with_tx(&mut state, "/login oll", &tx); + + let item = &state.autocomplete_matches[0]; + assert_eq!(item.value, "ollama"); + assert_eq!(item.description, "local, no credential needed"); + } + + #[test] + fn logout_suggests_provider_names() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + type_str_with_tx(&mut state, "/logout xa", &tx); + + let matching: Vec<_> = state + .autocomplete_matches + .iter() + .map(|item| item.value.as_str()) + .collect(); + assert_eq!(matching, vec!["xai"]); + } + + #[test] + fn model_model_suggests_models_for_the_active_provider() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + type_str_with_tx(&mut state, "/model model claude-", &tx); + + let matching: Vec<_> = state + .autocomplete_matches + .iter() + .map(|item| item.value.as_str()) + .collect(); + assert_eq!( + matching, + vec!["claude-sonnet-5", "claude-opus-5", "claude-haiku"] + ); + } + + #[test] + fn model_set_suggests_models_once_a_provider_is_typed() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + type_str_with_tx(&mut state, "/model set groq ", &tx); + + let matching: Vec<_> = state + .autocomplete_matches + .iter() + .map(|item| item.value.as_str()) + .collect(); + assert_eq!( + matching, + vec![ + "llama-3.3-70b-versatile", + "llama-3.1-8b-instant", + "mixtral-8x7b-32768" + ] + ); + } + + // The key is a secret, never a suggestion source. + #[test] + fn login_offers_no_suggestions_for_the_key_itself() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + type_str_with_tx(&mut state, "/login anthropic ", &tx); + + assert!(!state.autocomplete_active); + assert!(state.autocomplete_matches.is_empty()); + } + + // Once the typed text exactly matches the highlighted suggestion, Enter used to "accept" it — + // a no-op that left the command sitting in the input box requiring a second Enter to run. + #[test] + fn enter_submits_once_the_typed_argument_exactly_matches_the_suggestion() { + let mut state = AppState::new(); + let mut rx = submit(&mut state, "/model provider ollama"); + + assert!( + state.input.is_empty(), + "Enter should have submitted, not just accepted in place" + ); + match rx.try_recv() { + Ok(UserCommand::SwitchModel { provider, .. }) => { + assert_eq!(provider, Provider::Ollama); + } + other => panic!("expected the command to actually run, got {:?}", other), + } + } + + #[test] + fn backspacing_out_of_a_dead_end_revives_suggestions() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + // The key portion offers nothing (see above), so autocomplete closes here... + type_str_with_tx(&mut state, "/login anthropic k", &tx); + assert!(!state.autocomplete_active); + + // ...but deleting back into the provider name (dropping " k") should bring suggestions + // back rather than requiring the whole line to be retyped from a fresh "/". + press(&mut state, KeyCode::Backspace, KeyModifiers::NONE); + press(&mut state, KeyCode::Backspace, KeyModifiers::NONE); + assert_eq!(state.input, "/login anthropic"); + assert!(state.autocomplete_active); + let matching: Vec<_> = state + .autocomplete_matches + .iter() + .map(|item| item.value.as_str()) + .collect(); + assert_eq!(matching, vec!["anthropic"]); + } + + #[test] + fn tab_cycles_through_matches() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "m", &tx); + + assert_eq!(state.autocomplete_selected, 0); + press(&mut state, KeyCode::Tab, KeyModifiers::NONE); + assert_eq!(state.autocomplete_selected, 1); + press(&mut state, KeyCode::Tab, KeyModifiers::NONE); + assert_eq!(state.autocomplete_selected, 2); + } + + #[test] + fn tab_wraps_around() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "m", &tx); + + let count = state.autocomplete_matches.len(); + for _ in 0..count { + press(&mut state, KeyCode::Tab, KeyModifiers::NONE); + } + assert_eq!(state.autocomplete_selected, 0); + } + + #[test] + fn shift_tab_cycles_backwards() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "m", &tx); + + let count = state.autocomplete_matches.len(); + press(&mut state, KeyCode::BackTab, KeyModifiers::SHIFT); + assert_eq!(state.autocomplete_selected, count - 1); + } + + // The arrow keys reach the popup instead of the chat scroll while it is open — and go back to + // scrolling the chat once it closes. + #[test] + fn arrow_keys_navigate_the_popup() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "m", &tx); + let count = state.autocomplete_matches.len(); + assert!(count > 1); + + press(&mut state, KeyCode::Down, KeyModifiers::NONE); + assert_eq!(state.autocomplete_selected, 1); + + press(&mut state, KeyCode::Up, KeyModifiers::NONE); + assert_eq!(state.autocomplete_selected, 0); + + // Wraps to the last entry rather than sticking at the top. + press(&mut state, KeyCode::Up, KeyModifiers::NONE); + assert_eq!(state.autocomplete_selected, count - 1); + } + + #[test] + fn arrows_scroll_the_chat_once_the_popup_is_closed() { + let mut state = AppState::new(); + state.scroll_forward(5); + press(&mut state, KeyCode::Up, KeyModifiers::NONE); + assert_eq!(state.chat_scroll, 4); + } + + #[test] + fn enter_accepts_autocomplete() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "he", &tx); + press(&mut state, KeyCode::Enter, KeyModifiers::NONE); + + assert_eq!(state.input, "/help"); + assert!(!state.autocomplete_active); + } + + #[test] + fn escape_cancels_autocomplete() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "he", &tx); + press(&mut state, KeyCode::Esc, KeyModifiers::NONE); + + assert_eq!(state.input, "/"); + assert!(!state.autocomplete_active); + } + + #[test] + fn enter_submits_full_command_even_with_autocomplete_active() { + let mut state = AppState::new(); + let (_tx, _rx) = mpsc::unbounded_channel::(); + submit(&mut state, "/help"); + + let msg = last_system_message(&state); + assert!(msg.contains("Available commands"), "got: {}", msg); + } + + #[test] + fn backspace_deactivates_autocomplete_when_not_slash_prefix() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "h", &tx); + assert!(state.autocomplete_active); + + // Backspace removes "h", input becomes "/" - still a valid prefix + press(&mut state, KeyCode::Backspace, KeyModifiers::NONE); + assert!(state.autocomplete_active); + assert_eq!(state.input, "/"); + } + + #[test] + fn space_deactivates_autocomplete() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "help", &tx); + assert!(state.autocomplete_active); + + press(&mut state, KeyCode::Char(' '), KeyModifiers::NONE); + assert!(!state.autocomplete_active); + } + + #[test] + fn no_matches_deactivates_autocomplete() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel(); + press(&mut state, KeyCode::Char('/'), KeyModifiers::NONE); + type_str_with_tx(&mut state, "xyz", &tx); + + assert!(!state.autocomplete_active); + assert!(state.autocomplete_matches.is_empty()); + } + + #[test] + fn enter_without_autocomplete_submits_command() { + let mut state = AppState::new(); + let (tx, _rx) = mpsc::unbounded_channel::(); + type_str_with_tx(&mut state, "/status", &tx); + press(&mut state, KeyCode::Enter, KeyModifiers::NONE); + + let msg = last_system_message(&state); + assert!(msg.contains("Status: Ready"), "got: {}", msg); + } + + fn type_str_with_tx(state: &mut AppState, text: &str, tx: &mpsc::UnboundedSender) { + for c in text.chars() { + let modifiers = if c.is_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }; + state.handle_key(KeyEvent::new(KeyCode::Char(c), modifiers), tx); + } + } + + // --- /login, /logout, /providers --- + + // A tempdir keeps these tests off the real `~/.config/procyon/credentials.toml`. + fn state_with_tempdir() -> (AppState, tempfile::TempDir) { + let temp = tempfile::tempdir().unwrap(); + let mut state = AppState::new(); + state.credentials_path = Some(temp.path().join("credentials.toml")); + (state, temp) + } + + #[test] + fn login_stores_a_key_retrievable_afterward() { + let (mut state, temp) = state_with_tempdir(); + submit(&mut state, "/login groq gsk-secret"); + + let store = crate::credentials::CredentialStore::load(temp.path().join("credentials.toml")) + .unwrap(); + assert_eq!(store.get("groq"), Some("gsk-secret")); + } + + // A key split across more than one whitespace token used to be silently truncated to the + // first token alone. + #[test] + fn login_keeps_a_key_containing_internal_whitespace() { + let (mut state, temp) = state_with_tempdir(); + submit(&mut state, "/login groq gsk part-two"); + + let store = crate::credentials::CredentialStore::load(temp.path().join("credentials.toml")) + .unwrap(); + assert_eq!(store.get("groq"), Some("gsk part-two")); + } + + #[test] + fn login_overwriting_the_active_provider_triggers_a_live_switch() { + let (mut state, _temp) = state_with_tempdir(); + let active_model = state.active_model.clone(); + let mut rx = submit(&mut state, "/login anthropic sk-ant-secret"); + + match rx.try_recv() { + Ok(UserCommand::SwitchModel { provider, model }) => { + assert_eq!(provider, Provider::Anthropic); + assert_eq!(model, active_model); + } + other => panic!("expected SwitchModel, got {:?}", other), + } + } + + #[test] + fn login_for_an_inactive_provider_just_confirms_and_suggests_the_switch() { + let (mut state, _temp) = state_with_tempdir(); + let mut rx = submit(&mut state, "/login groq gsk-secret"); + assert!(rx.try_recv().is_err(), "should not have asked to switch"); + + let msg = last_system_message(&state); + assert!(msg.contains("/model provider groq"), "got: {}", msg); + } + + #[test] + fn login_with_an_invalid_provider_reports_an_error_and_does_not_crash() { + let (mut state, _temp) = state_with_tempdir(); + submit(&mut state, "/login fakeprovider somekey"); + let msg = last_system_message(&state); + assert!(msg.contains("Unknown provider"), "got: {}", msg); + } + + #[test] + fn login_never_leaks_the_raw_key_into_messages() { + let (mut state, _temp) = state_with_tempdir(); + submit(&mut state, "/login groq super-secret-key"); + + for message in &state.messages { + let text = match message { + ChatMessage::User(t) | ChatMessage::Agent(t) | ChatMessage::System(t) => t, + }; + assert!( + !text.contains("super-secret-key"), + "the raw key leaked into a message: {}", + text + ); + } + } + + #[test] + fn login_requires_both_a_provider_and_a_key() { + let (mut state, _temp) = state_with_tempdir(); + submit(&mut state, "/login groq"); + let msg = last_system_message(&state); + assert!(msg.contains("Usage: /login"), "got: {}", msg); + } + + #[test] + fn logout_removes_a_known_providers_credential() { + let (mut state, temp) = state_with_tempdir(); + submit(&mut state, "/login groq gsk-1"); + submit(&mut state, "/logout groq"); + + let msg = last_system_message(&state); + assert!(msg.contains("Removed stored credential"), "got: {}", msg); + + let store = crate::credentials::CredentialStore::load(temp.path().join("credentials.toml")) + .unwrap(); + assert_eq!(store.get("groq"), None); + } + + #[test] + fn logout_reports_when_there_is_nothing_to_remove() { + let (mut state, _temp) = state_with_tempdir(); + submit(&mut state, "/logout groq"); + let msg = last_system_message(&state); + assert!(msg.contains("No stored credential"), "got: {}", msg); + } + + #[test] + fn providers_lists_every_named_provider_and_flags_local_ones() { + let (mut state, _temp) = state_with_tempdir(); + submit(&mut state, "/providers"); + let msg = last_system_message(&state); + + for provider in Provider::ALL { + assert!( + msg.contains(&provider.to_string()), + "{} missing from: {}", + provider, + msg + ); + } + assert!(msg.contains("local, no credential needed"), "got: {}", msg); + } + + #[test] + fn providers_reflects_a_credential_saved_through_login() { + let (mut state, _temp) = state_with_tempdir(); + submit(&mut state, "/login groq gsk-1"); + submit(&mut state, "/providers"); + let msg = last_system_message(&state); + + let line = msg + .lines() + .find(|line| line.trim_start().starts_with("groq")) + .unwrap_or_else(|| panic!("no groq line in: {}", msg)); + assert!(line.contains("stored"), "got: {}", line); + } + + // Downloading and running a shell script on the user's machine is not something a bare + // `/install-stellar-build` should ever trigger — only a second, explicit `confirm`. + #[test] + fn install_stellar_build_without_confirm_explains_but_does_not_run_anything() { + let mut state = AppState::new(); + let mut rx = submit(&mut state, "/install-stellar-build"); + + let msg = last_system_message(&state); + assert!( + msg.contains(crate::channels::STELLAR_BUILD_INSTALL_URL), + "got: {}", + msg + ); + assert!(msg.contains("confirm"), "got: {}", msg); + assert!(rx.try_recv().is_err(), "should not have sent anything yet"); + } + + #[test] + fn install_stellar_build_confirm_sends_the_install_command() { + let mut state = AppState::new(); + let mut rx = submit(&mut state, "/install-stellar-build confirm"); + + match rx.try_recv() { + Ok(UserCommand::InstallStellarBuild) => {} + other => panic!("expected InstallStellarBuild, got {:?}", other), + } + } } diff --git a/src/budget.rs b/src/budget.rs index 7af291b..9b09f79 100644 --- a/src/budget.rs +++ b/src/budget.rs @@ -1,4 +1,5 @@ use crate::agent::{ContentPart, Message, ToolDefinition}; +use crate::config::Provider; // Deliberately crude: the estimate is only ever used as a *delta* on top of an anchor taken from // the provider's own usage numbers, so it does not need to be accurate in absolute terms. @@ -12,11 +13,28 @@ const ROLE_OVERHEAD: usize = 4; // locally served open weights, and those are routinely started with 8k-32k of context. pub const CONTEXT_WINDOW: usize = 32_000; -/// The context window to budget against for a model id. +// Confirmed against a live server (Ollama 0.20.4): a request through `/v1/chat/completions` — +// the only endpoint this build speaks to Ollama over — that asked for `options.num_ctx: 16384` +// still loaded a 4,096-cell KV cache and logged `truncating input prompt limit=4096`. The OpenAI +// compatibility shim does not forward that field at all, so no request over it can ever get more +// than Ollama's own built-in default, no matter what the loaded weights actually support or what +// this build asks for. That makes the ceiling a property of the transport, not of the model +// picked — the old table entries for "llama"/"qwen"/"mistral" (32k–128k) described what the +// weights could do, not what a request could ever obtain, so a long system prompt plus this +// build's full tool set (which alone were measured at 4k-7k tokens) was silently cut down with no +// error surfaced anywhere, and the model answered from whatever fragment survived. +const OLLAMA_CONTEXT_WINDOW: usize = 4_096; + +/// The context window to budget against for a provider/model pair. /// -/// Matched on substrings because provider ids carry suffixes (dates, `-latest`, an OpenRouter -/// `vendor/` prefix) that a table of exact names would miss. -pub fn context_window(model: &str) -> usize { +/// Takes the provider rather than trusting the model name alone: the same weights served by +/// Ollama, LM Studio, or a hosted API can have completely different *usable* windows, since that +/// depends on how the serving stack handles the request, not on the model id in it. +pub fn context_window(provider: Provider, model: &str) -> usize { + if provider == Provider::Ollama { + return OLLAMA_CONTEXT_WINDOW; + } + let model = model.to_lowercase(); const TABLE: &[(&str, usize)] = &[ @@ -40,9 +58,9 @@ pub fn context_window(model: &str) -> usize { ("gemini-3", 1_048_576), ("gemini-2.5", 1_048_576), ("gemini", 1_048_576), - // Open weights commonly served locally. Held at what a default local server actually - // allocates rather than what the weights support: a runtime that was started with a - // smaller window rejects the request, and there is no way to read the real one from here. + // Open weights, served by something other than Ollama (LM Studio, a hosted endpoint): + // unlike Ollama's OpenAI shim, these are assumed to honor the context length the operator + // actually configured, so the weights' own advertised window is used as given. ("qwen", 32_768), ("llama-3.3", 128_000), ("llama", 32_768), @@ -464,19 +482,27 @@ mod tests { fn an_unrecognized_model_gets_a_conservative_window() { // Stated against a literal rather than against the constant itself, which would be an // assertion the compiler can fold away to `true`. - assert_eq!(context_window("some-local-thing-v2"), 32_000); + assert_eq!( + context_window(Provider::LmStudio, "some-local-thing-v2"), + 32_000 + ); } #[test] fn newer_ids_are_recognized_rather_than_falling_back() { - for (model, window) in [ - ("gpt-5", 400_000), - ("gpt-5-mini-2025-08-07", 400_000), - ("o1-preview", 200_000), - ("gemini-3-pro", 1_048_576), - ("openrouter/qwen3-32b", 32_768), + for (provider, model, window) in [ + (Provider::OpenAi, "gpt-5", 400_000), + (Provider::OpenAi, "gpt-5-mini-2025-08-07", 400_000), + (Provider::OpenAi, "o1-preview", 200_000), + (Provider::OpenAiCompatible, "gemini-3-pro", 1_048_576), + (Provider::Openrouter, "openrouter/qwen3-32b", 32_768), ] { - assert_eq!(context_window(model), window, "model was {}", model); + assert_eq!( + context_window(provider, model), + window, + "model was {}", + model + ); } } @@ -484,8 +510,17 @@ mod tests { // would silently hand one the other's window. #[test] fn a_longer_id_is_not_shadowed_by_a_shorter_one() { - assert_eq!(context_window("gpt-4.1-mini"), 1_047_576); - assert_eq!(context_window("llama-3.3-70b"), 128_000); + assert_eq!(context_window(Provider::OpenAi, "gpt-4.1-mini"), 1_047_576); + assert_eq!(context_window(Provider::LmStudio, "llama-3.3-70b"), 128_000); + } + + // Verified against a live Ollama 0.20.4 server: `options.num_ctx` in the request body is not + // honored over `/v1/chat/completions`, so every model served through it is capped at Ollama's + // own default no matter how large the weights' real window is. + #[test] + fn ollama_is_capped_regardless_of_the_model_name() { + assert_eq!(context_window(Provider::Ollama, "llama-3.3-70b"), 4_096); + assert_eq!(context_window(Provider::Ollama, "qwen2.5-coder:32b"), 4_096); } #[test] diff --git a/src/channels.rs b/src/channels.rs index 984262c..7cf6bf7 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -1,6 +1,12 @@ use crate::config::Provider; use tokio::sync::mpsc; +/// Third-party persona pack (Justin, Nicole, Kaan, Tyler, Elliot, Bri) — not authored or hosted by +/// this project. Named here, rather than only where it's fetched, so the UI's confirmation prompt +/// and the agent task's download both show the same URL by construction. +pub const STELLAR_BUILD_INSTALL_URL: &str = + "https://raw.githubusercontent.com/kaankacar/stellar-build/main/install.sh"; + #[derive(Debug)] pub enum UserCommand { SendPrompt(String), @@ -13,6 +19,10 @@ pub enum UserCommand { provider: Provider, model: String, }, + /// Runs the third-party Stellar Build installer after the user has explicitly confirmed it. + /// Routed through the agent task rather than handled in the UI thread: it downloads a script + /// and runs it, which can take a while and must not freeze rendering or key handling. + InstallStellarBuild, } #[derive(Debug)] diff --git a/src/config.rs b/src/config.rs index 71bb0d7..2884cd6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,8 @@ use color_eyre::{eyre::bail, eyre::WrapErr, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use crate::credentials::CredentialStore; + // Must stay lowercase to match `Display`/`FromStr`, which is the spelling the config file and // the `/theme` command use. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -90,7 +92,7 @@ impl Provider { } /// The variable consulted when the config names no `api_key_env` of its own. - fn default_key_env(&self) -> &'static str { + pub fn default_key_env(&self) -> &'static str { match self { Provider::Anthropic => "ANTHROPIC_API_KEY", other => other @@ -99,6 +101,27 @@ impl Provider { .unwrap_or("OPENAI_API_KEY"), } } + + /// Served from this machine, so nothing checks a credential for it. + pub fn is_local(&self) -> bool { + matches!(self, Provider::Ollama | Provider::LmStudio) + } + + /// Every named provider, for pickers that need to enumerate them (the wizard's provider step, + /// `/providers`). Excludes `OpenAiCompatible`, which needs a `base_url` a list cannot supply. + pub const ALL: [Provider; 11] = [ + Provider::Anthropic, + Provider::OpenAi, + Provider::Deepseek, + Provider::Groq, + Provider::Openrouter, + Provider::Cerebras, + Provider::Fireworks, + Provider::Togetherai, + Provider::Xai, + Provider::Ollama, + Provider::LmStudio, + ]; } impl std::fmt::Display for Provider { @@ -306,8 +329,6 @@ impl AppConfig { Ok(config) } - // Called by the onboarding wizard (Sprint 4.1), which is what will first write a config. - #[allow(dead_code)] pub fn save(&self) -> Result<()> { let config_dir = Self::config_dir()?; std::fs::create_dir_all(&config_dir)?; @@ -320,7 +341,6 @@ impl AppConfig { Ok(()) } - #[allow(dead_code)] pub fn is_first_run() -> bool { match Self::config_path() { Ok(path) => !path.exists(), @@ -348,6 +368,16 @@ impl AppConfig { } pub fn get_api_key(&self) -> Result { + // The real store, resolved fresh each call: `/login` and `/logout` may have edited the + // file since the last time a key was needed. A store that fails to load (e.g. malformed + // TOML) is treated as absent rather than aborting the whole lookup — the env var and local + // bypass below still deserve a chance. + self.get_api_key_with_store(CredentialStore::load_default().ok().as_ref()) + } + + /// Split out so tests can hand in a `CredentialStore` pointed at a tempdir instead of the real + /// `~/.config/procyon/credentials.toml`. + fn get_api_key_with_store(&self, store: Option<&CredentialStore>) -> Result { // Retained for configs written before `provider` existed, where the inline key could only // ever have been an Anthropic one. if self.provider.is_anthropic() { @@ -356,6 +386,13 @@ impl AppConfig { } } + if let Some(key) = store + .and_then(|s| s.get(&self.provider.to_string())) + .filter(|k| !k.is_empty()) + { + return Ok(key.to_string()); + } + let var = self.key_env_var(); if let Some(key) = std::env::var(var).ok().filter(|k| !k.is_empty()) { return Ok(key); @@ -366,10 +403,12 @@ impl AppConfig { } bail!( - "No API key found for provider {:?}. Set {} in the environment, or point `api_key_env` \ - at the variable that holds it in ~/.config/procyon/config.toml", + "No API key found for provider {:?}. Set {} in the environment, run `/login {} ` \ + inside procyon, or point `api_key_env` at the variable that holds it in \ + ~/.config/procyon/config.toml", self.provider, - var + var, + self.provider ) } @@ -500,6 +539,38 @@ mod tests { .to_string(); assert!(err.contains("GROQ_API_KEY"), "{}", err); + // The error has to name the in-app fix, not just the environment-variable one. + assert!(err.contains("/login"), "{}", err); + } + + // `/login` writes here, so a stored key has to reach `get_api_key` before the config is asked + // to fall back to an environment variable that may not exist at all. + #[test] + fn a_stored_credential_is_used_before_the_env_var() { + let temp = tempfile::tempdir().unwrap(); + let mut store = + crate::credentials::CredentialStore::load(temp.path().join("credentials.toml")) + .unwrap(); + store.set("groq", "stored-key".to_string()).unwrap(); + + let config = with_provider(Provider::Groq); + assert_eq!( + config.get_api_key_with_store(Some(&store)).unwrap(), + "stored-key" + ); + } + + #[test] + fn no_stored_credential_falls_through_to_the_env_var_path() { + let temp = tempfile::tempdir().unwrap(); + let store = crate::credentials::CredentialStore::load(temp.path().join("credentials.toml")) + .unwrap(); + + let err = with_provider(Provider::Groq) + .get_api_key_with_store(Some(&store)) + .unwrap_err() + .to_string(); + assert!(err.contains("GROQ_API_KEY"), "{}", err); } fn parse(theme_value: &str) -> Result { diff --git a/src/credentials.rs b/src/credentials.rs new file mode 100644 index 0000000..36b3846 --- /dev/null +++ b/src/credentials.rs @@ -0,0 +1,170 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use color_eyre::{eyre::WrapErr, Result}; +use serde::{Deserialize, Serialize}; + +use crate::config::AppConfig; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CredentialEntry { + api_key: String, +} + +// Same 0600 rationale as `config.rs::restrict_to_owner`: this file holds live API keys, not just +// a reference to where they live. +#[cfg(unix)] +fn restrict_to_owner(path: &std::path::Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn restrict_to_owner(_path: &std::path::Path) -> Result<()> { + Ok(()) +} + +/// API keys entered through `/login`, kept out of `config.toml` so the config file stays safe to +/// share or check in. Keyed by the same lowercase string `Provider`'s `Display` produces. +pub struct CredentialStore { + path: PathBuf, + entries: BTreeMap, +} + +impl CredentialStore { + pub fn load(path: PathBuf) -> Result { + let entries = if path.exists() { + let content = std::fs::read_to_string(&path) + .wrap_err_with(|| format!("Reading credentials at {}", path.display()))?; + toml::from_str(&content) + .wrap_err_with(|| format!("Invalid credentials at {}", path.display()))? + } else { + BTreeMap::new() + }; + Ok(Self { path, entries }) + } + + /// The path real (non-test) callers use. + pub fn load_default() -> Result { + Self::load(AppConfig::config_dir()?.join("credentials.toml")) + } + + pub fn get(&self, provider: &str) -> Option<&str> { + self.entries.get(provider).map(|e| e.api_key.as_str()) + } + + pub fn set(&mut self, provider: &str, key: String) -> Result<()> { + self.entries + .insert(provider.to_string(), CredentialEntry { api_key: key }); + self.persist() + } + + /// Returns whether an entry actually existed to remove. + pub fn remove(&mut self, provider: &str) -> Result { + let existed = self.entries.remove(provider).is_some(); + if existed { + self.persist()?; + } + Ok(existed) + } + + pub fn providers_with_keys(&self) -> impl Iterator { + self.entries.keys().map(|s| s.as_str()) + } + + fn persist(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(&self.entries)?; + let tmp = self.path.with_extension("toml.tmp"); + std::fs::write(&tmp, &content)?; + restrict_to_owner(&tmp)?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store_at(dir: &std::path::Path) -> CredentialStore { + CredentialStore::load(dir.join("credentials.toml")).unwrap() + } + + #[test] + fn set_then_get_round_trips() { + let temp = tempfile::tempdir().unwrap(); + let mut store = store_at(temp.path()); + + store.set("anthropic", "sk-ant-123".to_string()).unwrap(); + + assert_eq!(store.get("anthropic"), Some("sk-ant-123")); + } + + #[test] + fn a_missing_file_behaves_as_an_empty_store() { + let temp = tempfile::tempdir().unwrap(); + let store = store_at(temp.path()); + + assert_eq!(store.get("anthropic"), None); + assert_eq!(store.providers_with_keys().count(), 0); + } + + #[test] + #[cfg(unix)] + fn a_write_leaves_the_file_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let mut store = store_at(temp.path()); + store.set("groq", "gsk-1".to_string()).unwrap(); + + let path = temp.path().join("credentials.toml"); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn removing_an_unknown_provider_reports_no_entry() { + let temp = tempfile::tempdir().unwrap(); + let mut store = store_at(temp.path()); + + assert!(!store.remove("openai").unwrap()); + } + + #[test] + fn removing_a_known_provider_removes_it_and_says_so() { + let temp = tempfile::tempdir().unwrap(); + let mut store = store_at(temp.path()); + store.set("openai", "sk-1".to_string()).unwrap(); + + assert!(store.remove("openai").unwrap()); + assert_eq!(store.get("openai"), None); + } + + #[test] + fn setting_an_existing_provider_again_overwrites_rather_than_duplicating() { + let temp = tempfile::tempdir().unwrap(); + let mut store = store_at(temp.path()); + + store.set("xai", "old-key".to_string()).unwrap(); + store.set("xai", "new-key".to_string()).unwrap(); + + assert_eq!(store.get("xai"), Some("new-key")); + assert_eq!(store.providers_with_keys().count(), 1); + } + + #[test] + fn a_stored_key_survives_a_reload_from_disk() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("credentials.toml"); + let mut store = CredentialStore::load(path.clone()).unwrap(); + store.set("deepseek", "dsk-1".to_string()).unwrap(); + + let reloaded = CredentialStore::load(path).unwrap(); + assert_eq!(reloaded.get("deepseek"), Some("dsk-1")); + } +} diff --git a/src/main.rs b/src/main.rs index 697797b..4133956 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod budget; mod channels; mod config; mod context; +mod credentials; mod diag; mod llm; mod mcp; @@ -20,12 +21,21 @@ mod skills; mod sse; mod tools; mod ui; +mod wizard; use color_eyre::Result; +use crossterm::cursor::SetCursorStyle; use crossterm::event::{self, Event}; use ratatui::DefaultTerminal; use tokio::sync::mpsc; +// A thin steady bar reads better against a text input than the terminal's default block, which +// buries the character it sits on. Reset on the way out so a user's own terminal preference isn't +// left overridden after Procyon quits. +fn set_cursor_style(style: SetCursorStyle) { + let _ = crossterm::execute!(std::io::stdout(), style); +} + const USAGE: &str = "\ procyon - development harness for Stellar and Soroban @@ -127,10 +137,28 @@ fn main() -> Result<()> { // instead of an alternate screen that is about to be torn down. let cfg = config::AppConfig::load()?; + // On first run, offer the onboarding wizard before the main TUI. + let cfg = if config::AppConfig::is_first_run() { + let mut terminal = ratatui::init(); + set_cursor_style(SetCursorStyle::SteadyBar); + let result = wizard::run_wizard(&mut terminal); + set_cursor_style(SetCursorStyle::DefaultUserShape); + ratatui::restore(); + + match result? { + Some(wizard_cfg) => wizard_cfg, + None => cfg, // User skipped; proceed with defaults. + } + } else { + cfg + }; + let resume_from = tokio::runtime::Runtime::new()?.block_on(resolve_startup(startup, &cfg))?; let mut terminal = ratatui::init(); + set_cursor_style(SetCursorStyle::SteadyBar); let result = run(&mut terminal, cfg, resume_from); + set_cursor_style(SetCursorStyle::DefaultUserShape); ratatui::restore(); result } @@ -194,7 +222,7 @@ async fn agent_task( ) { // The window depends on which model the config selected, so it is resolved once here rather // than read from a constant at each check. - let mut context_window = budget::context_window(&cfg.default_model); + let mut context_window = budget::context_window(cfg.provider, &cfg.default_model); // A missing credential used to end the task here. That took the command channel down with it, // so every later `/model` reached a dropped receiver while the UI — which discards send // failures — kept reporting switches that never happened. The one recovery the user has is the @@ -431,6 +459,8 @@ async fn agent_task( // Once compaction has reported that it cannot shrink this history, retrying it on // every tool round trip buys nothing and costs a summarization request each time. let mut compaction_stalled = false; + // Warned once per turn rather than on every round trip once it stays true. + let mut truncation_risk_warned = false; // The window the prompt may occupy, with room for the reply the provider will // count against the same window. @@ -438,13 +468,11 @@ async fn agent_task( loop { let system = build_system_prompt(workspace.as_deref(), explain); + let turn_tools = tools_for_provider(cfg.provider, &tool_defs_with_spawn); // The system prompt is rebuilt per turn and the tool block carries every MCP // server's schemas, so neither is a constant the threshold can ignore. - budget.set_envelope(budget::price_envelope( - system.as_deref(), - &tool_defs_with_spawn, - )); + budget.set_envelope(budget::price_envelope(system.as_deref(), &turn_tools)); // Checked before the request goes out, so pressure is relieved instead of // being discovered as an API error. @@ -454,7 +482,7 @@ async fn agent_task( &mut history, &mut budget, system.as_deref(), - &tool_defs_with_spawn, + &turn_tools, budget::retain_tokens(prompt_window), &agent_tx, &mut log, @@ -463,6 +491,29 @@ async fn agent_task( compaction_stalled = !shrank; } + // Every other provider rejects an over-budget request with an error the retry + // below reacts to. Ollama's OpenAI-compatible endpoint does neither — verified + // against a live 0.20.4 server, it silently truncates the prompt and answers + // HTTP 200 as if nothing were missing. Once compaction has nothing left to + // trim, that silent failure is the only way this turn can go wrong, so it is + // said out loud instead of showing up as a confidently wrong answer. Cutting + // the tool list to `OLLAMA_CORE_TOOLS` above keeps this from firing in the + // common case; it is left in for whatever still does not fit (a long + // conversation, an unusually large project context). + if !truncation_risk_warned + && matches!(cfg.provider, config::Provider::Ollama) + && budget.is_over_threshold(&history, prompt_window) + { + truncation_risk_warned = true; + let _ = agent_tx.send(channels::AgentUpdate::Status(format!( + "Warning: the system prompt and {} tool definitions already exceed \ + Ollama's context window with nothing left to trim. Ollama truncates \ + silently rather than erroring, so this response may be based on an \ + incomplete prompt.", + turn_tools.len() + ))); + } + // The request is about to leave the process; make sure what led to it is on // disk first. barrier(&mut log, &agent_tx).await; @@ -470,7 +521,7 @@ async fn agent_task( let outcome = match client .send_message_streaming( &history, - Some(&tool_defs_with_spawn), + Some(&turn_tools), system.as_deref(), &text_tx, ) @@ -494,7 +545,7 @@ async fn agent_task( &mut history, &mut budget, system.as_deref(), - &tool_defs_with_spawn, + &turn_tools, budget::retain_tokens(prompt_window), &agent_tx, &mut log, @@ -642,8 +693,18 @@ async fn agent_task( client = Some(new_client); cfg.provider = provider; cfg.default_model = model.clone(); - context_window = budget::context_window(&model); + context_window = budget::context_window(provider, &model); budget.invalidate(); + // Persisted so the next launch resumes on the provider/model actually in + // use, rather than silently reverting to whatever `config.toml` said + // before this switch. A failure to write is reported but not fatal: the + // live client this session already switched successfully. + if let Err(e) = cfg.save() { + let _ = agent_tx.send(channels::AgentUpdate::Error(format!( + "Switched, but failed to save it to config.toml: {}", + e + ))); + } // Switching to a local provider is the documented way out of a boot with // no credential, so this is what clears `NeedsCredential` in the header. let _ = agent_tx.send(channels::AgentUpdate::Ready { @@ -667,6 +728,72 @@ async fn agent_task( } } } + channels::UserCommand::InstallStellarBuild => { + // The installer is a shell script; there is no Windows equivalent to run it with, + // and pretending to try would just fail confusingly deep inside a spawned process. + if !cfg!(unix) { + let _ = agent_tx.send(channels::AgentUpdate::Error(format!( + "Stellar Build's installer is a shell script and only runs on Unix-like \ + systems. Install it manually from {}", + channels::STELLAR_BUILD_INSTALL_URL + ))); + continue; + } + + let _ = agent_tx.send(channels::AgentUpdate::Status(format!( + "Downloading {}", + channels::STELLAR_BUILD_INSTALL_URL + ))); + + let script = reqwest::Client::new() + .get(channels::STELLAR_BUILD_INSTALL_URL) + .send() + .await + .and_then(|r| r.error_for_status()); + + let script = match script { + Ok(resp) => resp.text().await, + Err(e) => Err(e), + }; + + let script = match script { + Ok(s) => s, + Err(e) => { + let _ = agent_tx.send(channels::AgentUpdate::Error(format!( + "Failed to download the Stellar Build installer: {}", + e + ))); + continue; + } + }; + + let _ = agent_tx.send(channels::AgentUpdate::Status( + "Running the Stellar Build installer...".to_string(), + )); + + match run_shell_script(&script).await { + Ok(output) if output.status.success() => { + let _ = agent_tx.send(channels::AgentUpdate::Status( + "Stellar Build installed. Restart Procyon to pick up the new \ + personas." + .to_string(), + )); + } + Ok(output) => { + let _ = agent_tx.send(channels::AgentUpdate::Error(format!( + "Stellar Build's installer exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Err(e) => { + let _ = agent_tx.send(channels::AgentUpdate::Error(format!( + "Failed to run the Stellar Build installer: {}", + e + ))); + } + } + } channels::UserCommand::ChangeProject(name) => { let _ = agent_tx.send(channels::AgentUpdate::Status(format!( "Project changed to: {}", @@ -793,6 +920,24 @@ const OVERFLOW_PHRASES: &[&str] = &[ // away history to fix a problem that waiting would have fixed. const OVERFLOW_EXCLUSIONS: &[&str] = &["rate limit", "too many requests", "service unavailable"]; +// Piped to `bash`'s stdin rather than written to a temp file and executed: the installer runs +// exactly once per confirmation, so there is nothing worth leaving on disk afterward. +async fn run_shell_script(script: &str) -> std::io::Result { + use tokio::io::AsyncWriteExt; + + let mut child = tokio::process::Command::new("bash") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(script.as_bytes()).await?; + } + + child.wait_with_output().await +} + fn is_context_overflow(error: &color_eyre::Report) -> bool { let text = error.to_string().to_lowercase(); @@ -944,6 +1089,46 @@ async fn barrier( } } +// The full registry's schemas alone were measured at ~4,358 estimated tokens for 30 tools — +// already past Ollama's 4,096-token ceiling (see `budget::context_window`) before a single +// message of conversation. This is the subset that keeps the core edit-build-deploy loop usable +// within that window; everything cut here (personas, party mode, spawn_agent, skills) is still +// reachable by switching to a provider with real headroom. +const OLLAMA_CORE_TOOLS: &[&str] = &[ + "list_dir", + "glob", + "grep", + "project_init", + "project_info", + "read_file", + "write_file", + "edit_file", + "caatinga_build", + "caatinga_deploy", + "caatinga_invoke", + "caatinga_read", + "caatinga_doctor", + "stellar_invoke", +]; + +/// The tool definitions to actually offer this turn. +/// +/// Computed per turn rather than once at boot: a live `/model provider ollama` switch has to +/// shrink what gets sent on the very next request, not only on a session that started that way. +fn tools_for_provider( + provider: config::Provider, + tools: &[agent::ToolDefinition], +) -> Vec { + if provider != config::Provider::Ollama { + return tools.to_vec(); + } + tools + .iter() + .filter(|t| OLLAMA_CORE_TOOLS.contains(&t.name.as_str())) + .cloned() + .collect() +} + // The explain instruction is appended rather than replacing the workspace prompt, so toggling it // keeps the environment description the model relies on. fn build_system_prompt(workspace: Option<&str>, explain: bool) -> Option { @@ -958,8 +1143,11 @@ fn build_system_prompt(workspace: Option<&str>, explain: bool) -> Option #[cfg(test)] mod tests { use super::build_system_prompt; + use super::tools_for_provider; use super::{parse_args, Startup}; + use crate::agent; + use crate::config; fn args(list: &[&str]) -> Startup { parse_args(list.iter().map(|s| s.to_string())) @@ -1021,6 +1209,39 @@ mod tests { fn no_workspace_and_no_explain_sends_no_system_prompt() { assert!(build_system_prompt(None, false).is_none()); } + + fn tool(name: &str) -> agent::ToolDefinition { + agent::ToolDefinition { + name: name.to_string(), + description: String::new(), + input_schema: serde_json::json!({}), + } + } + + #[test] + fn a_non_ollama_provider_gets_every_tool() { + let all = vec![tool("grep"), tool("spawn_agent"), tool("party_mode")]; + let kept = tools_for_provider(config::Provider::Anthropic, &all); + assert_eq!(kept.len(), all.len()); + } + + // Measured at ~4,358 estimated tokens for the full registry — already past the 4,096-token + // window Ollama enforces regardless of the model loaded, before any conversation at all. + #[test] + fn ollama_keeps_only_the_core_edit_build_deploy_tools() { + let all = vec![ + tool("grep"), + tool("read_file"), + tool("caatinga_deploy"), + tool("spawn_agent"), + tool("party_mode"), + tool("talk_to"), + tool("run_skill"), + ]; + let kept = tools_for_provider(config::Provider::Ollama, &all); + let names: Vec<_> = kept.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, vec!["grep", "read_file", "caatinga_deploy"]); + } } #[cfg(test)] diff --git a/src/ui.rs b/src/ui.rs index 574cc4e..8650fdf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use crate::app::{AppState, AppStatus, ChatMessage}; +use crate::app::{AppState, AppStatus, AutocompleteItem, ChatMessage}; use crate::config::Theme; pub struct ColorPalette { @@ -55,12 +55,20 @@ impl ColorPalette { pub fn render(frame: &mut Frame, state: &mut AppState, theme: &Theme) { let palette = ColorPalette::from_theme(theme); + // The popup is drawn inside the input chunk, so the chunk has to be tall enough for the input + // box plus every row the popup will use — otherwise the last suggestions are clipped away. + let input_height = if state.autocomplete_active && !state.autocomplete_matches.is_empty() { + 3 + autocomplete_popup_height(state.autocomplete_matches.len()) + } else { + 3u16 + }; + let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Min(0), - Constraint::Length(3), + Constraint::Length(input_height), ]) .split(frame.area()); @@ -68,7 +76,9 @@ pub fn render(frame: &mut Frame, state: &mut AppState, theme: &Theme) { let content_chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) + // The sidebar holds short labelled values, so it only needs enough width for the longest + // model name — the chat is what benefits from the extra columns. + .constraints([Constraint::Min(0), Constraint::Length(34)]) .split(main_chunks[1]); render_chat(frame, state, content_chunks[0], &palette); @@ -95,11 +105,6 @@ fn render_action_bar(frame: &mut Frame, area: Rect, palette: &ColorPalette) { Style::default().fg(palette.action_bar_fg).bg(Color::Yellow), ), Span::raw("Deploy "), - Span::styled( - " Ctrl+S ", - Style::default().fg(palette.action_bar_fg).bg(Color::Cyan), - ), - Span::raw("Save "), Span::styled( " /help ", Style::default() @@ -256,117 +261,84 @@ fn render_chat(frame: &mut Frame, state: &mut AppState, area: Rect, palette: &Co frame.render_widget(chat, area); } +/// Rows the session block needs: one per field, plus the two borders. +const SESSION_BLOCK_HEIGHT: u16 = SESSION_FIELDS + 2; +const SESSION_FIELDS: u16 = 7; + fn render_dashboard(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) { + // Status, Project and Account used to be three 8-row boxes for what is one line of content + // each, which pushed Help into the leftover half of the column. One block, one row per field. + // Help then takes whatever is left below it, rather than a fixed height with a bare gap under + // it — a sidebar with dead space at the bottom reads as content that failed to load. let dashboard_chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(8), - Constraint::Length(8), - Constraint::Length(8), - Constraint::Min(3), - ]) + .constraints([Constraint::Length(SESSION_BLOCK_HEIGHT), Constraint::Min(0)]) .split(area); - render_status_block(frame, state, dashboard_chunks[0], palette); - render_project_block(frame, state, dashboard_chunks[1], palette); - render_account_block(frame, state, dashboard_chunks[2], palette); - render_help_block(frame, dashboard_chunks[3], palette); + render_session_block(frame, state, dashboard_chunks[0], palette); + render_help_block(frame, dashboard_chunks[1], palette); } -fn render_status_block(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) { +fn render_session_block(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) { let status_color = match state.status { AppStatus::Ready => Color::Green, AppStatus::NeedsCredential => Color::Red, AppStatus::Working => Color::Yellow, }; - let status_block = Paragraph::new(vec![ - Line::from(vec![ - Span::styled("Status: ", Style::default().fg(palette.fg)), - Span::styled( - crate::app::status_label(&state.status), - Style::default().fg(status_color), - ), - ]), - Line::from(""), - // The pane had the room and the model was otherwise only reachable by running a command. - Line::from(vec![ - Span::styled("Provider: ", Style::default().fg(palette.fg)), - Span::styled(&state.active_provider, Style::default().fg(palette.accent)), - ]), - Line::from(vec![ - Span::styled("Model: ", Style::default().fg(palette.fg)), - Span::styled(&state.active_model, Style::default().fg(palette.accent)), - ]), - Line::from(""), - Line::from(vec![ - Span::styled("Network: ", Style::default().fg(palette.fg)), - Span::styled(&state.active_network, Style::default().fg(palette.accent)), - ]), + let field = |label: &'static str, value: &str, color: Color| { Line::from(vec![ - Span::styled("Explain: ", Style::default().fg(palette.fg)), - Span::styled( - if state.is_explaining() { "on" } else { "off" }, - Style::default().fg(if state.is_explaining() { - palette.accent - } else { - palette.border - }), - ), - ]), + Span::styled(label, Style::default().fg(palette.border)), + Span::styled(value.to_string(), Style::default().fg(color)), + ]) + }; + + // The old block listed seven lines in an eight-row box, so `Explain` was clipped off and the + // toggle had no visible state anywhere. + let session_block = Paragraph::new(vec![ + field( + "Status: ", + crate::app::status_label(&state.status), + status_color, + ), + field("Provider: ", &state.active_provider, palette.accent), + field("Model: ", &state.active_model, palette.accent), + field("Network: ", &state.active_network, palette.accent), + field("Account: ", &state.active_account, Color::Cyan), + field("Project: ", &state.project_name, palette.accent), + field( + "Explain: ", + if state.is_explaining() { "on" } else { "off" }, + if state.is_explaining() { + palette.accent + } else { + palette.border + }, + ), ]) .block( Block::default() - .title(" Status ") + .title(" Session ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::Yellow)), ); - frame.render_widget(status_block, area); -} - -fn render_project_block(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) { - let project_block = Paragraph::new(vec![Line::from(vec![ - Span::styled("Project: ", Style::default().fg(palette.fg)), - Span::styled(&state.project_name, Style::default().fg(palette.accent)), - ])]) - .block( - Block::default() - .title(" Project ") - .borders(Borders::ALL) - .border_style(Style::default().fg(palette.accent)), - ); - - frame.render_widget(project_block, area); -} - -fn render_account_block(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) { - let account_block = Paragraph::new(vec![Line::from(vec![ - Span::styled("Account: ", Style::default().fg(palette.fg)), - Span::styled(&state.active_account, Style::default().fg(Color::Cyan)), - ])]) - .block( - Block::default() - .title(" Account ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Green)), - ); - - frame.render_widget(account_block, area); + frame.render_widget(session_block, area); } fn render_help_block(frame: &mut Frame, area: Rect, palette: &ColorPalette) { + // Ctrl+S is gone from the list: nothing in `handle_key` ever bound it. let help_block = Paragraph::new(vec![ Line::from(Span::styled( "Shortcuts:", Style::default().fg(palette.fg).add_modifier(Modifier::BOLD), )), - Line::from(""), - Line::from(" Ctrl+D Deploy"), - Line::from(" Ctrl+T Test"), Line::from(" Ctrl+B Build"), - Line::from(" Ctrl+S Save"), + Line::from(" Ctrl+T Test"), + Line::from(" Ctrl+D Deploy"), Line::from(" Up/Down Scroll"), + Line::from(" / Commands"), + Line::from(" Ctrl+C Quit"), ]) .wrap(Wrap { trim: false }) .block( @@ -379,22 +351,132 @@ fn render_help_block(frame: &mut Frame, area: Rect, palette: &ColorPalette) { frame.render_widget(help_block, area); } +/// How many suggestion rows the popup shows at once before it starts scrolling. +const AUTOCOMPLETE_MAX_VISIBLE: usize = 5; + +/// Width of the command-name column, so the descriptions line up under each other. +const AUTOCOMPLETE_NAME_COLUMN: usize = 20; + +/// Width the popup needs so the longest description fits without being cut, clamped to the space +/// the input area actually has. +fn autocomplete_popup_width(items: &[AutocompleteItem], available: u16) -> u16 { + let widest_name = items + .iter() + .map(|c| c.value.chars().count()) + .max() + .unwrap_or(0) + .max(AUTOCOMPLETE_NAME_COLUMN); + let widest_desc = items + .iter() + .map(|c| c.description.chars().count()) + .max() + .unwrap_or(0); + + // " name " + description + a trailing space, plus the two border columns. + let content = 1 + widest_name + 1 + widest_desc + 1; + (content as u16 + 2).min(available) +} + +/// Rows the popup occupies for `match_count` suggestions, borders included. +fn autocomplete_popup_height(match_count: usize) -> u16 { + match_count.min(AUTOCOMPLETE_MAX_VISIBLE) as u16 + 2 +} + +/// First suggestion index to draw, so the highlighted row stays inside the visible window. +fn autocomplete_scroll_offset(selected: usize, match_count: usize) -> usize { + if match_count <= AUTOCOMPLETE_MAX_VISIBLE { + return 0; + } + let max_offset = match_count - AUTOCOMPLETE_MAX_VISIBLE; + selected + .saturating_sub(AUTOCOMPLETE_MAX_VISIBLE - 1) + .min(max_offset) +} + fn render_input(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) { + let show_autocomplete = state.autocomplete_active && !state.autocomplete_matches.is_empty(); + + let input_height = if show_autocomplete { 3u16 } else { area.height }; + + let input_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: input_height, + }; + + let title = if show_autocomplete { + format!( + " Input [{} matches] (Up/Down or Tab, Enter to accept) ", + state.autocomplete_matches.len() + ) + } else { + " Input (type /help for commands) ".to_string() + }; + let input_block = Paragraph::new(state.input.as_str()) .block( Block::default() - .title(" Input (type /help for commands) ") + .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Green)), ) .style(Style::default().fg(palette.fg)); - frame.render_widget(input_block, area); + frame.render_widget(input_block, input_area); let cursor_x = (state.input_cursor as u16 + 2).min(area.width.saturating_sub(3)); - let cursor_y = area.y + 1; + let cursor_y = input_area.y + 1; frame.set_cursor_position((cursor_x, cursor_y)); + + if show_autocomplete { + let items = &state.autocomplete_matches; + + let popup_area = Rect { + x: area.x + 1, + y: area.y + 3, + width: autocomplete_popup_width(items, area.width.saturating_sub(2)), + height: autocomplete_popup_height(items.len()), + }; + + let offset = autocomplete_scroll_offset(state.autocomplete_selected, items.len()); + + let lines: Vec> = items + .iter() + .enumerate() + .skip(offset) + .take(AUTOCOMPLETE_MAX_VISIBLE) + .map(|(i, item)| { + let style = if i == state.autocomplete_selected { + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.fg) + }; + Line::from(vec![ + Span::styled( + format!(" {: = AppState::slash_commands() + .iter() + .map(|c| AutocompleteItem { + value: c.name.to_string(), + description: c.description.to_string(), + }) + .collect(); + + let width = autocomplete_popup_width(&items, 200); + let longest = items + .iter() + .map(|c| c.description.chars().count()) + .max() + .unwrap(); + assert!(width as usize >= AUTOCOMPLETE_NAME_COLUMN + longest); + + // Never wider than the space it was given. + assert_eq!(autocomplete_popup_width(&items, 30), 30); + } + + #[test] + fn the_popup_scrolls_to_keep_the_selection_visible() { + // Short lists never scroll. + assert_eq!(autocomplete_scroll_offset(2, 3), 0); + // Long lists hold still until the highlight reaches the bottom row... + assert_eq!( + autocomplete_scroll_offset(AUTOCOMPLETE_MAX_VISIBLE - 1, 10), + 0 + ); + // ...then follow it, and stop once the last entry is on screen. + assert_eq!(autocomplete_scroll_offset(AUTOCOMPLETE_MAX_VISIBLE, 10), 1); + assert_eq!( + autocomplete_scroll_offset(9, 10), + 10 - AUTOCOMPLETE_MAX_VISIBLE + ); + } + #[test] fn wraps_at_the_given_width() { let lines = wrap_text("aaa bbb ccc ddd", 7); diff --git a/src/wizard.rs b/src/wizard.rs new file mode 100644 index 0000000..5dd382c --- /dev/null +++ b/src/wizard.rs @@ -0,0 +1,767 @@ +use color_eyre::Result; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::{ + backend::CrosstermBackend, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Terminal, +}; +use std::io; + +use crate::config::{AppConfig, Provider, Theme}; + +#[derive(Debug, Clone, PartialEq)] +enum Step { + Welcome, + Provider, + Model, + Theme, + Network, + Summary, +} + +struct WizardState { + step: Step, + config: AppConfig, + provider_list: Vec, + provider_idx: usize, + model_list: Vec, + model_idx: usize, + theme_list: Vec, + theme_idx: usize, + network_list: Vec<&'static str>, + network_idx: usize, + error: Option, +} + +impl WizardState { + fn new() -> Self { + let providers = Provider::ALL.to_vec(); + + let provider = providers[0]; + let models = provider + .suggested_models() + .iter() + .map(|s| s.to_string()) + .collect(); + + Self { + step: Step::Welcome, + config: AppConfig::default(), + provider_list: providers, + provider_idx: 0, + model_list: models, + model_idx: 0, + theme_list: vec![Theme::Dark, Theme::Light], + theme_idx: 0, + network_list: vec!["local", "testnet", "mainnet"], + network_idx: 1, // testnet default + error: None, + } + } + + fn selected_provider(&self) -> Provider { + self.provider_list[self.provider_idx] + } + + fn selected_model(&self) -> &str { + &self.model_list[self.model_idx] + } + + fn selected_theme(&self) -> Theme { + self.theme_list[self.theme_idx].clone() + } + + fn selected_network(&self) -> &'static str { + self.network_list[self.network_idx] + } + + fn sync_models(&mut self) { + let provider = self.selected_provider(); + self.model_list = provider + .suggested_models() + .iter() + .map(|s| s.to_string()) + .collect(); + if self.model_idx >= self.model_list.len() { + self.model_idx = 0; + } + } + + fn apply_selections(&mut self) { + self.config.provider = self.selected_provider(); + self.config.default_model = self.selected_model().to_string(); + self.config.theme = self.selected_theme(); + self.config.default_network = self.selected_network().to_string(); + } + + fn next(&mut self) { + self.error = None; + match self.step { + Step::Welcome => self.step = Step::Provider, + Step::Provider => { + self.sync_models(); + self.step = Step::Model; + } + Step::Model => self.step = Step::Theme, + Step::Theme => self.step = Step::Network, + Step::Network => { + self.apply_selections(); + self.step = Step::Summary; + } + Step::Summary => {} + } + } + + fn prev(&mut self) { + self.error = None; + match self.step { + Step::Provider => self.step = Step::Welcome, + Step::Model => self.step = Step::Provider, + Step::Theme => self.step = Step::Model, + Step::Network => self.step = Step::Theme, + Step::Summary => self.step = Step::Network, + Step::Welcome => {} + } + } + + fn move_up(&mut self) { + match self.step { + Step::Provider => { + self.provider_idx = self.provider_idx.saturating_sub(1); + } + Step::Model => { + self.model_idx = self.model_idx.saturating_sub(1); + } + Step::Theme => { + self.theme_idx = self.theme_idx.saturating_sub(1); + } + Step::Network => { + self.network_idx = self.network_idx.saturating_sub(1); + } + _ => {} + } + } + + #[allow(clippy::collapsible_match)] + fn move_down(&mut self) { + match self.step { + Step::Provider => { + if self.provider_idx + 1 < self.provider_list.len() { + self.provider_idx += 1; + } + } + Step::Model => { + if self.model_idx + 1 < self.model_list.len() { + self.model_idx += 1; + } + } + Step::Theme => { + if self.theme_idx + 1 < self.theme_list.len() { + self.theme_idx += 1; + } + } + Step::Network => { + if self.network_idx + 1 < self.network_list.len() { + self.network_idx += 1; + } + } + _ => {} + } + } + + fn save(&mut self) -> Result<(), String> { + self.apply_selections(); + self.config.save().map_err(|e| e.to_string()) + } +} + +struct WizardPalette { + fg: Color, + accent: Color, + selected_bg: Color, + selected_fg: Color, + dim: Color, +} + +impl WizardPalette { + fn dark() -> Self { + Self { + fg: Color::White, + accent: Color::Cyan, + selected_bg: Color::DarkGray, + selected_fg: Color::Cyan, + dim: Color::Gray, + } + } +} + +fn centered_area(area: Rect, width: u16, height: u16) -> Rect { + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + Rect::new(x, y, width.min(area.width), height.min(area.height)) +} + +fn render_wizard_frame(frame: &mut ratatui::Frame, state: &WizardState, palette: &WizardPalette) { + let area = frame.area(); + + // Title + let title = Paragraph::new(Line::from(vec![Span::styled( + " Procyon Setup ", + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + )])) + .alignment(ratatui::layout::Alignment::Center); + let title_area = Rect::new(0, 0, area.width, 1); + frame.render_widget(title, title_area); + + match state.step { + Step::Welcome => render_welcome(frame, area, palette), + Step::Provider => render_provider(frame, area, state, palette), + Step::Model => render_model(frame, area, state, palette), + Step::Theme => render_theme(frame, area, state, palette), + Step::Network => render_network(frame, area, state, palette), + Step::Summary => render_summary(frame, area, state, palette), + } + + // Footer + let footer = match state.step { + Step::Welcome => "Enter: begin q: skip", + Step::Summary => "Enter: save & start Esc: go back", + _ => "Enter: next ↑/↓: select Esc: back q: skip", + }; + let footer_area = Rect::new(0, area.height.saturating_sub(1), area.width, 1); + let footer_widget = Paragraph::new(Span::styled(footer, Style::default().fg(palette.dim))) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(footer_widget, footer_area); +} + +fn render_welcome(frame: &mut ratatui::Frame, area: Rect, palette: &WizardPalette) { + let block_area = centered_area(area, 50, 10); + let block = Block::default() + .title(" Welcome to Procyon ") + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.accent)); + + let lines = vec![ + Line::from(""), + Line::from(Span::styled( + " A terminal harness for Stellar and Soroban", + Style::default().fg(palette.fg), + )), + Line::from(Span::styled( + " development, with an AI agent driving the tools.", + Style::default().fg(palette.fg), + )), + Line::from(""), + Line::from(Span::styled( + " This wizard will configure your settings.", + Style::default().fg(palette.dim), + )), + Line::from(Span::styled( + " It takes about 30 seconds.", + Style::default().fg(palette.dim), + )), + Line::from(""), + Line::from(Span::styled( + " Press Enter to begin, or q to skip and use defaults.", + Style::default().fg(palette.accent), + )), + ]; + + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, block_area); +} + +fn render_provider( + frame: &mut ratatui::Frame, + area: Rect, + state: &WizardState, + palette: &WizardPalette, +) { + let block_area = centered_area(area, 50, 18); + let block = Block::default() + .title(" Step 1/5 - Provider ") + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.accent)); + + let inner = block.inner(block_area); + frame.render_widget(block, block_area); + + let desc = Paragraph::new(Line::from(Span::styled( + "Select your LLM provider:", + Style::default().fg(palette.fg), + ))); + frame.render_widget(desc, Rect::new(inner.x, inner.y, inner.width, 1)); + + let list_area = Rect::new( + inner.x, + inner.y + 2, + inner.width, + inner.height.saturating_sub(2), + ); + let items: Vec = state + .provider_list + .iter() + .enumerate() + .map(|(i, p)| { + let style = if i == state.provider_idx { + Style::default() + .fg(palette.selected_fg) + .bg(palette.selected_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.fg) + }; + let label = if i == state.provider_idx { + format!(" > {} ", p) + } else { + format!(" {} ", p) + }; + ListItem::new(Line::from(Span::styled(label, style))) + }) + .collect(); + + let list = List::new(items); + let mut list_state = ListState::default(); + list_state.select(Some(state.provider_idx)); + frame.render_stateful_widget(list, list_area, &mut list_state); +} + +fn render_model( + frame: &mut ratatui::Frame, + area: Rect, + state: &WizardState, + palette: &WizardPalette, +) { + let block_area = centered_area(area, 50, 16); + let block = Block::default() + .title(format!( + " Step 2/5 - Model ({}) ", + state.selected_provider() + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.accent)); + + let inner = block.inner(block_area); + frame.render_widget(block, block_area); + + let desc = Paragraph::new(Line::from(Span::styled( + "Select a model:", + Style::default().fg(palette.fg), + ))); + frame.render_widget(desc, Rect::new(inner.x, inner.y, inner.width, 1)); + + let list_area = Rect::new( + inner.x, + inner.y + 2, + inner.width, + inner.height.saturating_sub(2), + ); + let items: Vec = state + .model_list + .iter() + .enumerate() + .map(|(i, m)| { + let style = if i == state.model_idx { + Style::default() + .fg(palette.selected_fg) + .bg(palette.selected_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.fg) + }; + let label = if i == state.model_idx { + format!(" > {} ", m) + } else { + format!(" {} ", m) + }; + ListItem::new(Line::from(Span::styled(label, style))) + }) + .collect(); + + let list = List::new(items); + let mut list_state = ListState::default(); + list_state.select(Some(state.model_idx)); + frame.render_stateful_widget(list, list_area, &mut list_state); +} + +fn render_theme( + frame: &mut ratatui::Frame, + area: Rect, + state: &WizardState, + palette: &WizardPalette, +) { + let block_area = centered_area(area, 50, 12); + let block = Block::default() + .title(" Step 3/5 - Theme ") + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.accent)); + + let inner = block.inner(block_area); + frame.render_widget(block, block_area); + + let desc = Paragraph::new(Line::from(Span::styled( + "Choose a color theme:", + Style::default().fg(palette.fg), + ))); + frame.render_widget(desc, Rect::new(inner.x, inner.y, inner.width, 1)); + + let list_area = Rect::new( + inner.x, + inner.y + 2, + inner.width, + inner.height.saturating_sub(2), + ); + let items: Vec = state + .theme_list + .iter() + .enumerate() + .map(|(i, t)| { + let style = if i == state.theme_idx { + Style::default() + .fg(palette.selected_fg) + .bg(palette.selected_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.fg) + }; + let label = if i == state.theme_idx { + format!(" > {} ", t) + } else { + format!(" {} ", t) + }; + ListItem::new(Line::from(Span::styled(label, style))) + }) + .collect(); + + let list = List::new(items); + let mut list_state = ListState::default(); + list_state.select(Some(state.theme_idx)); + frame.render_stateful_widget(list, list_area, &mut list_state); +} + +fn render_network( + frame: &mut ratatui::Frame, + area: Rect, + state: &WizardState, + palette: &WizardPalette, +) { + let block_area = centered_area(area, 50, 12); + let block = Block::default() + .title(" Step 4/5 - Network ") + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.accent)); + + let inner = block.inner(block_area); + frame.render_widget(block, block_area); + + let desc = Paragraph::new(vec![ + Line::from(Span::styled( + "Select default network:", + Style::default().fg(palette.fg), + )), + Line::from(Span::styled( + " (you can change this later with /network)", + Style::default().fg(palette.dim), + )), + ]); + frame.render_widget(desc, Rect::new(inner.x, inner.y, inner.width, 2)); + + let list_area = Rect::new( + inner.x, + inner.y + 3, + inner.width, + inner.height.saturating_sub(3), + ); + let items: Vec = state + .network_list + .iter() + .enumerate() + .map(|(i, n)| { + let style = if i == state.network_idx { + Style::default() + .fg(palette.selected_fg) + .bg(palette.selected_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.fg) + }; + let label = if i == state.network_idx { + format!(" > {} ", n) + } else { + format!(" {} ", n) + }; + ListItem::new(Line::from(Span::styled(label, style))) + }) + .collect(); + + let list = List::new(items); + let mut list_state = ListState::default(); + list_state.select(Some(state.network_idx)); + frame.render_stateful_widget(list, list_area, &mut list_state); +} + +fn render_summary( + frame: &mut ratatui::Frame, + area: Rect, + state: &WizardState, + palette: &WizardPalette, +) { + let block_area = centered_area(area, 50, 16); + let block = Block::default() + .title(" Step 5/5 - Summary ") + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.accent)); + + let inner = block.inner(block_area); + frame.render_widget(block, block_area); + + let provider = state.selected_provider(); + let model = state.selected_model(); + let theme = state.selected_theme(); + let network = state.selected_network(); + + let mut lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Provider: ", Style::default().fg(palette.fg)), + Span::styled( + provider.to_string(), + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Model: ", Style::default().fg(palette.fg)), + Span::styled( + model.to_string(), + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Theme: ", Style::default().fg(palette.fg)), + Span::styled( + theme.to_string(), + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Network: ", Style::default().fg(palette.fg)), + Span::styled( + network.to_string(), + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + ]; + + if let Some(ref err) = state.error { + lines.push(Line::from(Span::styled( + format!(" Error: {}", err), + Style::default().fg(Color::Red), + ))); + lines.push(Line::from("")); + } + + lines.push(Line::from(Span::styled( + " Press Enter to save and start, or Esc to go back.", + Style::default().fg(palette.dim), + ))); + + let paragraph = Paragraph::new(lines); + let desc_area = Rect::new(inner.x, inner.y, inner.width, inner.height); + frame.render_widget(paragraph, desc_area); +} + +/// Runs the onboarding wizard. Returns the final config, or `None` if the user skipped. +pub fn run_wizard( + terminal: &mut Terminal>, +) -> Result> { + let mut state = WizardState::new(); + let palette = WizardPalette::dark(); + + loop { + terminal.draw(|frame| { + render_wizard_frame(frame, &state, &palette); + })?; + + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Press { + continue; + } + + match (key.modifiers, key.code) { + (KeyModifiers::CONTROL, KeyCode::Char('c')) => return Ok(None), + (KeyModifiers::NONE, KeyCode::Char('q')) => return Ok(None), + (KeyModifiers::NONE, KeyCode::Up) | (KeyModifiers::CONTROL, KeyCode::Char('p')) => { + state.move_up(); + } + (KeyModifiers::NONE, KeyCode::Down) + | (KeyModifiers::CONTROL, KeyCode::Char('n')) => { + state.move_down(); + } + (KeyModifiers::NONE, KeyCode::Enter) => { + if state.step == Step::Summary { + match state.save() { + Ok(()) => return Ok(Some(state.config)), + Err(e) => { + state.error = Some(e); + } + } + } else { + state.next(); + } + } + (KeyModifiers::NONE, KeyCode::Esc) => { + state.prev(); + } + _ => {} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state_at(step: Step) -> WizardState { + let mut s = WizardState::new(); + s.step = step; + s + } + + #[test] + fn wizard_starts_on_welcome() { + let s = WizardState::new(); + assert_eq!(s.step, Step::Welcome); + } + + #[test] + fn welcome_advances_to_provider() { + let mut s = state_at(Step::Welcome); + s.next(); + assert_eq!(s.step, Step::Provider); + } + + #[test] + fn provider_advances_to_model_and_syncs() { + let mut s = state_at(Step::Provider); + s.provider_idx = 8; // xai + s.next(); + assert_eq!(s.step, Step::Model); + assert_eq!(s.model_list, vec!["grok-3", "grok-3-mini"]); + } + + #[test] + fn model_advances_to_theme() { + let mut s = state_at(Step::Model); + s.next(); + assert_eq!(s.step, Step::Theme); + } + + #[test] + fn theme_advances_to_network() { + let mut s = state_at(Step::Theme); + s.next(); + assert_eq!(s.step, Step::Network); + } + + #[test] + fn network_advances_to_summary() { + let mut s = state_at(Step::Network); + s.next(); + assert_eq!(s.step, Step::Summary); + } + + #[test] + fn summary_does_not_advance() { + let mut s = state_at(Step::Summary); + s.next(); + assert_eq!(s.step, Step::Summary); + } + + #[test] + fn back_goes_to_previous_step() { + let mut s = state_at(Step::Model); + s.prev(); + assert_eq!(s.step, Step::Provider); + } + + #[test] + fn back_on_welcome_stays() { + let mut s = state_at(Step::Welcome); + s.prev(); + assert_eq!(s.step, Step::Welcome); + } + + #[test] + fn move_up_clamps_at_zero() { + let mut s = state_at(Step::Provider); + s.provider_idx = 0; + s.move_up(); + assert_eq!(s.provider_idx, 0); + } + + #[test] + fn move_down_clamps_at_end() { + let mut s = state_at(Step::Provider); + s.provider_idx = s.provider_list.len() - 1; + s.move_down(); + assert_eq!(s.provider_idx, s.provider_list.len() - 1); + } + + #[test] + fn apply_selections_populates_config() { + let mut s = WizardState::new(); + s.provider_idx = 9; // ollama + s.sync_models(); + s.model_idx = 0; + s.theme_idx = 1; // light + s.network_idx = 0; // local + s.apply_selections(); + + assert_eq!(s.config.provider, Provider::Ollama); + assert_eq!(s.config.default_model, "llama3.2"); + assert_eq!(s.config.theme, Theme::Light); + assert_eq!(s.config.default_network, "local"); + } + + #[test] + fn sync_models_preserves_index_when_in_bounds() { + let mut s = state_at(Step::Provider); + s.provider_idx = 0; // anthropic: 3 models + s.model_idx = 1; + s.sync_models(); + assert_eq!(s.model_idx, 1); + } + + #[test] + fn sync_models_clamps_index_when_out_of_bounds() { + let mut s = state_at(Step::Provider); + s.provider_idx = 0; // anthropic: 3 models + s.model_idx = 10; + s.sync_models(); + assert_eq!(s.model_idx, 0); + } + + #[test] + fn next_clears_error() { + let mut s = state_at(Step::Summary); + s.error = Some("previous error".to_string()); + s.step = Step::Network; + s.next(); + assert!(s.error.is_none()); + } +}