A safe, simple, extensible, and fast agent harness
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

TUI First Time Setup (#25)

* Cleanup error messages in OpenRouter plugin

* Implement first time setup flow for Ein.

Open a first time setup wizard when starting the TUI with no config file
(`~/.ein/config.json`). This walks users through first time setup of the
default plugins to get them started and to a working setup quickly.

authored by

Mason Stallmo and committed by
GitHub
a363ae2f 9813d1e8

+991 -146
+4 -6
crates/ein-server/src/grpc.rs
··· 459 459 Err(err) => { 460 460 eprintln!("[session] agent error: {err}"); 461 461 channel_sender 462 - .send_error(Status::internal(err.to_string())) 462 + .send_event(Event::AgentError(AgentError { 463 + message: err.to_string(), 464 + })) 463 465 .await; 464 - // Deliberate: we do not call save_messages here because this hard-error 465 - // path is only reached by catastrophic transport failures. Soft errors 466 - // (API errors, HTTP failures) are returned as Ok(()) by run_agent and 467 - // reach the save_messages call below. 468 - break; 466 + continue; 469 467 } 470 468 }; 471 469
+213 -9
crates/ein-tui/src/app.rs
··· 47 47 pub(crate) enum DisplayMessage { 48 48 /// Welcome banner shown once at the top of the conversation on startup. 49 49 Header { cwd: String }, 50 + /// Shown on first run when no provider is configured. 51 + SetupPrompt, 50 52 /// Text sent by the local user. 51 53 User(String), 52 54 /// Streamed text from the agent (may be appended to incrementally). ··· 72 74 } 73 75 74 76 // --------------------------------------------------------------------------- 77 + // Setup wizard state 78 + // --------------------------------------------------------------------------- 79 + 80 + /// Provider display names in the order they appear in the wizard. 81 + /// Index 0 is the default selection. 82 + pub(crate) const PROVIDERS: &[(&str, &str)] = &[ 83 + ("ein_openrouter", "OpenRouter"), 84 + ("ein_anthropic", "Anthropic"), 85 + ("ein_openai", "OpenAI"), 86 + ("ein_ollama", "Ollama (local)"), 87 + ]; 88 + 89 + /// Which page of the setup wizard is active. 90 + #[derive(Clone)] 91 + pub(crate) enum WizardStep { 92 + ChooseProvider, 93 + EnterApiKey, 94 + EnterBaseUrl, 95 + EnterModel, 96 + Confirm, 97 + } 98 + 99 + pub(crate) struct SetupWizardState { 100 + pub(crate) step: WizardStep, 101 + pub(crate) provider_idx: usize, 102 + pub(crate) api_key: String, 103 + pub(crate) api_key_cursor: usize, 104 + pub(crate) base_url: String, 105 + pub(crate) base_url_cursor: usize, 106 + pub(crate) model: String, 107 + pub(crate) model_cursor: usize, 108 + /// Set when a save attempt fails; displayed in the Confirm page. 109 + pub(crate) error: Option<String>, 110 + } 111 + 112 + impl SetupWizardState { 113 + pub(crate) fn new() -> Self { 114 + Self { 115 + step: WizardStep::ChooseProvider, 116 + provider_idx: 0, 117 + api_key: String::new(), 118 + api_key_cursor: 0, 119 + base_url: String::new(), 120 + base_url_cursor: 0, 121 + model: String::new(), 122 + model_cursor: 0, 123 + error: None, 124 + } 125 + } 126 + 127 + pub(crate) fn provider_key(&self) -> &'static str { 128 + PROVIDERS[self.provider_idx].0 129 + } 130 + 131 + /// Advance to the next step, skipping steps that don't apply to the selected provider. 132 + pub(crate) fn advance_step(&mut self) { 133 + self.step = match self.step { 134 + WizardStep::ChooseProvider => { 135 + self.api_key.clear(); 136 + self.api_key_cursor = 0; 137 + self.base_url.clear(); 138 + self.base_url_cursor = 0; 139 + self.model.clear(); 140 + self.model_cursor = 0; 141 + if self.provider_key() == "ein_ollama" { 142 + WizardStep::EnterBaseUrl 143 + } else { 144 + WizardStep::EnterApiKey 145 + } 146 + } 147 + WizardStep::EnterApiKey => { 148 + if self.provider_key() == "ein_anthropic" { 149 + WizardStep::EnterModel 150 + } else { 151 + WizardStep::EnterBaseUrl 152 + } 153 + } 154 + WizardStep::EnterBaseUrl => WizardStep::EnterModel, 155 + WizardStep::EnterModel => WizardStep::Confirm, 156 + WizardStep::Confirm => WizardStep::Confirm, 157 + }; 158 + } 159 + 160 + /// Go back one step, skipping steps that don't apply to the selected provider. 161 + pub(crate) fn retreat_step(&mut self) { 162 + self.step = match self.step { 163 + WizardStep::ChooseProvider => WizardStep::ChooseProvider, 164 + WizardStep::EnterApiKey => WizardStep::ChooseProvider, 165 + WizardStep::EnterBaseUrl => { 166 + if self.provider_key() == "ein_ollama" { 167 + WizardStep::ChooseProvider 168 + } else { 169 + WizardStep::EnterApiKey 170 + } 171 + } 172 + WizardStep::EnterModel => { 173 + if self.provider_key() == "ein_anthropic" { 174 + WizardStep::EnterApiKey 175 + } else { 176 + WizardStep::EnterBaseUrl 177 + } 178 + } 179 + WizardStep::Confirm => WizardStep::EnterModel, 180 + }; 181 + } 182 + } 183 + 184 + // --------------------------------------------------------------------------- 185 + // Modal overlay 186 + // 187 + // Exactly one modal can be active at a time. Using an enum enforces this 188 + // invariant at the type level and eliminates scattered Option fields. 189 + // --------------------------------------------------------------------------- 190 + 191 + /// The overlay currently covering the main UI. Only one is ever active. 192 + pub(crate) enum Modal { 193 + /// First-time setup wizard (provider / API key entry). 194 + SetupWizard(SetupWizardState), 195 + /// Plugin manager, opened via `/plugins`. 196 + PluginManager(PluginModalState), 197 + /// Session picker shown on first connection when no cache exists. 198 + SessionPicker(SessionPickerState), 199 + /// CWD access prompt, shown after choosing "New Session". 200 + CwdPrompt(CwdState), 201 + } 202 + 203 + // --------------------------------------------------------------------------- 75 204 // Session picker / CWD prompt state 76 205 // --------------------------------------------------------------------------- 77 206 ··· 169 298 /// Last connection error message, shown above the connecting spinner. 170 299 /// Replaced in-place on each disconnect; cleared when connected. 171 300 pub(crate) connection_error: Option<String>, 172 - /// When `Some`, the session picker overlay is visible (shown first on startup). 173 - pub(crate) pending_session_picker: Option<SessionPickerState>, 174 - /// When `Some`, the CWD access modal is visible (only for new sessions). 175 - pub(crate) pending_cwd_prompt: Option<CwdState>, 176 301 /// Current working directory captured at startup; offered when creating new sessions. 177 302 pub(crate) cwd: Option<String>, 178 303 /// Current client config, kept in sync with `ConfigChanged` events. 179 304 pub(crate) current_cfg: ClientConfig, 180 305 /// Session UUID assigned by the server, shown in the status bar. 181 306 pub(crate) session_id: Option<String>, 182 - /// When `Some`, the plugin manager modal is visible. 183 - pub(crate) pending_plugin_modal: Option<PluginModalState>, 307 + /// The active modal overlay, if any. Only one modal is ever shown at a time. 308 + pub(crate) modal: Option<Modal>, 184 309 } 185 310 186 311 impl App { ··· 205 330 connection_status: ConnectionStatus::Connecting, 206 331 prompt_tx: None, 207 332 connection_error: None, 208 - pending_session_picker: None, 209 - pending_cwd_prompt: None, 210 333 cwd, 211 334 current_cfg, 212 335 session_id: None, 213 - pending_plugin_modal: None, 336 + modal: None, 214 337 } 215 338 } 216 339 } ··· 218 341 // --------------------------------------------------------------------------- 219 342 // Test helpers (shared across all test modules in this crate) 220 343 // --------------------------------------------------------------------------- 344 + 345 + // --------------------------------------------------------------------------- 346 + // Tests 347 + // --------------------------------------------------------------------------- 348 + 349 + #[cfg(test)] 350 + mod wizard_tests { 351 + use super::*; 352 + 353 + fn wizard_at_provider(provider_idx: usize) -> SetupWizardState { 354 + let mut w = SetupWizardState::new(); 355 + w.provider_idx = provider_idx; 356 + w 357 + } 358 + 359 + fn provider_idx(name: &str) -> usize { 360 + PROVIDERS.iter().position(|(k, _)| *k == name).unwrap() 361 + } 362 + 363 + #[test] 364 + fn openrouter_step_sequence() { 365 + let mut w = wizard_at_provider(provider_idx("ein_openrouter")); 366 + assert!(matches!(w.step, WizardStep::ChooseProvider)); 367 + w.advance_step(); 368 + assert!(matches!(w.step, WizardStep::EnterApiKey)); 369 + w.advance_step(); 370 + assert!(matches!(w.step, WizardStep::EnterBaseUrl)); 371 + w.advance_step(); 372 + assert!(matches!(w.step, WizardStep::EnterModel)); 373 + w.advance_step(); 374 + assert!(matches!(w.step, WizardStep::Confirm)); 375 + } 376 + 377 + #[test] 378 + fn anthropic_skips_base_url() { 379 + let mut w = wizard_at_provider(provider_idx("ein_anthropic")); 380 + w.advance_step(); // ChooseProvider -> EnterApiKey 381 + assert!(matches!(w.step, WizardStep::EnterApiKey)); 382 + w.advance_step(); // EnterApiKey -> EnterModel (skips EnterBaseUrl) 383 + assert!(matches!(w.step, WizardStep::EnterModel)); 384 + } 385 + 386 + #[test] 387 + fn ollama_skips_api_key() { 388 + let mut w = wizard_at_provider(provider_idx("ein_ollama")); 389 + w.advance_step(); // ChooseProvider -> EnterBaseUrl (skips EnterApiKey) 390 + assert!(matches!(w.step, WizardStep::EnterBaseUrl)); 391 + w.advance_step(); 392 + assert!(matches!(w.step, WizardStep::EnterModel)); 393 + } 394 + 395 + #[test] 396 + fn retreat_openrouter_from_confirm() { 397 + let mut w = wizard_at_provider(provider_idx("ein_openrouter")); 398 + w.step = WizardStep::Confirm; 399 + w.retreat_step(); 400 + assert!(matches!(w.step, WizardStep::EnterModel)); 401 + w.retreat_step(); 402 + assert!(matches!(w.step, WizardStep::EnterBaseUrl)); 403 + w.retreat_step(); 404 + assert!(matches!(w.step, WizardStep::EnterApiKey)); 405 + w.retreat_step(); 406 + assert!(matches!(w.step, WizardStep::ChooseProvider)); 407 + } 408 + 409 + #[test] 410 + fn retreat_ollama_from_base_url_goes_to_choose_provider() { 411 + let mut w = wizard_at_provider(provider_idx("ein_ollama")); 412 + w.step = WizardStep::EnterBaseUrl; 413 + w.retreat_step(); 414 + assert!(matches!(w.step, WizardStep::ChooseProvider)); 415 + } 416 + 417 + #[test] 418 + fn retreat_anthropic_from_model_goes_to_api_key() { 419 + let mut w = wizard_at_provider(provider_idx("ein_anthropic")); 420 + w.step = WizardStep::EnterModel; 421 + w.retreat_step(); 422 + assert!(matches!(w.step, WizardStep::EnterApiKey)); 423 + } 424 + } 221 425 222 426 #[cfg(test)] 223 427 pub(crate) mod test_helpers {
+214 -4
crates/ein-tui/src/config.rs
··· 2 2 // Copyright 2026 Mason Stallmo 3 3 4 4 use std::collections::HashMap; 5 + use std::fs; 5 6 6 7 /// Per-plugin configuration stored in `~/.ein/config.json`. 7 8 #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] ··· 44 45 .join("config.json"); 45 46 46 47 if config_path.exists() { 47 - let raw = std::fs::read_to_string(&config_path)?; 48 + let raw = fs::read_to_string(&config_path)?; 48 49 let mut value: serde_json::Value = serde_json::from_str(&raw)?; 50 + 49 51 if migrate_v1_to_v2(&mut value) { 50 - std::fs::write(&config_path, serde_json::to_string_pretty(&value)?)?; 52 + fs::write(&config_path, serde_json::to_string_pretty(&value)?)?; 51 53 } 54 + 52 55 Ok(serde_json::from_value(value)?) 53 56 } else { 54 57 let default = ClientConfig::default(); 58 + 55 59 if let Some(parent) = config_path.parent() { 56 - std::fs::create_dir_all(parent)?; 60 + fs::create_dir_all(parent)?; 57 61 } 58 - std::fs::write(&config_path, serde_json::to_string_pretty(&default)?)?; 62 + fs::write(&config_path, serde_json::to_string_pretty(&default)?)?; 63 + 59 64 Ok(default) 60 65 } 61 66 } 62 67 68 + /// Returns true when no model provider has been configured (first-run state). 69 + pub fn is_first_run(cfg: &ClientConfig) -> bool { 70 + cfg.model_client_name.is_empty() && cfg.plugin_configs.is_empty() 71 + } 72 + 73 + /// Writes `cfg` to `~/.ein/config.json`, creating parent directories as needed. 74 + pub fn save_config(cfg: &ClientConfig) -> anyhow::Result<()> { 75 + let config_path = dirs::home_dir() 76 + .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))? 77 + .join(".ein") 78 + .join("config.json"); 79 + 80 + if let Some(parent) = config_path.parent() { 81 + fs::create_dir_all(parent)?; 82 + } 83 + 84 + fs::write(&config_path, serde_json::to_string_pretty(cfg)?)?; 85 + 86 + Ok(()) 87 + } 88 + 89 + /// Constructs a `ClientConfig` from wizard-collected inputs for a given provider. 90 + /// 91 + /// Provider-specific defaults are applied when optional fields are left blank: 92 + /// - `ein_openrouter`: `base_url` defaults to `https://openrouter.ai/api/v1` 93 + /// - `ein_ollama`: `base_url` defaults to `http://localhost:11434` 94 + pub fn build_config_for_provider( 95 + provider: &str, 96 + api_key: &str, 97 + base_url: &str, 98 + model: &str, 99 + ) -> ClientConfig { 100 + let mut params: HashMap<String, serde_json::Value> = HashMap::new(); 101 + 102 + match provider { 103 + "ein_openrouter" => { 104 + params.insert("api_key".to_string(), serde_json::json!(api_key)); 105 + 106 + let url = if base_url.is_empty() { 107 + "https://openrouter.ai/api/v1" 108 + } else { 109 + base_url 110 + }; 111 + params.insert("base_url".to_string(), serde_json::json!(url)); 112 + 113 + if !model.is_empty() { 114 + params.insert("model".to_string(), serde_json::json!(model)); 115 + } 116 + } 117 + "ein_anthropic" => { 118 + params.insert("api_key".to_string(), serde_json::json!(api_key)); 119 + 120 + if !model.is_empty() { 121 + params.insert("model".to_string(), serde_json::json!(model)); 122 + } 123 + } 124 + "ein_openai" => { 125 + params.insert("api_key".to_string(), serde_json::json!(api_key)); 126 + 127 + if !base_url.is_empty() { 128 + params.insert("base_url".to_string(), serde_json::json!(base_url)); 129 + } 130 + 131 + if !model.is_empty() { 132 + params.insert("model".to_string(), serde_json::json!(model)); 133 + } 134 + } 135 + "ein_ollama" => { 136 + let url = if base_url.is_empty() { 137 + "http://localhost:11434" 138 + } else { 139 + base_url 140 + }; 141 + params.insert("base_url".to_string(), serde_json::json!(url)); 142 + 143 + if !model.is_empty() { 144 + params.insert("model".to_string(), serde_json::json!(model)); 145 + } 146 + } 147 + _ => {} 148 + } 149 + 150 + let mut plugin_configs = HashMap::new(); 151 + plugin_configs.insert( 152 + provider.to_string(), 153 + PluginConfig { 154 + allowed_paths: vec![], 155 + allowed_hosts: vec![], 156 + params, 157 + }, 158 + ); 159 + 160 + ClientConfig { 161 + model_client_name: provider.to_string(), 162 + plugin_configs, 163 + ..Default::default() 164 + } 165 + } 166 + 63 167 /// Returns true if the value was modified (migration performed). 64 168 fn migrate_v1_to_v2(value: &mut serde_json::Value) -> bool { 65 169 let obj = match value.as_object_mut() { ··· 105 209 106 210 true 107 211 } 212 + 213 + // --------------------------------------------------------------------------- 214 + // Tests 215 + // --------------------------------------------------------------------------- 216 + 217 + #[cfg(test)] 218 + mod tests { 219 + use super::*; 220 + 221 + #[test] 222 + fn is_first_run_empty_config() { 223 + assert!(is_first_run(&ClientConfig::default())); 224 + } 225 + 226 + #[test] 227 + fn is_first_run_false_when_model_client_name_set() { 228 + let cfg = ClientConfig { 229 + model_client_name: "ein_openrouter".to_string(), 230 + ..Default::default() 231 + }; 232 + assert!(!is_first_run(&cfg)); 233 + } 234 + 235 + #[test] 236 + fn is_first_run_false_when_plugin_configs_set() { 237 + let cfg = build_config_for_provider("ein_openrouter", "key", "", "model"); 238 + assert!(!is_first_run(&cfg)); 239 + } 240 + 241 + #[test] 242 + fn build_config_openrouter_default_base_url() { 243 + let cfg = build_config_for_provider("ein_openrouter", "sk-key", "", "my-model"); 244 + let params = &cfg.plugin_configs["ein_openrouter"].params; 245 + 246 + assert_eq!( 247 + params["base_url"].as_str().unwrap(), 248 + "https://openrouter.ai/api/v1" 249 + ); 250 + assert_eq!(params["api_key"].as_str().unwrap(), "sk-key"); 251 + assert_eq!(params["model"].as_str().unwrap(), "my-model"); 252 + assert_eq!(cfg.model_client_name, "ein_openrouter"); 253 + } 254 + 255 + #[test] 256 + fn build_config_openrouter_custom_base_url() { 257 + let cfg = 258 + build_config_for_provider("ein_openrouter", "key", "https://custom.example.com", "m"); 259 + 260 + assert_eq!( 261 + cfg.plugin_configs["ein_openrouter"].params["base_url"] 262 + .as_str() 263 + .unwrap(), 264 + "https://custom.example.com" 265 + ); 266 + } 267 + 268 + #[test] 269 + fn build_config_anthropic_no_base_url() { 270 + let cfg = build_config_for_provider("ein_anthropic", "sk-ant", "", "claude-opus-4-7"); 271 + let params = &cfg.plugin_configs["ein_anthropic"].params; 272 + 273 + assert!( 274 + !params.contains_key("base_url"), 275 + "Anthropic should not have base_url" 276 + ); 277 + assert_eq!(params["api_key"].as_str().unwrap(), "sk-ant"); 278 + assert_eq!(cfg.model_client_name, "ein_anthropic"); 279 + } 280 + 281 + #[test] 282 + fn build_config_ollama_no_api_key_default_url() { 283 + let cfg = build_config_for_provider("ein_ollama", "", "", "llama3"); 284 + let params = &cfg.plugin_configs["ein_ollama"].params; 285 + 286 + assert!( 287 + !params.contains_key("api_key"), 288 + "Ollama should not have api_key" 289 + ); 290 + assert_eq!( 291 + params["base_url"].as_str().unwrap(), 292 + "http://localhost:11434" 293 + ); 294 + } 295 + 296 + #[test] 297 + fn save_config_roundtrip() { 298 + let cfg = build_config_for_provider("ein_openrouter", "test-key", "", "test-model"); 299 + let dir = std::env::temp_dir().join(format!("ein_test_{}", std::process::id())); 300 + fs::create_dir_all(&dir).unwrap(); 301 + 302 + let path = dir.join("config.json"); 303 + let json = serde_json::to_string_pretty(&cfg).unwrap(); 304 + fs::write(&path, &json).unwrap(); 305 + 306 + let loaded: ClientConfig = serde_json::from_str(&json).unwrap(); 307 + 308 + assert_eq!(loaded.model_client_name, "ein_openrouter"); 309 + assert_eq!( 310 + loaded.plugin_configs["ein_openrouter"].params["api_key"] 311 + .as_str() 312 + .unwrap(), 313 + "test-key" 314 + ); 315 + fs::remove_dir_all(&dir).unwrap(); 316 + } 317 + }
+5 -2
crates/ein-tui/src/connection.rs
··· 152 152 return Ok(false); // TUI exited while we were fetching 153 153 } 154 154 155 - // Block until the user makes a selection (or the TUI exits). 155 + // Block until the user makes a selection (or the picker is dismissed). 156 156 match rx.await { 157 157 Ok(cfg) => { 158 158 *session_config_cache.lock().await = Some(cfg.clone()); 159 159 cfg 160 160 } 161 - Err(_) => return Ok(false), // oneshot dropped — TUI exited 161 + // Oneshot dropped without a value — the session picker was dismissed 162 + // (e.g. user opened the setup wizard). Treat as a transient failure so 163 + // the connection manager retries once the cache is populated. 164 + Err(_) => return Err(anyhow::anyhow!("session selection cancelled")), 162 165 } 163 166 } 164 167 }
+206 -59
crates/ein-tui/src/input.rs
··· 5 5 use ein_proto::ein::{UserInput, agent_event::Event as ServerEvent, user_input}; 6 6 use tracing::{debug, info, warn}; 7 7 8 - use crate::app::{App, CwdState, DisplayMessage, SessionPickerState}; 8 + use crate::app::{ 9 + App, CwdState, DisplayMessage, Modal, SessionPickerState, SetupWizardState, WizardStep, 10 + }; 9 11 use crate::connection::to_proto_session_config; 10 12 11 13 // --------------------------------------------------------------------------- ··· 50 52 name: "/plugins", 51 53 description: "Manage installed plugins", 52 54 }, 55 + CommandDef { 56 + name: "/setup", 57 + description: "Run the first-time setup wizard", 58 + }, 53 59 ]; 54 60 55 61 /// Recomputes `autocomplete_matches` and `autocomplete_active` based on the ··· 92 98 OpenPluginModal, 93 99 /// User selected a plugin source to install/update; `source_id` identifies it. 94 100 InstallPlugin { source_id: String }, 101 + /// Open (or reopen) the first-time setup wizard. 102 + OpenSetupWizard, 103 + /// Setup wizard saved config; trigger an immediate reconnect. 104 + SetupComplete, 95 105 } 96 106 97 107 // --------------------------------------------------------------------------- ··· 109 119 return KeyAction::Quit; 110 120 } 111 121 112 - // While the plugin modal is visible, route all key events to it. 113 - if app.pending_plugin_modal.is_some() { 114 - return handle_plugin_modal_key(app, key); 115 - } 116 - 117 - // While the session picker is visible, route all key events to it. 118 - if app.pending_session_picker.is_some() { 119 - return handle_session_picker_key(app, key).await; 122 + match &app.modal { 123 + Some(Modal::SetupWizard(_)) => handle_setup_wizard_key(app, key), 124 + Some(Modal::PluginManager(_)) => handle_plugin_modal_key(app, key), 125 + Some(Modal::SessionPicker(_)) => handle_session_picker_key(app, key).await, 126 + Some(Modal::CwdPrompt(_)) => handle_cwd_modal_key(app, key), 127 + None => handle_normal_key(app, key).await, 120 128 } 121 - 122 - // While the CWD modal is visible (only for new sessions), intercept Y/N. 123 - if app.pending_cwd_prompt.is_some() { 124 - return handle_cwd_modal_key(app, key); 125 - } 126 - 127 - handle_normal_key(app, key).await 128 129 } 129 130 130 131 fn handle_plugin_modal_key(app: &mut App, key: KeyEvent) -> KeyAction { 131 - // While an async operation is in flight, only Esc is allowed. 132 - let busy = app 133 - .pending_plugin_modal 134 - .as_ref() 135 - .is_some_and(|m| m.loading || m.installing); 132 + let busy = matches!(&app.modal, Some(Modal::PluginManager(m)) if m.loading || m.installing); 136 133 if busy { 137 134 if key.code == KeyCode::Esc { 138 - app.pending_plugin_modal = None; 135 + app.modal = None; 139 136 } 140 137 return KeyAction::Continue; 141 138 } 142 139 143 140 match key.code { 144 141 KeyCode::Esc => { 145 - app.pending_plugin_modal = None; 142 + app.modal = None; 146 143 } 147 144 KeyCode::Up => { 148 - if let Some(modal) = app.pending_plugin_modal.as_mut() { 149 - if modal.selected > 0 { 150 - modal.selected -= 1; 145 + if let Some(Modal::PluginManager(m)) = &mut app.modal { 146 + if m.selected > 0 { 147 + m.selected -= 1; 151 148 } 152 149 } 153 150 } 154 151 KeyCode::Down => { 155 - if let Some(modal) = app.pending_plugin_modal.as_mut() { 156 - if modal.selected + 1 < modal.sources.len() { 157 - modal.selected += 1; 152 + if let Some(Modal::PluginManager(m)) = &mut app.modal { 153 + if m.selected + 1 < m.sources.len() { 154 + m.selected += 1; 158 155 } 159 156 } 160 157 } 161 158 KeyCode::Enter => { 162 - let source_id = app 163 - .pending_plugin_modal 164 - .as_ref() 165 - .and_then(|m| m.sources.get(m.selected)) 166 - .map(|s| s.id.clone()) 167 - .unwrap_or_else(|| "default".to_string()); 168 - if let Some(modal) = app.pending_plugin_modal.as_mut() { 169 - modal.installing = true; 170 - modal.status_message = None; 159 + let source_id = match &app.modal { 160 + Some(Modal::PluginManager(m)) => m 161 + .sources 162 + .get(m.selected) 163 + .map(|s| s.id.clone()) 164 + .unwrap_or_else(|| "default".to_string()), 165 + _ => return KeyAction::Continue, 166 + }; 167 + if let Some(Modal::PluginManager(m)) = &mut app.modal { 168 + m.installing = true; 169 + m.status_message = None; 171 170 } 172 171 return KeyAction::InstallPlugin { source_id }; 173 172 } ··· 177 176 } 178 177 179 178 async fn handle_session_picker_key(app: &mut App, key: KeyEvent) -> KeyAction { 180 - let picker = app.pending_session_picker.as_mut().unwrap(); 181 179 match key.code { 182 180 KeyCode::Up => { 183 - if picker.selected > 0 { 184 - picker.selected -= 1; 181 + if let Some(Modal::SessionPicker(p)) = &mut app.modal { 182 + if p.selected > 0 { 183 + p.selected -= 1; 184 + } 185 185 } 186 186 } 187 187 KeyCode::Down => { 188 - if picker.selected < picker.sessions.len() { 189 - picker.selected += 1; 188 + if let Some(Modal::SessionPicker(p)) = &mut app.modal { 189 + if p.selected < p.sessions.len() { 190 + p.selected += 1; 191 + } 190 192 } 191 193 } 192 194 // Shift+D: delete the highlighted existing session (not "New Session"). 193 195 KeyCode::Char('D') => { 194 - let picker = app.pending_session_picker.as_ref().unwrap(); 195 - if picker.selected > 0 { 196 - let session_id = picker.sessions[picker.selected - 1].id.clone(); 197 - return KeyAction::DeleteSession(session_id); 196 + if let Some(Modal::SessionPicker(p)) = &app.modal { 197 + if p.selected > 0 { 198 + let session_id = p.sessions[p.selected - 1].id.clone(); 199 + return KeyAction::DeleteSession(session_id); 200 + } 198 201 } 199 202 } 203 + // S: open the setup wizard to configure a provider. 204 + KeyCode::Char('s') | KeyCode::Char('S') => { 205 + return KeyAction::OpenSetupWizard; 206 + } 200 207 KeyCode::Enter => { 201 - let state = app.pending_session_picker.take().unwrap(); 208 + let state = match app.modal.take() { 209 + Some(Modal::SessionPicker(s)) => s, 210 + other => { 211 + app.modal = other; 212 + return KeyAction::Continue; 213 + } 214 + }; 202 215 if state.selected == 0 { 203 - // "New Session" — build config from current settings. 204 216 let base = to_proto_session_config(&app.current_cfg, String::new()); 205 217 if let Some(cwd) = app.cwd.clone() { 206 - // Show the CWD modal before sending the config. 207 - app.pending_cwd_prompt = Some(CwdState { 218 + app.modal = Some(Modal::CwdPrompt(CwdState { 208 219 cwd, 209 220 base_config: base, 210 221 session_tx: state.session_tx, 211 - }); 222 + })); 212 223 } else { 213 224 let _ = state.session_tx.send(base); 214 225 } 215 226 } else { 216 - // Resume existing session using its stored config. 217 227 resolve_session_resume(state).await; 218 228 } 219 229 } ··· 251 261 let _ = state.session_tx.send(resume_cfg); 252 262 } 253 263 264 + fn handle_setup_wizard_key(app: &mut App, key: KeyEvent) -> KeyAction { 265 + // Clone the current step so we release the borrow before mutating app.modal. 266 + let step = match &app.modal { 267 + Some(Modal::SetupWizard(w)) => w.step.clone(), 268 + _ => return KeyAction::Continue, 269 + }; 270 + 271 + match step { 272 + WizardStep::ChooseProvider => match key.code { 273 + KeyCode::Esc => { 274 + app.modal = None; 275 + } 276 + KeyCode::Up => { 277 + if let Some(Modal::SetupWizard(w)) = &mut app.modal { 278 + if w.provider_idx > 0 { 279 + w.provider_idx -= 1; 280 + } 281 + } 282 + } 283 + KeyCode::Down => { 284 + if let Some(Modal::SetupWizard(w)) = &mut app.modal { 285 + if w.provider_idx + 1 < crate::app::PROVIDERS.len() { 286 + w.provider_idx += 1; 287 + } 288 + } 289 + } 290 + KeyCode::Enter | KeyCode::Tab => { 291 + if let Some(Modal::SetupWizard(w)) = &mut app.modal { 292 + w.advance_step(); 293 + } 294 + } 295 + _ => {} 296 + }, 297 + 298 + WizardStep::EnterApiKey => { 299 + if let Some(Modal::SetupWizard(w)) = &mut app.modal { 300 + handle_wizard_text_input(key, w, |w| (&mut w.api_key, &mut w.api_key_cursor)); 301 + } 302 + } 303 + WizardStep::EnterBaseUrl => { 304 + if let Some(Modal::SetupWizard(w)) = &mut app.modal { 305 + handle_wizard_text_input(key, w, |w| (&mut w.base_url, &mut w.base_url_cursor)); 306 + } 307 + } 308 + WizardStep::EnterModel => { 309 + if let Some(Modal::SetupWizard(w)) = &mut app.modal { 310 + handle_wizard_text_input(key, w, |w| (&mut w.model, &mut w.model_cursor)); 311 + } 312 + } 313 + 314 + WizardStep::Confirm => match key.code { 315 + KeyCode::Esc => { 316 + if let Some(Modal::SetupWizard(w)) = &mut app.modal { 317 + w.error = None; 318 + w.retreat_step(); 319 + } 320 + } 321 + KeyCode::Enter => { 322 + let (provider_key, api_key, base_url, model) = match &app.modal { 323 + Some(Modal::SetupWizard(w)) => ( 324 + w.provider_key(), 325 + w.api_key.clone(), 326 + w.base_url.clone(), 327 + w.model.clone(), 328 + ), 329 + _ => return KeyAction::Continue, 330 + }; 331 + let cfg = crate::config::build_config_for_provider( 332 + provider_key, 333 + &api_key, 334 + &base_url, 335 + &model, 336 + ); 337 + match crate::config::save_config(&cfg) { 338 + Ok(()) => { 339 + app.modal = None; 340 + return KeyAction::SetupComplete; 341 + } 342 + Err(e) => { 343 + if let Some(Modal::SetupWizard(w)) = &mut app.modal { 344 + w.error = Some(e.to_string()); 345 + } 346 + } 347 + } 348 + } 349 + _ => {} 350 + }, 351 + } 352 + 353 + KeyAction::Continue 354 + } 355 + 356 + /// Handles a key press for a wizard text-input step. 357 + /// 358 + /// `field_fn` extracts mutable references to the buffer and cursor for the active field. 359 + fn handle_wizard_text_input<F>(key: KeyEvent, wizard: &mut SetupWizardState, field_fn: F) 360 + where 361 + F: Fn(&mut SetupWizardState) -> (&mut String, &mut usize), 362 + { 363 + match key.code { 364 + KeyCode::Esc => wizard.retreat_step(), 365 + KeyCode::Enter | KeyCode::Tab => wizard.advance_step(), 366 + KeyCode::Char(c) => { 367 + let (buf, cursor) = field_fn(wizard); 368 + let byte_idx = char_to_byte_idx(buf, *cursor); 369 + buf.insert(byte_idx, c); 370 + *cursor += 1; 371 + } 372 + KeyCode::Backspace => { 373 + let (buf, cursor) = field_fn(wizard); 374 + if *cursor > 0 { 375 + let byte_end = char_to_byte_idx(buf, *cursor); 376 + let byte_start = char_to_byte_idx(buf, *cursor - 1); 377 + buf.drain(byte_start..byte_end); 378 + *cursor -= 1; 379 + } 380 + } 381 + KeyCode::Left => { 382 + let (_, cursor) = field_fn(wizard); 383 + if *cursor > 0 { 384 + *cursor -= 1; 385 + } 386 + } 387 + KeyCode::Right => { 388 + let (buf, cursor) = field_fn(wizard); 389 + let len = buf.chars().count(); 390 + if *cursor < len { 391 + *cursor += 1; 392 + } 393 + } 394 + _ => {} 395 + } 396 + } 397 + 254 398 fn handle_cwd_modal_key(app: &mut App, key: KeyEvent) -> KeyAction { 255 399 match key.code { 256 400 KeyCode::Char('y') | KeyCode::Char('Y') => { 257 - let state = app.pending_cwd_prompt.take().unwrap(); 258 - let mut config = state.base_config; 259 - config.allowed_paths.push(state.cwd); 260 - let _ = state.session_tx.send(config); 401 + if let Some(Modal::CwdPrompt(state)) = app.modal.take() { 402 + let mut config = state.base_config; 403 + config.allowed_paths.push(state.cwd); 404 + let _ = state.session_tx.send(config); 405 + } 261 406 } 262 407 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Enter | KeyCode::Esc => { 263 - let state = app.pending_cwd_prompt.take().unwrap(); 264 - let _ = state.session_tx.send(state.base_config); 408 + if let Some(Modal::CwdPrompt(state)) = app.modal.take() { 409 + let _ = state.session_tx.send(state.base_config); 410 + } 265 411 } 266 412 _ => {} 267 413 } ··· 334 480 "/new" => return KeyAction::NewSession, 335 481 "/plugins" => return KeyAction::OpenPluginModal, 336 482 "/sessions" => return KeyAction::OpenSessionPicker, 483 + "/setup" => return KeyAction::OpenSetupWizard, 337 484 _ => { 338 485 // Reject unrecognized slash commands — display a local error, do not send to server. 339 486 if text.starts_with('/') {
+91 -28
crates/ein-tui/src/lib.rs
··· 13 13 mod render; 14 14 15 15 use crate::app::{ 16 - App, AppEvent, ConnectionStatus, CwdState, DisplayMessage, PluginModalState, SessionPickerState, 16 + App, AppEvent, ConnectionStatus, CwdState, DisplayMessage, Modal, PluginModalState, 17 + SessionPickerState, SetupWizardState, 17 18 }; 18 19 use crate::config::load_or_create_config; 19 20 use crate::connection::{ ··· 98 99 99 100 // Load (or create) the client config before opening the gRPC session. 100 101 let cfg = load_or_create_config()?; 102 + let first_run = config::is_first_run(&cfg); 101 103 102 104 // Derive a short model name for the status bar by stripping the vendor 103 105 // prefix (e.g. "anthropic/claude-haiku-4.5" → "claude-haiku-4.5"). ··· 139 141 let mut terminal = Terminal::new(backend)?; 140 142 141 143 let mut app = App::new(model_display, cwd_str, cwd_display, cfg.clone()); 144 + 145 + if first_run { 146 + app.messages.push(DisplayMessage::SetupPrompt); 147 + app.modal = Some(Modal::SetupWizard(SetupWizardState::new())); 148 + } 149 + 142 150 let mut term_events = EventStream::new(); 143 151 // Ticker drives the thinking spinner; only app.tick is incremented when 144 152 // the agent is busy, so the timer is cheap when idle. ··· 149 157 150 158 tokio::select! { 151 159 _ = ticker.tick() => { 152 - let plugin_busy = app 153 - .pending_plugin_modal 160 + let plugin_busy = app.modal 154 161 .as_ref() 155 - .is_some_and(|m| m.loading || m.installing); 162 + .is_some_and(|m| { 163 + match m { 164 + Modal::PluginManager(m) => { 165 + m.loading || m.installing 166 + }, 167 + _ => false 168 + } 169 + }); 170 + 156 171 if app.agent_busy 157 172 || matches!(app.connection_status, ConnectionStatus::Connecting) 158 173 || plugin_busy ··· 201 216 // path. A bridge task receives the final config from the modal 202 217 // and updates the cache before signalling the connection manager. 203 218 let (tx, rx) = tokio::sync::oneshot::channel::<SessionConfig>(); 204 - app.pending_cwd_prompt = Some(CwdState { 205 - cwd, 206 - base_config: base, 207 - session_tx: tx, 208 - }); 219 + app.modal = Some(Modal::CwdPrompt( 220 + CwdState { 221 + cwd, 222 + base_config: base, 223 + session_tx: tx, 224 + } 225 + )); 209 226 let cache = session_config_cache.clone(); 210 227 let notify = reconnect_notify.clone(); 211 228 tokio::spawn(async move { ··· 243 260 }); 244 261 } 245 262 KeyAction::OpenPluginModal => { 246 - app.pending_plugin_modal = Some(PluginModalState { 263 + app.modal = Some(Modal::PluginManager(PluginModalState { 247 264 sources: vec![], 248 265 selected: 0, 249 266 installing: false, 250 267 loading: true, 251 268 status_message: None, 252 - }); 269 + 270 + })); 253 271 let addr = args.server_addr.clone(); 254 272 let tx = event_tx.clone(); 255 273 tokio::spawn(async move { ··· 281 299 } 282 300 }); 283 301 } 302 + KeyAction::OpenSetupWizard => { 303 + app.modal = Some(Modal::SetupWizard(SetupWizardState::new())); 304 + } 305 + KeyAction::SetupComplete => { 306 + app.prompt_tx = None; 307 + app.connection_status = ConnectionStatus::Connecting; 308 + app.session_id = None; 309 + app.agent_busy = false; 310 + app.connection_error = None; 311 + // Remove the "No provider configured" banner now that setup is done. 312 + app.messages 313 + .retain(|m| !matches!(m, DisplayMessage::SetupPrompt)); 314 + // Reload the freshly-saved config, then ask about CWD access the 315 + // same way the /new command and session picker do. 316 + if let Ok(new_cfg) = load_or_create_config() { 317 + app.current_cfg = new_cfg.clone(); 318 + app.model_display = model_display_from_config(&new_cfg); 319 + let base = to_proto_session_config(&new_cfg, String::new()); 320 + if let Some(cwd) = app.cwd.clone() { 321 + let (tx, rx) = 322 + tokio::sync::oneshot::channel::<SessionConfig>(); 323 + app.modal = Some(Modal::CwdPrompt(CwdState { 324 + cwd, 325 + base_config: base, 326 + session_tx: tx, 327 + })); 328 + let cache = session_config_cache.clone(); 329 + let notify = reconnect_notify.clone(); 330 + tokio::spawn(async move { 331 + if let Ok(cfg) = rx.await { 332 + *cache.lock().await = Some(cfg); 333 + notify.notify_one(); 334 + } 335 + }); 336 + } else { 337 + *session_config_cache.lock().await = Some(base); 338 + reconnect_notify.notify_one(); 339 + } 340 + } else { 341 + *session_config_cache.lock().await = None; 342 + reconnect_notify.notify_one(); 343 + } 344 + } 284 345 KeyAction::Continue => {} 285 346 } 286 347 } ··· 320 381 } 321 382 } 322 383 AppEvent::SessionsLoaded(sessions, session_tx) => { 323 - app.pending_session_picker = Some(SessionPickerState { 324 - sessions, 325 - selected: 0, 326 - session_tx, 327 - }); 384 + if app.modal.is_none() { 385 + app.modal = Some(Modal::SessionPicker(SessionPickerState { 386 + sessions, 387 + selected: 0, 388 + session_tx, 389 + })); 390 + } 328 391 } 329 392 AppEvent::SessionDeleted(id) => { 330 - if let Some(picker) = &mut app.pending_session_picker { 331 - picker.sessions.retain(|s| s.id != id); 393 + if let Some(Modal::SessionPicker(picker_state)) = &mut app.modal { 394 + picker_state.sessions.retain(|s| s.id != id); 332 395 // Clamp selection: index 0 is always "New Session". 333 - let max_idx = picker.sessions.len(); 334 - if picker.selected > max_idx { 335 - picker.selected = max_idx; 396 + let max_idx = picker_state.sessions.len(); 397 + if picker_state.selected > max_idx { 398 + picker_state.selected = max_idx; 336 399 } 337 400 } 338 401 } 339 402 AppEvent::PluginStatusLoaded(sources) => { 340 - if let Some(modal) = &mut app.pending_plugin_modal { 341 - modal.sources = sources; 342 - modal.loading = false; 403 + if let Some(Modal::PluginManager(modal_state)) = &mut app.modal { 404 + modal_state.sources = sources; 405 + modal_state.loading = false; 343 406 } 344 407 } 345 408 AppEvent::PluginInstallResult { success, message } => { 346 - if let Some(modal) = &mut app.pending_plugin_modal { 347 - modal.installing = false; 348 - modal.status_message = Some(message); 409 + if let Some(Modal::PluginManager(modal_state)) = &mut app.modal { 410 + modal_state.installing = false; 411 + modal_state.status_message = Some(message); 349 412 // Optimistically mark the source as installed on success. 350 413 if success { 351 - for source in &mut modal.sources { 414 + for source in &mut modal_state.sources { 352 415 source.installed = true; 353 416 } 354 417 }
+252 -13
crates/ein-tui/src/render.rs
··· 17 17 }; 18 18 use tracing::debug; 19 19 20 - use crate::app::{App, ConnectionStatus, DisplayMessage, PluginModalState, SessionPickerState}; 20 + use crate::app::{ 21 + App, ConnectionStatus, DisplayMessage, Modal, PROVIDERS, PluginModalState, SessionPickerState, 22 + SetupWizardState, WizardStep, 23 + }; 21 24 use crate::input::COMMANDS; 22 25 23 26 // --------------------------------------------------------------------------- ··· 304 307 } 305 308 306 309 // --- Plugin modal --- 307 - if let Some(modal) = &app.pending_plugin_modal { 308 - render_plugin_modal(modal, app.tick, frame); 309 - } 310 310 311 - // --- Session picker (overlays everything, shown before CWD modal) --- 312 - if let Some(picker) = &app.pending_session_picker { 313 - render_session_picker(picker, frame); 314 - } 315 - 316 - // --- CWD access modal (overlays everything when present, after session picker) --- 317 - if let Some(cwd_state) = &app.pending_cwd_prompt { 318 - render_cwd_modal(&cwd_state.cwd, frame); 311 + if let Some(modal) = &app.modal { 312 + match modal { 313 + Modal::SetupWizard(setup_wizard_state) => { 314 + render_setup_wizard(setup_wizard_state, frame); 315 + } 316 + Modal::PluginManager(plugin_manager_state) => { 317 + render_plugin_modal(plugin_manager_state, app.tick, frame); 318 + } 319 + Modal::SessionPicker(session_picker_state) => { 320 + render_session_picker(session_picker_state, frame); 321 + } 322 + Modal::CwdPrompt(cwd_state) => { 323 + render_cwd_modal(&cwd_state.cwd, frame); 324 + } 325 + } 319 326 } 320 327 321 328 // --- Status bar --- ··· 406 413 407 414 lines.push(Line::raw("")); 408 415 } 416 + DisplayMessage::SetupPrompt => { 417 + lines.push(Line::from(Span::styled( 418 + " No provider configured.", 419 + Style::default() 420 + .fg(DISCONNECTED_COLOR) 421 + .add_modifier(Modifier::BOLD), 422 + ))); 423 + lines.push(Line::from(Span::styled( 424 + " Run /setup to get started, or /config to edit ~/.ein/config.json directly.", 425 + Style::default().fg(MUTED_COLOR), 426 + ))); 427 + lines.push(Line::raw("")); 428 + } 409 429 DisplayMessage::User(text) => { 410 430 lines.push(Line::from(vec![ 411 431 Span::styled("You: ", Style::default().add_modifier(Modifier::BOLD)), ··· 815 835 .fg(AUTOCOMPLETE_TOP_COLOR) 816 836 .add_modifier(Modifier::BOLD), 817 837 ), 818 - Span::styled(" Delete", Style::default().fg(MUTED_COLOR)), 838 + Span::styled(" Delete ", Style::default().fg(MUTED_COLOR)), 839 + Span::styled( 840 + "[S]", 841 + Style::default() 842 + .fg(AUTOCOMPLETE_TOP_COLOR) 843 + .add_modifier(Modifier::BOLD), 844 + ), 845 + Span::styled(" Setup", Style::default().fg(MUTED_COLOR)), 819 846 ])); 820 847 821 848 frame.render_widget(Paragraph::new(lines), inner); 849 + } 850 + 851 + /// Renders an input field with a cursor indicator within a wizard page. 852 + /// 853 + /// `masked` replaces visible characters with `*` (used for API key fields). 854 + fn render_wizard_field(label: &str, buf: &str, cursor: usize, masked: bool) -> Line<'static> { 855 + let displayed: String = if masked { 856 + "*".repeat(buf.chars().count()) 857 + } else { 858 + buf.to_string() 859 + }; 860 + 861 + let before: String = displayed.chars().take(cursor).collect(); 862 + let at: String = displayed 863 + .chars() 864 + .nth(cursor) 865 + .map(|c| c.to_string()) 866 + .unwrap_or_else(|| " ".to_string()); 867 + let after: String = displayed.chars().skip(cursor + 1).collect(); 868 + 869 + Line::from(vec![ 870 + Span::styled(format!(" {label}: "), Style::default().fg(MUTED_COLOR)), 871 + Span::raw(before), 872 + Span::styled(at, Style::default().add_modifier(Modifier::REVERSED)), 873 + Span::raw(after), 874 + ]) 875 + } 876 + 877 + /// Key hint line shown at the bottom of each wizard page. 878 + fn wizard_hint( 879 + primary: &str, 880 + primary_label: &str, 881 + secondary: &str, 882 + secondary_label: &str, 883 + ) -> Line<'static> { 884 + Line::from(vec![ 885 + Span::styled( 886 + format!(" [{primary}]"), 887 + Style::default() 888 + .fg(AUTOCOMPLETE_TOP_COLOR) 889 + .add_modifier(Modifier::BOLD), 890 + ), 891 + Span::styled( 892 + format!(" {primary_label} "), 893 + Style::default().fg(MUTED_COLOR), 894 + ), 895 + Span::styled( 896 + format!("[{secondary}]"), 897 + Style::default() 898 + .fg(AUTOCOMPLETE_TOP_COLOR) 899 + .add_modifier(Modifier::BOLD), 900 + ), 901 + Span::styled( 902 + format!(" {secondary_label}"), 903 + Style::default().fg(MUTED_COLOR), 904 + ), 905 + ]) 906 + } 907 + 908 + /// Renders the first-time setup wizard modal over the entire terminal. 909 + fn render_setup_wizard(wizard: &SetupWizardState, frame: &mut Frame) { 910 + let modal_width = (frame.area().width * 7 / 10) 911 + .max(60) 912 + .min(frame.area().width); 913 + 914 + // Build the page content first so we know the height. 915 + let mut content: Vec<Line> = vec![Line::raw("")]; 916 + 917 + let title = match wizard.step { 918 + WizardStep::ChooseProvider => { 919 + for (i, (_, display_name)) in PROVIDERS.iter().enumerate() { 920 + let is_sel = wizard.provider_idx == i; 921 + let cursor = if is_sel { "> " } else { " " }; 922 + let style = if is_sel { 923 + Style::default() 924 + .fg(AUTOCOMPLETE_TOP_COLOR) 925 + .add_modifier(Modifier::BOLD) 926 + } else { 927 + Style::default().fg(MUTED_COLOR) 928 + }; 929 + content.push(Line::from(Span::styled( 930 + format!("{cursor}{display_name}"), 931 + style, 932 + ))); 933 + } 934 + content.push(Line::raw("")); 935 + content.push(wizard_hint("↑↓", "Navigate", "Enter", "Select")); 936 + " Setup: Choose Provider " 937 + } 938 + WizardStep::EnterApiKey => { 939 + content.push(render_wizard_field( 940 + "API Key", 941 + &wizard.api_key, 942 + wizard.api_key_cursor, 943 + true, 944 + )); 945 + content.push(Line::raw("")); 946 + content.push(wizard_hint("Enter", "Next", "Esc", "Back")); 947 + " Setup: API Key " 948 + } 949 + WizardStep::EnterBaseUrl => { 950 + let default_hint = match wizard.provider_key() { 951 + "ein_openrouter" => " (default: https://openrouter.ai/api/v1)", 952 + "ein_ollama" => " (default: http://localhost:11434)", 953 + _ => " (leave blank for api.openai.com)", 954 + }; 955 + content.push(render_wizard_field( 956 + "Base URL", 957 + &wizard.base_url, 958 + wizard.base_url_cursor, 959 + false, 960 + )); 961 + content.push(Line::from(Span::styled( 962 + default_hint.to_string(), 963 + Style::default().fg(MUTED_COLOR), 964 + ))); 965 + content.push(Line::raw("")); 966 + content.push(wizard_hint("Enter", "Next", "Esc", "Back")); 967 + " Setup: Base URL " 968 + } 969 + WizardStep::EnterModel => { 970 + content.push(render_wizard_field( 971 + "Model", 972 + &wizard.model, 973 + wizard.model_cursor, 974 + false, 975 + )); 976 + content.push(Line::raw("")); 977 + content.push(wizard_hint("Enter", "Next", "Esc", "Back")); 978 + " Setup: Model " 979 + } 980 + WizardStep::Confirm => { 981 + let provider_name = PROVIDERS[wizard.provider_idx].1; 982 + let key_chars: Vec<char> = wizard.api_key.chars().collect(); 983 + let masked_key = if key_chars.len() > 4 { 984 + format!( 985 + "*****{}", 986 + key_chars[key_chars.len() - 4..].iter().collect::<String>() 987 + ) 988 + } else if key_chars.is_empty() { 989 + "(none)".to_string() 990 + } else { 991 + "*****".to_string() 992 + }; 993 + 994 + content.push(Line::from(vec![ 995 + Span::styled(" Provider : ", Style::default().fg(MUTED_COLOR)), 996 + Span::styled( 997 + provider_name.to_string(), 998 + Style::default().fg(AUTOCOMPLETE_TOP_COLOR), 999 + ), 1000 + ])); 1001 + 1002 + if !wizard.api_key.is_empty() { 1003 + content.push(Line::from(vec![ 1004 + Span::styled(" API Key : ", Style::default().fg(MUTED_COLOR)), 1005 + Span::styled(masked_key, Style::default().fg(AUTOCOMPLETE_TOP_COLOR)), 1006 + ])); 1007 + } 1008 + 1009 + let effective_url: &str = match wizard.provider_key() { 1010 + "ein_openrouter" if wizard.base_url.is_empty() => "https://openrouter.ai/api/v1", 1011 + "ein_ollama" if wizard.base_url.is_empty() => "http://localhost:11434", 1012 + _ => &wizard.base_url, 1013 + }; 1014 + if !effective_url.is_empty() { 1015 + content.push(Line::from(vec![ 1016 + Span::styled(" Base URL : ", Style::default().fg(MUTED_COLOR)), 1017 + Span::styled( 1018 + effective_url.to_string(), 1019 + Style::default().fg(AUTOCOMPLETE_TOP_COLOR), 1020 + ), 1021 + ])); 1022 + } 1023 + 1024 + if !wizard.model.is_empty() { 1025 + content.push(Line::from(vec![ 1026 + Span::styled(" Model : ", Style::default().fg(MUTED_COLOR)), 1027 + Span::styled( 1028 + wizard.model.clone(), 1029 + Style::default().fg(AUTOCOMPLETE_TOP_COLOR), 1030 + ), 1031 + ])); 1032 + } 1033 + 1034 + if let Some(err) = &wizard.error { 1035 + content.push(Line::raw("")); 1036 + content.push(Line::from(Span::styled( 1037 + format!(" Error: {err}"), 1038 + Style::default().fg(DISCONNECTED_COLOR), 1039 + ))); 1040 + } 1041 + 1042 + content.push(Line::raw("")); 1043 + content.push(wizard_hint("Enter", "Save", "Esc", "Back")); 1044 + " Setup: Confirm " 1045 + } 1046 + }; 1047 + 1048 + let modal_height = (content.len() as u16) + 2; // +2 for borders 1049 + let area = centered_rect(modal_width, modal_height, frame.area()); 1050 + 1051 + frame.render_widget(Clear, area); 1052 + 1053 + let block = Block::default() 1054 + .title(title) 1055 + .borders(Borders::ALL) 1056 + .border_style(Style::default().fg(INPUT_BORDER_COLOR)); 1057 + let inner = block.inner(area); 1058 + frame.render_widget(block, area); 1059 + 1060 + frame.render_widget(Paragraph::new(content), inner); 822 1061 } 823 1062 824 1063 fn format_session_date(unix_secs: i64) -> String {
+6 -25
packages/ein_openrouter/src/lib.rs
··· 63 63 .bearer_auth(&self.config.api_key) 64 64 .json(&req)? 65 65 .send() 66 - .map_err(|e| { 67 - let is_local = self.config.base_url.contains("localhost") 68 - || self.config.base_url.contains("127.0.0.1"); 69 - if is_local { 70 - anyhow!( 71 - "Could not connect to {}.\n\ 72 - Is the server running? (e.g. `ollama serve`)\n\ 73 - Details: {e}", 74 - self.config.base_url 75 - ) 76 - } else { 77 - anyhow!("Could not connect to {}: {e}", self.config.base_url) 78 - } 79 - })?; 66 + .map_err(|e| anyhow!("Could not connect to {}: {e}", self.config.base_url))?; 80 67 81 68 match resp.status { 82 69 401 => { ··· 98 85 404 => { 99 86 let msg = extract_api_error(&resp.body) 100 87 .unwrap_or_else(|| "Resource not found".to_owned()); 101 - let is_local = self.config.base_url.contains("localhost") 102 - || self.config.base_url.contains("127.0.0.1"); 103 - let hint = if is_local { 104 - "\n\nThe model may not be downloaded yet. Try:\n ollama pull <model-name>" 105 - .to_owned() 106 - } else { 107 - String::new() 108 - }; 109 - return Err(anyhow!("{msg}{hint}")); 88 + return Err(anyhow!("{msg}")); 110 89 } 111 90 s if !(200..300).contains(&s) => { 112 - let msg = extract_api_error(&resp.body).unwrap_or_else(|| format!("HTTP {s}")); 113 - return Err(anyhow!("API error: {msg}")); 91 + let status = format!("HTTP {s}"); 92 + let msg = extract_api_error(&resp.body).unwrap_or_default(); 93 + 94 + return Err(anyhow!("Status {status}. API error: {msg}")); 114 95 } 115 96 _ => {} 116 97 }