diff --git a/Cargo.lock b/Cargo.lock index fc08116..6264b30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,7 @@ dependencies = [ "llm", "providers", "ratatui", + "serde", "serde_json", "strum", "tokio", diff --git a/README.md b/README.md index 3755a2e..9c35d48 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Alan stores credentials at `~/.alan/auth.json` by default. Set `ALAN_HOME` to ch matches anywhere; `@src/co` matches by directory). - Slash commands: - `/login` — sign in to a provider interactively + - `/settings` — view and change settings - `/plan` — toggle plan mode (also `Shift+Tab`) - `/help` — list available commands - Key bindings: `Esc` clears input/selection, `Ctrl+C` interrupts the agent, @@ -67,14 +68,35 @@ All variables are optional: | `ALAN_MODEL` | Model id | `openai/gpt-4o-mini` | | `ALAN_HOME` | Alan home directory (Alan uses `$ALAN_HOME/.alan/`) | `$HOME` | | `ALAN_SESSION` | Resume this session ID from the current working directory | unset | -| `ALAN_REASONING_EFFORT` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` | unset | +| `ALAN_REASONING_EFFORT` | `auto` (model decides), `none` (off), `minimal`, `low`, `medium`, `high`, `xhigh`, `max` | `auto` | | `ALAN_OPENROUTER_WEB_SEARCH` | Enable web search tool (`1`, `true`, `yes`, `on`) | off | | `ALAN_OPENROUTER_WEB_FETCH` | Enable web fetch tool (`1`, `true`, `yes`, `on`) | off | | `ALAN_LOG` | Log filter (falls back to `RUST_LOG`) | unset | | `ALAN_LOG_DIR` | Where daily log files go | `$ALAN_HOME/.alan/logs` | -Files live under `$ALAN_HOME/.alan/`: `auth.json` (credentials), -`sessions/` (conversation history), and `logs/`. +Files live under `$ALAN_HOME/.alan/`: `settings.json` (see below), `auth.json` +(credentials), `sessions/` (conversation history), and `logs/`. + +## Settings + +Run `/settings` to view and change settings. `↑↓` moves, `Enter` cycles a value +or opens a prompt, `Backspace` clears a row, `Tab` switches between project and +global scope. Each row shows which layer its value came from. + +Settings live in `$ALAN_HOME/.alan/settings.json` and +`/.alan/settings.json`. Neither is created until you change something, +and each lists only the keys you set: + +```json +{ + "model": "anthropic/claude-opus-4", + "reasoning_effort": "high", + "max_tool_rounds": 30, + "tools": { "web_search": true } +} +``` + +Project settings override global ones, and environment variables override both. ## Development diff --git a/crates/agent/src/agent/mod.rs b/crates/agent/src/agent/mod.rs index 6db90c6..31a80e2 100644 --- a/crates/agent/src/agent/mod.rs +++ b/crates/agent/src/agent/mod.rs @@ -96,6 +96,21 @@ impl Agent { .map(|session| session.id.clone()) } + /// Swap the bound model for the rest of this session. + pub async fn set_model(&self, model: Model) -> Result<(), AgentError> { + persistence::persist_model( + self, + model.info().provider.0.clone(), + model.info().id.clone(), + model.reasoning_effort(), + ) + .await?; + + *self.model.lock().await = model; + + Ok(()) + } + pub fn set_mode(&self, mode: Mode) { self.mode.store(mode.as_u8(), Ordering::Release); self.review_intro_pending diff --git a/crates/agent/src/agent/persistence.rs b/crates/agent/src/agent/persistence.rs index 7fdfccb..02f9221 100644 --- a/crates/agent/src/agent/persistence.rs +++ b/crates/agent/src/agent/persistence.rs @@ -64,6 +64,26 @@ async fn persist_message(agent: &Agent, message: &AgentMessage) -> Result<(), Ag Ok(()) } +/// Applies the change in memory too, so both describe the same thing. +pub(super) async fn persist_model( + agent: &Agent, + provider: String, + model: String, + reasoning_effort: llm::ReasoningEffort, +) -> Result<(), AgentError> { + let mut active_session = agent.active_session.lock().await; + let (Some(manager), Some(session)) = (&agent.session_manager, &mut *active_session) else { + return Ok(()); + }; + + let record = manager + .append_model(&session.id, &session.pwd, provider, model, reasoning_effort) + .await?; + session.record(record); + + Ok(()) +} + pub(super) async fn persist_usage(agent: &Agent, usage: &Usage) -> Result<(), AgentError> { let active_session = agent.active_session.lock().await; if let (Some(manager), Some(session)) = (&agent.session_manager, &*active_session) { diff --git a/crates/agent/src/agent/tests.rs b/crates/agent/src/agent/tests.rs index 69ed562..0996e38 100644 --- a/crates/agent/src/agent/tests.rs +++ b/crates/agent/src/agent/tests.rs @@ -697,7 +697,7 @@ async fn resumed_agent_includes_restored_messages_in_first_request() { let manager = Arc::new(SessionManager::new(&root)); let session = manager - .create(&root, "openrouter", "test", None) + .create(&root, "openrouter", "test", llm::ReasoningEffort::Auto) .await .expect("create session"); manager @@ -898,7 +898,7 @@ async fn session_images_roundtrip_through_reload() { let manager = Arc::new(SessionManager::new(&root)); let session = manager - .create(&root, "openrouter", "test", None) + .create(&root, "openrouter", "test", llm::ReasoningEffort::Auto) .await .expect("create session"); let images = vec![llm::ImageUrl { diff --git a/crates/agent/src/session/manager.rs b/crates/agent/src/session/manager.rs index f7103ca..292b5ea 100644 --- a/crates/agent/src/session/manager.rs +++ b/crates/agent/src/session/manager.rs @@ -38,11 +38,11 @@ impl SessionManager { pwd: impl Into, provider: impl Into, model: impl Into, - thinking_level: Option, + reasoning_effort: ReasoningEffort, ) -> Result { let pwd = normalize(pwd.into())?; let key = pwd_key(&pwd); - let session = Session::new(pwd, provider, model, thinking_level); + let session = Session::new(pwd, provider, model, reasoning_effort); validate_session_id(&session.id)?; let path = self.file_path(&key, &session.id); // `file_path` always builds `root/key/name.jsonl`, so parent is @@ -102,6 +102,25 @@ impl SessionManager { self.append_record(session_id, pwd, &record).await } + pub async fn append_model( + &self, + session_id: &str, + pwd: &Path, + provider: impl Into, + model: impl Into, + reasoning_effort: ReasoningEffort, + ) -> Result { + let record = SessionRecord::Model { + provider: provider.into(), + model: model.into(), + reasoning_effort, + timestamp_ms: now_ms(), + }; + + self.append_record(session_id, pwd, &record).await?; + Ok(record) + } + pub async fn get_session(&self, session_id: &str, pwd: &Path) -> Result { validate_session_id(session_id)?; let normalized = normalize(pwd.to_path_buf())?; @@ -203,7 +222,7 @@ fn parse_header( pwd, provider, model, - thinking_level, + reasoning_effort, created_at_ms, updated_at_ms, } = record @@ -244,7 +263,7 @@ fn parse_header( pwd, provider, model, - thinking_level, + reasoning_effort, messages: Vec::new(), usage: Usage::default(), created_at_ms, @@ -287,7 +306,12 @@ mod tests { let manager = SessionManager::new(&root); let session = manager - .create("/tmp/project", "openrouter", "test-model", None) + .create( + "/tmp/project", + "openrouter", + "test-model", + ReasoningEffort::Auto, + ) .await .expect("create session"); @@ -314,7 +338,12 @@ mod tests { let manager = SessionManager::new(&root); manager - .create("/tmp/project", "openrouter", "test-model", None) + .create( + "/tmp/project", + "openrouter", + "test-model", + ReasoningEffort::Auto, + ) .await .expect("create session without a pre-existing root"); @@ -338,11 +367,11 @@ mod tests { let manager = SessionManager::new(&root); manager - .create("/tmp/a", "openrouter", "m", None) + .create("/tmp/a", "openrouter", "m", ReasoningEffort::Auto) .await .expect("create a"); manager - .create("/tmp/b", "openrouter", "m", None) + .create("/tmp/b", "openrouter", "m", ReasoningEffort::Auto) .await .expect("create b"); @@ -361,7 +390,7 @@ mod tests { "/tmp/project", "openrouter", "test-model", - Some(ReasoningEffort::High), + ReasoningEffort::High, ) .await .expect("create"); @@ -393,7 +422,7 @@ mod tests { assert_eq!(loaded.pwd, session.pwd); assert_eq!(loaded.provider, "openrouter"); assert_eq!(loaded.model, "test-model"); - assert_eq!(loaded.thinking_level, Some(ReasoningEffort::High)); + assert_eq!(loaded.reasoning_effort, ReasoningEffort::High); assert_eq!(loaded.created_at_ms, session.created_at_ms); assert_eq!( loaded.messages, @@ -408,7 +437,7 @@ mod tests { let root = temp_root("usage"); let manager = SessionManager::new(&root); let session = manager - .create("/tmp/p", "o", "m", None) + .create("/tmp/p", "o", "m", ReasoningEffort::Auto) .await .expect("create"); @@ -434,7 +463,7 @@ mod tests { let root = temp_root("truncated"); let manager = SessionManager::new(&root); let session = manager - .create("/tmp/p", "o", "m", None) + .create("/tmp/p", "o", "m", ReasoningEffort::Auto) .await .expect("create"); let path = session_file(&root, "/tmp/p", &session.id); @@ -471,7 +500,7 @@ mod tests { let root = temp_root("cross"); let manager = SessionManager::new(&root); let session = manager - .create("/tmp/p", "o", "m", None) + .create("/tmp/p", "o", "m", ReasoningEffort::Auto) .await .expect("create"); @@ -496,14 +525,14 @@ mod tests { let pwd = "/tmp/p"; let session = manager - .create(pwd, "o", "m", None) + .create(pwd, "o", "m", ReasoningEffort::Auto) .await .expect("first create"); let before = std::fs::read_to_string(session_file(&root, pwd, &session.id)).unwrap(); // Simulate a second create racing onto the same id: the exclusive // file creation must refuse rather than truncate the existing file. - let mut collision = Session::new(pwd, "o", "m", None); + let mut collision = Session::new(pwd, "o", "m", ReasoningEffort::Auto); collision.id = session.id.clone(); collision.created_at_ms = 123_456; collision.updated_at_ms = 123_456; @@ -526,7 +555,10 @@ mod tests { let root = temp_root("header"); let manager = SessionManager::new(&root); let pwd = "/tmp/p"; - let session = manager.create(pwd, "o", "m", None).await.expect("create"); + let session = manager + .create(pwd, "o", "m", ReasoningEffort::Auto) + .await + .expect("create"); let path = session_file(&root, pwd, &session.id); // Wrong schema version. @@ -543,7 +575,10 @@ mod tests { assert!(matches!(err, SessionError::UnsupportedVersion { .. })); // Mismatched header id (fresh file). - let session = manager.create(pwd, "o", "m", None).await.expect("create"); + let session = manager + .create(pwd, "o", "m", ReasoningEffort::Auto) + .await + .expect("create"); let path = session_file(&root, pwd, &session.id); let content = std::fs::read_to_string(&path).unwrap(); std::fs::write(&path, content.replace(&session.id, "other-id")).unwrap(); @@ -554,7 +589,10 @@ mod tests { assert!(matches!(err, SessionError::InvalidHeader { .. })); // Mismatched header pwd / cross-directory load (fresh file). - let session = manager.create(pwd, "o", "m", None).await.expect("create"); + let session = manager + .create(pwd, "o", "m", ReasoningEffort::Auto) + .await + .expect("create"); let path = session_file(&root, pwd, &session.id); let content = std::fs::read_to_string(&path).unwrap(); std::fs::write(&path, content.replace(pwd, "/elsewhere")).unwrap(); @@ -570,7 +608,7 @@ mod tests { async fn append_to_missing_session_fails() { let root = temp_root("missing-append"); let manager = SessionManager::new(&root); - let session = Session::new("/tmp/p", "o", "m", None); + let session = Session::new("/tmp/p", "o", "m", ReasoningEffort::Auto); let err = manager .append_message(&session.id, &session.pwd, &AgentMessage::user("hi")) @@ -588,7 +626,7 @@ mod tests { let root = temp_root("perms"); let manager = SessionManager::new(&root); let session = manager - .create("/tmp/p", "o", "m", None) + .create("/tmp/p", "o", "m", ReasoningEffort::Auto) .await .expect("create"); diff --git a/crates/agent/src/session/record.rs b/crates/agent/src/session/record.rs index 4a72a32..836fdbb 100644 --- a/crates/agent/src/session/record.rs +++ b/crates/agent/src/session/record.rs @@ -21,7 +21,7 @@ pub struct Session { pub pwd: PathBuf, pub provider: String, pub model: String, - pub thinking_level: Option, + pub reasoning_effort: ReasoningEffort, pub messages: Vec, pub usage: Usage, pub created_at_ms: u64, @@ -33,7 +33,7 @@ impl Session { pwd: impl Into, provider: impl Into, model: impl Into, - thinking_level: Option, + reasoning_effort: ReasoningEffort, ) -> Self { let now = now_ms(); Self { @@ -42,7 +42,7 @@ impl Session { pwd: pwd.into(), provider: provider.into(), model: model.into(), - thinking_level, + reasoning_effort, messages: Vec::new(), usage: Usage::default(), created_at_ms: now, @@ -58,7 +58,7 @@ impl Session { pwd: self.pwd.clone(), provider: self.provider.clone(), model: self.model.clone(), - thinking_level: self.thinking_level, + reasoning_effort: self.reasoning_effort, created_at_ms: self.created_at_ms, updated_at_ms: self.updated_at_ms, } @@ -71,6 +71,16 @@ impl Session { SessionRecord::Session { .. } => {} SessionRecord::Message { message, .. } => self.messages.push(message), SessionRecord::Usage { usage, .. } => self.usage = usage, + SessionRecord::Model { + provider, + model, + reasoning_effort, + .. + } => { + self.provider = provider; + self.model = model; + self.reasoning_effort = reasoning_effort; + } } self.updated_at_ms = self.updated_at_ms.max(timestamp_ms); } @@ -92,7 +102,7 @@ pub enum SessionRecord { pwd: PathBuf, provider: String, model: String, - thinking_level: Option, + reasoning_effort: ReasoningEffort, created_at_ms: u64, updated_at_ms: u64, }, @@ -104,6 +114,12 @@ pub enum SessionRecord { usage: Usage, timestamp_ms: u64, }, + Model { + provider: String, + model: String, + reasoning_effort: ReasoningEffort, + timestamp_ms: u64, + }, } impl SessionRecord { @@ -111,7 +127,9 @@ impl SessionRecord { pub(super) fn timestamp_ms(&self) -> u64 { match self { Self::Session { updated_at_ms, .. } => *updated_at_ms, - Self::Message { timestamp_ms, .. } | Self::Usage { timestamp_ms, .. } => *timestamp_ms, + Self::Message { timestamp_ms, .. } + | Self::Usage { timestamp_ms, .. } + | Self::Model { timestamp_ms, .. } => *timestamp_ms, } } @@ -180,9 +198,46 @@ mod tests { ); } + /// The header pins the model a session *started* with. Replaying a `Model` + /// record moves that forward, which is what lets the bound model change + /// mid-session without the resume check rejecting the file. + #[test] + fn replaying_a_model_record_moves_the_session_to_the_new_model() { + let mut session = Session::new( + "/tmp/project", + "openrouter", + "old-model", + ReasoningEffort::Auto, + ); + // `record` advances `updated_at_ms` monotonically, so start it behind + // the record's timestamp rather than at wall-clock now. + session.updated_at_ms = 1_000; + + session.record(SessionRecord::Model { + provider: "openrouter".into(), + model: "new-model".into(), + reasoning_effort: ReasoningEffort::High, + timestamp_ms: 5_000, + }); + + assert_eq!(session.model, "new-model"); + assert_eq!(session.reasoning_effort, ReasoningEffort::High); + assert_eq!(session.updated_at_ms, 5_000); + // The header a fresh writer would emit now describes the current model. + assert!(matches!( + session.header_record(), + SessionRecord::Session { model, .. } if model == "new-model" + )); + } + #[test] fn header_record_preserves_session_metadata() { - let mut session = Session::new("/tmp/project", "openrouter", "test-model", None); + let mut session = Session::new( + "/tmp/project", + "openrouter", + "test-model", + ReasoningEffort::Auto, + ); session.created_at_ms = 1_000; session.updated_at_ms = 2_000; @@ -193,7 +248,7 @@ mod tests { pwd, provider, model, - thinking_level, + reasoning_effort, created_at_ms, updated_at_ms, } => { @@ -202,7 +257,7 @@ mod tests { assert_eq!(pwd, PathBuf::from("/tmp/project")); assert_eq!(provider, "openrouter"); assert_eq!(model, "test-model"); - assert_eq!(thinking_level, None); + assert_eq!(reasoning_effort, ReasoningEffort::Auto); assert_eq!(created_at_ms, 1_000); assert_eq!(updated_at_ms, 2_000); } @@ -212,7 +267,12 @@ mod tests { #[test] fn apply_record_appends_messages_and_replaces_usage_snapshot() { - let mut session = Session::new("/tmp/project", "openrouter", "test-model", None); + let mut session = Session::new( + "/tmp/project", + "openrouter", + "test-model", + ReasoningEffort::Auto, + ); session.record(SessionRecord::Message { message: AgentMessage::user("first"), diff --git a/crates/alan/Cargo.toml b/crates/alan/Cargo.toml index 103cec3..08abc11 100644 --- a/crates/alan/Cargo.toml +++ b/crates/alan/Cargo.toml @@ -13,6 +13,7 @@ tui-textarea-2 = "0.13" anyhow = "1" tokio = { workspace = true } futures-util = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } strum = { workspace = true } async-trait = { workspace = true } diff --git a/crates/alan/src/core/action.rs b/crates/alan/src/core/action.rs index 44f9524..ca676b9 100644 --- a/crates/alan/src/core/action.rs +++ b/crates/alan/src/core/action.rs @@ -16,7 +16,7 @@ pub enum Action { ScrollDown, MouseScrollUp, MouseScrollDown, - TogglePlanMode, + Cycle, } /// An image attached to the next prompt via clipboard paste. @@ -37,6 +37,16 @@ pub enum Command { text: String, images: Vec, }, - MoveLoginSelection(isize), - TogglePlanMode, + /// The settings scope while its list is open, the agent mode otherwise. + Cycle, + MoveSelection(isize), + ClearSelection, +} + +/// What the visible surface wants from navigation and editing keys. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum InputMode { + #[default] + Prompt, + List, } diff --git a/crates/alan/src/core/chat.rs b/crates/alan/src/core/chat.rs index 603e5ce..4cd750e 100644 --- a/crates/alan/src/core/chat.rs +++ b/crates/alan/src/core/chat.rs @@ -6,6 +6,7 @@ use agent::{Agent, AgentEvent, AgentStream}; use llm::Usage; use std::sync::Arc; use std::time::{Duration, Instant}; +use tokio::sync::mpsc; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Entry { @@ -35,6 +36,9 @@ const POLL_TIME_BUDGET: Duration = Duration::from_millis(2); pub struct ChatController { agent: Arc, + swap_error_sender: mpsc::UnboundedSender, + /// Failed model swaps, which are async and so cannot report inline. + swap_error_receiver: mpsc::UnboundedReceiver, entries: Vec, stream: Option, busy: bool, @@ -45,8 +49,12 @@ pub struct ChatController { impl ChatController { pub fn new(agent: Agent) -> Self { + let (swap_error_sender, swap_error_receiver) = mpsc::unbounded_channel(); + Self { agent: Arc::new(agent), + swap_error_sender, + swap_error_receiver, entries: Vec::new(), stream: None, busy: false, @@ -56,6 +64,19 @@ impl ChatController { } } + /// Swap the bound model. The write is async, so failure arrives through + /// [`poll`](Self::poll) rather than being returned here. Success is silent. + pub fn set_model(&mut self, model: providers::Model) { + let agent = self.agent.clone(); + let report = self.swap_error_sender.clone(); + + tokio::spawn(async move { + if let Err(error) = agent.set_model(model).await { + let _ = report.send(error); + } + }); + } + pub async fn session_id(&self) -> Option { self.agent.session_id().await } @@ -147,6 +168,11 @@ impl ChatController { self.revision = self.revision.wrapping_add(1); } + pub fn push_error(&mut self, text: impl Into) { + self.entries.push(Entry::Error(text.into())); + self.revision = self.revision.wrapping_add(1); + } + pub fn submit(&mut self, text: impl Into, images: Vec) { let text = text.into(); let text = text.trim(); @@ -196,6 +222,15 @@ impl ChatController { } pub fn poll(&mut self) -> Poll { + let mut outcome = Poll::Idle; + while let Ok(error) = self.swap_error_receiver.try_recv() { + self.push_error(format!("settings: {error}")); + outcome = Poll::Changed; + } + outcome.combine(self.poll_stream()) + } + + fn poll_stream(&mut self) -> Poll { let Some(mut stream) = self.stream.take() else { return Poll::Idle; }; diff --git a/crates/alan/src/core/command.rs b/crates/alan/src/core/command.rs index 876c9be..5bf4e8f 100644 --- a/crates/alan/src/core/command.rs +++ b/crates/alan/src/core/command.rs @@ -9,6 +9,7 @@ use strum::{EnumIter, EnumString, IntoEnumIterator, IntoStaticStr}; #[strum(serialize_all = "kebab-case")] pub enum SlashCommand { Login, + Settings, Plan, Review, Normal, @@ -38,6 +39,7 @@ impl SlashCommand { pub fn description(self) -> &'static str { match self { Self::Login => "sign in to a provider", + Self::Settings => "view and change settings", Self::Plan => "turn on plan mode (also Shift+Tab)", Self::Review => "turn on review mode (also Shift+Tab)", Self::Normal => "turn off plan and review mode", diff --git a/crates/alan/src/core/controller.rs b/crates/alan/src/core/controller.rs index a891a21..b4d6b25 100644 --- a/crates/alan/src/core/controller.rs +++ b/crates/alan/src/core/controller.rs @@ -1,10 +1,11 @@ //! UI-independent application coordinator. -use super::action::{Command, ImageAttachment}; +use super::action::{Command, ImageAttachment, InputMode}; use super::chat::{ChatController, Entry}; use super::command::SlashCommand; use super::completion::{Commands, CompletionController, Paths}; use super::login::{LoginController, LoginState}; +use super::settings::{self, SettingsController}; use agent::Agent; use llm::Usage; use providers::{CredentialStore, ProviderRegistry}; @@ -35,6 +36,7 @@ impl Poll { pub enum Overlay { None, Login, + Settings, } /// What the prompt is doing, and so what Enter does to it. @@ -53,33 +55,27 @@ pub struct Controller { chat: ChatController, login: LoginController, completion: CompletionController, + settings: SettingsController, + providers: ProviderRegistry, overlay: Overlay, } impl Controller { - // TODO: drop the attribute once a non-test caller exists. Only `#[cfg(test)]` - // code reaches this today, so the binary build reports it as dead. - #[allow(dead_code)] - pub fn new(agent: Agent) -> Self { - Self::with_runtime( - agent, - ProviderRegistry::default(), - Arc::new(providers::InMemoryCredentialStore::new()), - ) - } - - pub fn with_runtime( + pub fn new( agent: Agent, providers: ProviderRegistry, credentials: Arc, + settings: SettingsController, ) -> Self { Self { chat: ChatController::new(agent), - login: LoginController::new(providers, credentials), + login: LoginController::new(providers.clone(), credentials), completion: CompletionController::new(vec![ Box::new(Paths::default()), Box::new(Commands::default()), ]), + settings, + providers, overlay: Overlay::None, } } @@ -123,10 +119,6 @@ impl Controller { self.login.state() } - pub fn login_selection_active(&self) -> bool { - matches!(self.login.state(), LoginState::Selecting { .. }) - } - pub fn completion(&self) -> &CompletionController { &self.completion } @@ -139,19 +131,67 @@ impl Controller { self.overlay } + pub fn input_mode(&self, overlay: Overlay) -> InputMode { + let showing_list = match overlay { + Overlay::Login => matches!(self.login.state(), LoginState::Selecting { .. }), + Overlay::Settings => !self.settings.editing(), + Overlay::None => false, + }; + + if showing_list { + InputMode::List + } else { + InputMode::Prompt + } + } + pub fn poll(&mut self) -> Poll { self.chat .poll() .combine(self.login.poll()) .combine(self.completion.poll()) + .combine(self.poll_settings()) + } + + /// Skipped while streaming: swapping the model mid-response would leave a + /// transcript half-answered by each. The next idle poll picks it up. + fn poll_settings(&mut self) -> Poll { + if self.chat.is_busy() { + return Poll::Idle; + } + + match self.settings.poll() { + Ok(Poll::Changed) => { + self.apply_settings(); + Poll::Changed + } + Ok(poll) => poll, + Err(error) => { + self.chat.push_error(format!("settings: {error}")); + Poll::Error + } + } + } + + fn apply_settings(&mut self) { + let next = self.settings.current().clone(); + match settings::bind(&self.providers, &next) { + Ok(model) => self.chat.set_model(model), + Err(error) => self + .chat + .push_error(format!("settings: cannot use {}: {error}", next.model)), + } } pub fn handle(&mut self, command: Command) -> bool { match command { Command::Interrupt => self.abort_or_quit(), Command::Cancel => { - if self.overlay == Overlay::Login { - self.close_login(); + match self.overlay { + Overlay::Login => self.close_login(), + Overlay::Settings if self.settings.editing() => self.settings.cancel_edit(), + Overlay::Settings => self.close_settings(), + Overlay::None => {} } false } @@ -159,21 +199,44 @@ impl Controller { self.submit(text, images); false } - Command::MoveLoginSelection(delta) => { - self.move_login_selection(delta); + Command::Cycle => { + match self.overlay { + Overlay::Settings => self.settings.toggle_scope(), + // Login hides the footer the mode is shown in. + Overlay::Login => {} + Overlay::None => self.chat.toggle_mode(), + } false } - Command::TogglePlanMode => { - self.chat.toggle_mode(); + Command::MoveSelection(delta) => { + match self.overlay { + Overlay::Login => self.login.move_selection(delta), + Overlay::Settings => self.settings.move_selection(delta), + Overlay::None => {} + } + false + } + Command::ClearSelection => { + if self.overlay == Overlay::Settings { + let outcome = self.settings.clear(); + self.settings_action(outcome); + } false } } } fn abort_or_quit(&mut self) -> bool { - if self.overlay == Overlay::Login { - self.close_login(); - return false; + match self.overlay { + Overlay::Login => { + self.close_login(); + return false; + } + Overlay::Settings => { + self.close_settings(); + return false; + } + Overlay::None => {} } if self.chat.is_busy() { self.chat.abort(); @@ -187,11 +250,21 @@ impl Controller { self.login.submit(text); return; } + if self.overlay == Overlay::Settings { + let outcome = if self.settings.editing() { + self.settings.submit_edit(&text) + } else { + self.settings.activate() + }; + self.settings_action(outcome); + return; + } // Not trimmed: a leading space means this is a prompt. if let Some(command) = SlashCommand::parse(&text) { match command { SlashCommand::Login => self.open_login(), + SlashCommand::Settings => self.open_settings(), SlashCommand::Plan => self.chat.set_mode(agent::Mode::Plan), SlashCommand::Review => self.chat.set_mode(agent::Mode::Review), SlashCommand::Normal => self.chat.set_mode(agent::Mode::Normal), @@ -208,13 +281,37 @@ impl Controller { self.chat.submit(text.to_owned(), images); } - pub fn open_login(&mut self) { - self.login.open(); - self.overlay = Overlay::Login; + fn open_settings(&mut self) { + self.settings.open(); + self.overlay = Overlay::Settings; + } + + fn close_settings(&mut self) { + self.settings.close(); + self.overlay = Overlay::None; + } + + pub fn settings(&self) -> &SettingsController { + &self.settings + } + + /// Take the value a just-opened overlay prompt should start from. + pub fn take_input_seed(&mut self) -> Option { + self.settings.take_seed() } - pub fn move_login_selection(&mut self, delta: isize) { - self.login.move_selection(delta); + /// Report a settings action's outcome and apply whatever it changed. + fn settings_action(&mut self, outcome: settings::Outcome) { + match outcome { + Ok(true) => self.apply_settings(), + Ok(false) => {} + Err(reason) => self.chat.push_error(format!("settings: {reason}")), + } + } + + fn open_login(&mut self) { + self.login.open(); + self.overlay = Overlay::Login; } fn close_login(&mut self) { @@ -231,9 +328,39 @@ mod tests { use providers::{ ApiId, ModelCapabilities, ModelInfo, OpenRouterProvider, Provider, ProviderId, }; + use std::path::PathBuf; use std::sync::Arc; + use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; + /// Owns the settings directory, so a test that writes a setting cannot + /// reach outside its scratch space. + struct TestController { + controller: Controller, + settings_dir: PathBuf, + } + + /// So a test leaves the filesystem as it found it, panic or not. + impl Drop for TestController { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.settings_dir); + } + } + + impl std::ops::Deref for TestController { + type Target = Controller; + + fn deref(&self) -> &Controller { + &self.controller + } + } + + impl std::ops::DerefMut for TestController { + fn deref_mut(&mut self) -> &mut Controller { + &mut self.controller + } + } + struct FakeApi; #[async_trait] @@ -251,7 +378,8 @@ mod tests { } } - fn make_controller() -> Controller { + /// A controller with no provider, no credentials, and default settings. + fn controller_with(api: Arc) -> TestController { let info = ModelInfo { provider: ProviderId::new("openrouter"), id: "test".into(), @@ -262,12 +390,34 @@ mod tests { }; let model = OpenRouterProvider::builder("key") .with_models([info]) - .with_api(Arc::new(FakeApi)) + .with_api(api) .build() .unwrap() .bind("test") .unwrap(); - Controller::new(Agent::builder(model).build().unwrap()) + // Counted: several tests build a controller, and two sharing a name + // would delete each other's directory. + static NEXT: AtomicU64 = AtomicU64::new(0); + let settings_dir = std::env::temp_dir().join(format!( + "alan-controller-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&settings_dir); + std::fs::create_dir_all(&settings_dir).expect("settings dir"); + TestController { + controller: Controller::new( + Agent::builder(model).build().unwrap(), + ProviderRegistry::default(), + Arc::new(providers::InMemoryCredentialStore::new()), + SettingsController::new(&settings_dir, &settings_dir).expect("defaults"), + ), + settings_dir, + } + } + + fn make_controller() -> TestController { + controller_with(Arc::new(FakeApi)) } #[tokio::test] @@ -306,22 +456,7 @@ mod tests { #[tokio::test] async fn submit_streams_reasoning_and_response() { - let info = ModelInfo { - provider: ProviderId::new("openrouter"), - id: "test".into(), - name: "Test".into(), - api: ApiId::ChatCompletions, - capabilities: ModelCapabilities::default(), - pricing: None, - }; - let model = OpenRouterProvider::builder("key") - .with_models([info]) - .with_api(Arc::new(ReasoningFakeApi)) - .build() - .unwrap() - .bind("test") - .unwrap(); - let mut controller = Controller::new(Agent::builder(model).build().unwrap()); + let mut controller = controller_with(Arc::new(ReasoningFakeApi)); controller.submit("hi".into(), vec![]); tokio::time::sleep(Duration::from_millis(50)).await; @@ -401,16 +536,108 @@ mod tests { let mut controller = make_controller(); assert_eq!(controller.mode(), agent::Mode::Normal); - controller.handle(Command::TogglePlanMode); + controller.handle(Command::Cycle); assert_eq!(controller.mode(), agent::Mode::Plan); - controller.handle(Command::TogglePlanMode); + controller.handle(Command::Cycle); assert_eq!(controller.mode(), agent::Mode::Review); - controller.handle(Command::TogglePlanMode); + controller.handle(Command::Cycle); assert_eq!(controller.mode(), agent::Mode::Normal); } + /// The same key reaches the surface in front of you, so with the list open + /// it must not reach past it to the agent. + #[test] + fn shift_tab_cycles_the_settings_scope_while_that_list_is_open() { + let mut controller = make_controller(); + controller.submit("/settings".into(), vec![]); + let before = controller.settings().overlay().expect("open").scope; + + controller.handle(Command::Cycle); + + let overlay = controller.settings().overlay().expect("open"); + assert_ne!(overlay.scope, before, "the scope moved"); + assert_eq!(controller.mode(), agent::Mode::Normal, "the agent did not"); + } + + /// The login overlay covers the footer, so the mode it would change is not + /// visible while it is open. + #[test] + fn cycling_does_nothing_while_logging_in() { + let mut controller = make_controller(); + controller.submit("/login".into(), vec![]); + assert_eq!(controller.overlay(), Overlay::Login); + + controller.handle(Command::Cycle); + + assert_eq!(controller.mode(), agent::Mode::Normal, "the agent did not"); + } + + /// A row's value differs per scope, so a half-typed one belongs to the + /// scope being left. + #[test] + fn cycling_scope_abandons_a_row_being_edited() { + let mut controller = make_controller(); + controller.submit("/settings".into(), vec![]); + // Enter on `model`, a text row, opens its prompt. + controller.submit(String::new(), vec![]); + assert!(controller.settings().editing()); + + controller.handle(Command::Cycle); + + assert!(!controller.settings().editing(), "the prompt closed"); + assert_eq!(controller.overlay(), Overlay::Settings, "the list stayed"); + } + + /// Which overlay is open decides where a shared key lands. + #[test] + fn selection_keys_route_to_whichever_overlay_is_open() { + let mut controller = make_controller(); + controller.submit("/settings".into(), vec![]); + + controller.handle(Command::MoveSelection(1)); + assert_eq!( + controller.settings().overlay().expect("open").selected, + 1, + "the settings list moved" + ); + + // Backspace clears a settings row, and means nothing to the login list. + controller.handle(Command::ClearSelection); + controller.handle(Command::Cancel); + + controller.submit("/login".into(), vec![]); + assert_eq!(controller.overlay(), Overlay::Login); + controller.handle(Command::ClearSelection); + assert_eq!(controller.overlay(), Overlay::Login, "login ignores it"); + } + + /// The footer offers "Enter save · Esc cancel" for a row's prompt, so Esc + /// has to mean the prompt rather than the whole overlay. + #[test] + fn esc_while_typing_closes_the_prompt_not_the_overlay() { + let mut controller = make_controller(); + controller.submit("/settings".into(), vec![]); + assert_eq!(controller.overlay(), Overlay::Settings); + + // Enter on `model`, a text row, opens its prompt. + controller.submit(String::new(), vec![]); + assert!(controller.settings().editing()); + + controller.handle(Command::Cancel); + assert!(!controller.settings().editing(), "the prompt closed"); + assert_eq!( + controller.overlay(), + Overlay::Settings, + "the list is still open" + ); + + // A second Esc, now with no prompt open, closes the overlay. + controller.handle(Command::Cancel); + assert_eq!(controller.overlay(), Overlay::None); + } + /// Submitting a prompt spawns an agent task, so this needs a runtime. #[tokio::test] async fn unknown_slash_text_is_sent_as_a_prompt() { diff --git a/crates/alan/src/core/mod.rs b/crates/alan/src/core/mod.rs index 18e6791..068f926 100644 --- a/crates/alan/src/core/mod.rs +++ b/crates/alan/src/core/mod.rs @@ -6,8 +6,9 @@ pub mod command; pub mod completion; pub mod controller; pub mod login; +pub mod settings; -pub use action::{Action, Command, ImageAttachment}; +pub use action::{Action, Command, ImageAttachment, InputMode}; pub use chat::Entry; pub use command::SlashCommand; pub use completion::{Accept, CompletionController, CompletionItem, CompletionStatus}; diff --git a/crates/alan/src/core/settings/controller.rs b/crates/alan/src/core/settings/controller.rs new file mode 100644 index 0000000..3bb2076 --- /dev/null +++ b/crates/alan/src/core/settings/controller.rs @@ -0,0 +1,301 @@ +//! Keeps the running agent in step with the settings files. +//! +//! Polls mtime rather than watching for events: editors save atomically, so an +//! ordinary `:w` arrives as remove-then-create and an event watcher would need +//! to watch the directory, filter and debounce to catch it. A renamed file has +//! a different mtime either way. + +use super::{Layer, Layers, Settings, SettingsOverlay}; +use crate::core::Poll; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; + +const CHECK_INTERVAL: Duration = Duration::from_secs(1); + +/// `Ok(true)` means the settings changed and need applying. +pub type Outcome = Result; + +/// Includes the paths themselves, so a project file appearing or being deleted +/// counts as a change +type Fingerprint = Vec<(PathBuf, Option)>; + +pub struct SettingsController { + global_dir: PathBuf, + pub(super) cwd: PathBuf, + current: Settings, + /// Unfolded, so a single scope can be read and a key's origin found. + layers: Layers, + seen: Fingerprint, + next_check: Instant, + pub(super) overlay: Option, +} + +impl SettingsController { + /// Loads the files itself, by the same route [`reload`](Self::reload) uses. + pub fn new(global_dir: &Path, cwd: &Path) -> anyhow::Result { + let layers = super::load_settings_layers(global_dir, cwd)?; + let seen = fingerprint(global_dir, cwd); + + Ok(Self { + global_dir: global_dir.to_path_buf(), + cwd: cwd.to_path_buf(), + current: layers.resolve(), + layers, + seen, + next_check: Instant::now() + CHECK_INTERVAL, + overlay: None, + }) + } + + /// Writes to the file and reloads, so a change from the UI and one from an + /// editor take the same route. + pub(super) fn write(&mut self, key: &'static str, value: Option) -> Outcome { + let Some(path) = self.target() else { + return Ok(false); + }; + super::write_key(&path, key, value).map_err(|error| error.to_string())?; + self.seen = fingerprint(&self.global_dir, &self.cwd); + self.reload() + } + + /// What a row in `scope` displays and edits — not the resolved value. + pub fn as_of(&self, scope: Layer) -> Settings { + self.layers.resolve_as_of(scope) + } + + pub fn current(&self) -> &Settings { + &self.current + } + + pub fn origin(&self, key: &str) -> Layer { + self.layers.origin_of(key) + } + + /// The project file in force, if one exists. + pub fn project_file(&self) -> Option { + super::project_settings(&self.cwd, &super::global_path(&self.global_dir)) + } + + /// Where a write in this scope lands: the project file if there is one, + /// otherwise where it would be created. + pub(super) fn path(&self, scope: Layer) -> PathBuf { + match scope { + Layer::Project => self + .project_file() + .unwrap_or_else(|| super::project_target(&self.cwd)), + _ => super::global_path(&self.global_dir), + } + } + + /// `Ok(Poll::Changed)` means the caller should apply the new settings. + pub fn poll(&mut self) -> Result { + let now = Instant::now(); + if now < self.next_check { + return Ok(Poll::Idle); + } + self.next_check = now + CHECK_INTERVAL; + + let current = fingerprint(&self.global_dir, &self.cwd); + if current == self.seen { + return Ok(Poll::Idle); + } + // Recorded before the read, so a broken file is reported once. + self.seen = current; + match self.reload()? { + true => Ok(Poll::Changed), + false => Ok(Poll::Idle), + } + } + + /// Keeps the previous values if anything is wrong: a live session should + /// survive a typo made mid-edit. + fn reload(&mut self) -> Outcome { + let layers = super::load_settings_layers(&self.global_dir, &self.cwd) + .map_err(|error| format!("{error} — previous settings kept"))?; + let next = layers.resolve(); + self.layers = layers; + if next == self.current { + return Ok(false); + } + self.current = next; + Ok(true) + } +} + +fn fingerprint(global_dir: &Path, cwd: &Path) -> Fingerprint { + let global = super::global_path(global_dir); + let mut paths = vec![global.clone()]; + if let Some(project) = super::project_settings(cwd, &global) { + paths.push(project); + } + paths + .into_iter() + .map(|path| { + let modified = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()); + (path, modified) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::settings::files::{SETTINGS_DIR, SETTINGS_FILE}; + + struct Scratch { + root: PathBuf, + home: PathBuf, + project: PathBuf, + } + + /// So a test leaves the filesystem as it found it, panic or not. + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + impl Scratch { + fn new(name: &str) -> Self { + let root = + std::env::temp_dir().join(format!("alan-reload-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let home = root.join("home"); + let project = root.join("project"); + std::fs::create_dir_all(&home).expect("home"); + std::fs::create_dir_all(project.join(SETTINGS_DIR)).expect("project"); + // A repo boundary, so the walk cannot escape the temp dir. + std::fs::create_dir_all(project.join(".git")).expect("git"); + Self { + root, + home, + project, + } + } + + fn write_global(&self, contents: &str) { + write(&self.home.join(SETTINGS_FILE), contents); + } + + fn write_project(&self, contents: &str) { + write( + &self.project.join(SETTINGS_DIR).join(SETTINGS_FILE), + contents, + ); + } + + fn controller(&self) -> SettingsController { + SettingsController::new(&self.home, &self.project).expect("valid settings") + } + } + + fn write(path: &Path, contents: &str) { + std::fs::write(path, contents).expect("write"); + // Some filesystems have mtime resolution coarse enough that two writes + // in the same instant look identical. + let later = SystemTime::now() + Duration::from_secs(1); + let _ = std::fs::File::open(path).and_then(|file| file.set_modified(later)); + } + + /// Bypasses the throttle so tests need not sleep. + fn poll_now(controller: &mut SettingsController) -> Result { + controller.next_check = Instant::now(); + controller.poll() + } + + #[test] + fn an_unchanged_file_is_not_reloaded() { + let scratch = Scratch::new("unchanged"); + scratch.write_global(r#"{"model":"opus"}"#); + let mut controller = scratch.controller(); + + assert_eq!(poll_now(&mut controller), Ok(Poll::Idle)); + assert_eq!(poll_now(&mut controller), Ok(Poll::Idle)); + } + + #[test] + fn editing_the_file_applies_without_a_command() { + let scratch = Scratch::new("edited"); + scratch.write_global(r#"{"model":"first"}"#); + let mut controller = scratch.controller(); + assert_eq!(controller.current().model, "first"); + + scratch.write_global(r#"{"model":"second"}"#); + + assert_eq!(poll_now(&mut controller), Ok(Poll::Changed)); + assert_eq!(controller.current().model, "second"); + } + + /// Killing a session with history in it because someone typo'd mid-edit + /// would be worse than ignoring the edit. + #[test] + fn a_broken_edit_keeps_the_previous_settings() { + let scratch = Scratch::new("broken"); + scratch.write_global(r#"{"model":"good"}"#); + let mut controller = scratch.controller(); + + scratch.write_global("{ broken"); + let error = poll_now(&mut controller).expect_err("a broken file must report"); + + assert!(error.contains("previous settings kept"), "{error}"); + assert_eq!(controller.current().model, "good", "old values stay"); + } + + #[test] + fn a_broken_file_is_reported_once_not_every_check() { + let scratch = Scratch::new("broken-once"); + scratch.write_global(r#"{"model":"good"}"#); + let mut controller = scratch.controller(); + + scratch.write_global("{ broken"); + assert!(poll_now(&mut controller).is_err()); + + assert_eq!( + poll_now(&mut controller), + Ok(Poll::Idle), + "no second complaint until the file changes again" + ); + } + + #[test] + fn an_identical_rewrite_is_not_a_change() { + let scratch = Scratch::new("identical"); + scratch.write_global(r#"{"model":"same"}"#); + let mut controller = scratch.controller(); + + scratch.write_global(r#"{"model":"same"}"#); + assert_eq!(poll_now(&mut controller), Ok(Poll::Idle)); + } + + /// No file's mtime moved — the project file simply began to exist. + #[test] + fn a_project_file_appearing_is_noticed() { + let scratch = Scratch::new("appeared"); + scratch.write_global(r#"{"model":"global"}"#); + let mut controller = scratch.controller(); + assert_eq!(controller.current().model, "global"); + + scratch.write_project(r#"{"model":"project"}"#); + + assert_eq!(poll_now(&mut controller), Ok(Poll::Changed)); + assert_eq!(controller.current().model, "project"); + assert_eq!(controller.origin("model"), Layer::Project); + } + + #[test] + fn the_project_file_outranks_the_global_one_per_field() { + let scratch = Scratch::new("layered"); + scratch.write_global(r#"{"model":"global","tools":{"web_search":true}}"#); + scratch.write_project(r#"{"model":"project"}"#); + let controller = scratch.controller(); + + assert_eq!(controller.current().model, "project"); + assert_eq!(controller.origin("model"), Layer::Project); + // Untouched by the project file, so the global one still applies. + assert!(controller.current().tools.web_search); + assert_eq!(controller.origin("tools.web_search"), Layer::Global); + assert_eq!(controller.origin("tools.web_fetch"), Layer::Default); + } +} diff --git a/crates/alan/src/core/settings/env.rs b/crates/alan/src/core/settings/env.rs new file mode 100644 index 0000000..f8f9c34 --- /dev/null +++ b/crates/alan/src/core/settings/env.rs @@ -0,0 +1,71 @@ +//! The `ALAN_*` layer. + +use super::{SettingsLayer, ToolsLayer}; +use llm::ReasoningEffort; + +/// `ALAN_HOME`, `ALAN_SESSION` and `ALAN_LOG*` are absent on purpose: they +/// decide where settings live, or apply to one run, so they are not settings. +pub(super) fn env_layer() -> anyhow::Result { + let tools = ToolsLayer { + web_search: from_env("ALAN_OPENROUTER_WEB_SEARCH", parse_bool, "a boolean")?, + web_fetch: from_env("ALAN_OPENROUTER_WEB_FETCH", parse_bool, "a boolean")?, + }; + + Ok(SettingsLayer { + model: env_value("ALAN_MODEL"), + reasoning_effort: from_env( + "ALAN_REASONING_EFFORT", + parse_effort, + "one of auto, none, minimal, low, medium, high, xhigh, max", + )?, + // An all-empty `ToolsLayer` folds the same as no layer at all. + tools: Some(tools), + }) +} + +fn from_env( + name: &str, + parse: impl Fn(&str) -> Option, + expected: &str, +) -> anyhow::Result> { + let Some(raw) = env_value(name) else { + return Ok(None); + }; + parse(&raw) + .map(Some) + .ok_or_else(|| anyhow::anyhow!("{name}: expected {expected}, got {raw:?}")) +} + +fn env_value(name: &str) -> Option { + let value = std::env::var_os(name)?.to_string_lossy().trim().to_owned(); + (!value.is_empty()).then_some(value) +} + +fn parse_bool(raw: &str) -> Option { + match raw.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} + +fn parse_effort(raw: &str) -> Option { + ReasoningEffort::parse(&raw.to_ascii_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_unset_variable_is_not_an_error_but_a_bad_one_is() { + assert!(matches!( + from_env("ALAN_TEST_MISSING_VAR", parse_bool, "a boolean"), + Ok(None) + )); + assert!(from_env("PATH", |_| None::, "a boolean").is_err()); + + assert_eq!(parse_bool("nope"), None); + assert_eq!(parse_bool("ON"), Some(true)); + } +} diff --git a/crates/alan/src/core/settings/files.rs b/crates/alan/src/core/settings/files.rs new file mode 100644 index 0000000..b3e3dd1 --- /dev/null +++ b/crates/alan/src/core/settings/files.rs @@ -0,0 +1,375 @@ +//! Finding, reading and writing the settings files. + +use super::{Layers, SETTINGS, SettingsLayer, env}; +use std::path::{Path, PathBuf}; + +pub(super) const SETTINGS_FILE: &str = "settings.json"; + +pub(super) const SETTINGS_DIR: &str = ".alan"; + +/// The global settings file inside `dir`. +pub fn global_path(dir: &Path) -> PathBuf { + dir.join(SETTINGS_FILE) +} + +/// Read every source. Errors are fatal at startup and non-fatal on reload; +/// the caller decides which. +pub fn load_settings_layers(global_dir: &Path, cwd: &Path) -> anyhow::Result { + let global_path = global_path(global_dir); + let global = read_layer(&global_path)?; + + let project = match project_settings(cwd, &global_path) { + Some(path) => project_layer(&path)?, + None => SettingsLayer::default(), + }; + + Ok(Layers::new(global, project, env::env_layer()?)) +} + +/// Rejects rather than ignores keys a project file may not set, so the mistake +/// is visible. +fn project_layer(path: &Path) -> anyhow::Result { + let layer = read_layer(path)?; + let refused: Vec<_> = SETTINGS + .iter() + .filter(|def| !def.project_safe && layer.has(def.key)) + .map(|def| format!("`{}`", def.key)) + .collect(); + if !refused.is_empty() { + anyhow::bail!( + "{}: {} cannot be set by a project file", + path.display(), + refused.join(", ") + ); + } + Ok(layer) +} + +/// Not having a settings file is normal and silent. Anything else — unreadable, +/// bad syntax, a wrong type, an unknown key — is an error naming the file. +/// Running on defaults because a file could not be read would be worse. +fn read_layer(path: &Path) -> anyhow::Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(SettingsLayer::default()); + } + Err(error) => anyhow::bail!("{}: {error}", path.display()), + }; + serde_json::from_str(&text).map_err(|error| anyhow::anyhow!("{}: {error}", path.display())) +} + +/// The nearest project settings file, if any. +/// +/// Stops at a `.git` boundary: an ancestor's settings apply to a package in a +/// monorepo, but not to an unrelated checkout that happens to sit below it. +pub fn project_settings(cwd: &Path, global: &Path) -> Option { + for dir in cwd.ancestors() { + let candidate = dir.join(SETTINGS_DIR).join(SETTINGS_FILE); + if candidate.is_file() && !same_file(&candidate, global) { + return Some(candidate); + } + if dir.join(".git").exists() { + break; + } + } + None +} + +/// The nearest enclosing repository, if any. `Some` also answers whether a +/// project write has somewhere sensible to go. +pub(super) fn repo_root(cwd: &Path) -> Option<&Path> { + cwd.ancestors().find(|dir| dir.join(".git").exists()) +} + +/// Where a project write lands when no project file exists yet. Prefers the +/// repo root, so running in `repo/crates/agent` does not bury it four levels +/// down. +pub fn project_target(cwd: &Path) -> PathBuf { + repo_root(cwd) + .unwrap_or(cwd) + .join(SETTINGS_DIR) + .join(SETTINGS_FILE) +} + +/// True only when both paths exist and resolve to the same file. Two missing +/// files are not equal. +fn same_file(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +/// Mutates the raw JSON rather than re-serializing from `SettingsLayer`, so +/// keys this build does not know about survive. +pub fn write_key(path: &Path, key: &str, value: Option) -> anyhow::Result<()> { + let mut root = match std::fs::read_to_string(path) { + Ok(text) => serde_json::from_str(&text)?, + // Only a file that does not exist yet starts from scratch. Treating one + // we merely failed to read as empty would overwrite it below. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + serde_json::Value::Object(serde_json::Map::new()) + } + Err(error) => anyhow::bail!("{}: {error}", path.display()), + }; + if !root.is_object() { + anyhow::bail!("{}: settings must be a JSON object", path.display()); + } + + set_path(&mut root, key, value).map_err(|group| { + anyhow::anyhow!( + "{}: `{group}` is not an object, so `{key}` cannot be set — fix the file first", + path.display() + ) + })?; + prune_empty_groups(&mut root); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + ignore_project_dir(parent); + } + atomic_write(path, &format!("{}\n", serde_json::to_string_pretty(&root)?)) +} + +/// A project settings directory starts ignored: committing it is a decision to +/// take deliberately rather than by default. Best effort — failing to write it +/// is no reason to lose the setting. +fn ignore_project_dir(dir: &Path) { + if dir.file_name().is_some_and(|name| name == SETTINGS_DIR) && !dir.join(".gitignore").exists() + { + let _ = std::fs::write(dir.join(".gitignore"), "*\n"); + } +} + +/// Creates intermediate groups as needed. Returns the offending group when a +/// value already sits where one belongs, rather than overwriting it. +fn set_path( + root: &mut serde_json::Value, + key: &str, + value: Option, +) -> Result<(), String> { + let mut node = root; + let mut walked = String::new(); + let mut parts = key.split('.').peekable(); + while let Some(part) = parts.next() { + let Some(object) = node.as_object_mut() else { + return Err(walked); + }; + if parts.peek().is_none() { + match value { + Some(value) => object.insert(part.to_owned(), value), + None => object.remove(part), + }; + return Ok(()); + } + if !walked.is_empty() { + walked.push('.'); + } + walked.push_str(part); + node = object + .entry(part.to_owned()) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + } + Ok(()) +} + +/// A file that lists only what you changed should not keep `"tools": {}`. +fn prune_empty_groups(root: &mut serde_json::Value) { + if let Some(object) = root.as_object_mut() { + object.retain(|_, child| !child.as_object().is_some_and(|group| group.is_empty())); + } +} + +/// Canonicalised first, because renaming over a symlink would replace it with +/// a regular file and detach it from whatever manages it. +fn atomic_write(path: &Path, contents: &str) -> anyhow::Result<()> { + let target = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let temporary = target.with_extension("json.tmp"); + std::fs::write(&temporary, contents)?; + std::fs::rename(&temporary, &target)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::settings::SETTINGS; + + /// Removes the directory on drop, so a test leaves the filesystem as it + /// found it even when it panics part-way through. + struct Scratch(PathBuf); + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// The directory is returned alongside the path: dropping it deletes both. + fn temp_file(name: &str, contents: &str) -> (Scratch, PathBuf) { + let dir = std::env::temp_dir().join(format!("alan-settings-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join(SETTINGS_FILE); + std::fs::write(&path, contents).expect("write"); + (Scratch(dir), path) + } + + #[test] + fn a_missing_file_is_silent() { + let layer = read_layer(std::path::Path::new("/nonexistent/alan/settings.json")) + .expect("not having a settings file is normal"); + assert_eq!(layer, SettingsLayer::default()); + } + + #[test] + fn a_valid_file_becomes_a_layer() { + let (_dir, path) = temp_file("valid", r#"{"model":"opus","tools":{"web_search":true}}"#); + let layer = read_layer(&path).expect("valid file"); + + assert_eq!(layer.model.as_deref(), Some("opus")); + assert_eq!(layer.tools.unwrap().web_search, Some(true)); + } + + /// Starting with silently different settings is worse than not starting: + /// you would see the default model and wonder why the file did nothing. + #[test] + fn malformed_json_refuses_to_start() { + let (_dir, path) = temp_file("malformed", "{ this is not json"); + let error = read_layer(&path).expect_err("malformed config must be fatal"); + assert!( + error.to_string().contains("settings.json"), + "the message must name the file: {error}" + ); + } + + #[test] + fn a_wrongly_typed_value_refuses_to_start() { + let (_dir, path) = temp_file("typed", r#"{"model":42}"#); + assert!(read_layer(&path).is_err()); + } + + /// A typo means the setting you wrote is not applied, so it is an error. + /// The message names the valid keys, including inside a nested group. + #[test] + fn an_unknown_key_refuses_to_start_and_names_the_valid_ones() { + let (_dir, path) = temp_file("unknown", r#"{"model":"opus","reasoning_efort":"high"}"#); + let error = read_layer(&path) + .expect_err("a typo must be fatal") + .to_string(); + assert!(error.contains("reasoning_efort"), "{error}"); + assert!( + error.contains("reasoning_effort"), + "names the real key: {error}" + ); + + let (_dir, path) = temp_file("unknown-nested", r#"{"tools":{"web_serch":true}}"#); + let error = read_layer(&path) + .expect_err("a nested typo must be fatal") + .to_string(); + assert!(error.contains("web_serch"), "{error}"); + assert!(error.contains("web_search"), "names the real key: {error}"); + } + + #[test] + fn a_known_group_is_not_itself_an_unknown_key() { + let (_dir, path) = temp_file("group", r#"{"tools":{"web_search":true}}"#); + read_layer(&path).expect("`tools` is a group, not a typo"); + } + + #[test] + fn load_reads_the_global_file_from_the_directory_it_is_given() { + let (_dir, path) = temp_file("load", "{ not json"); + let dir = path.parent().expect("temp dir"); + assert!( + load_settings_layers(dir, dir).is_err(), + "the global file's parse failure must reach the caller" + ); + } + + /// Writing from the typed struct would drop anything this build does not + /// know about — a key a newer Alan wrote, silently deleted by an older one. + #[test] + fn a_write_preserves_keys_this_build_does_not_know() { + let (_dir, path) = temp_file("preserve", r#"{"model":"opus","from_the_future":{"a":1}}"#); + write_key(&path, "model", Some(serde_json::json!("haiku"))).expect("write"); + + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(raw["model"], "haiku"); + assert_eq!(raw["from_the_future"]["a"], 1, "unknown keys survive"); + } + + #[test] + fn a_write_creates_nested_groups_and_clearing_prunes_them() { + let (_dir, path) = temp_file("nested", "{}"); + write_key(&path, "tools.web_search", Some(serde_json::json!(true))).expect("set"); + + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(raw["tools"]["web_search"], true); + + write_key(&path, "tools.web_search", None).expect("clear"); + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!( + raw.get("tools").is_none(), + "an empty group is noise in a file that lists only what you changed: {raw}" + ); + } + + /// A settings file that failed to load is exactly when someone is most + /// likely mid-edit, so a write must not "repair" it by discarding whatever + /// sits where a group belongs. + #[test] + fn a_write_refuses_rather_than_clobbering_a_value_where_a_group_belongs() { + let (_dir, path) = temp_file("clobber", r#"{"tools":42}"#); + let error = write_key(&path, "tools.web_search", Some(serde_json::json!(true))) + .expect_err("must refuse"); + + assert!(error.to_string().contains("tools"), "{error}"); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("42"), "the file is untouched: {text}"); + } + + /// Dotfile managers symlink configs; renaming over the link would replace + /// it with a regular file and quietly detach it from the repo. + #[test] + fn a_write_follows_a_symlink_rather_than_replacing_it() { + let (_dir, real) = temp_file("symlink-target", r#"{"model":"before"}"#); + let link = real.with_file_name("linked.json"); + let _ = std::fs::remove_file(&link); + #[cfg(unix)] + std::os::unix::fs::symlink(&real, &link).expect("symlink"); + #[cfg(not(unix))] + return; + + write_key(&link, "model", Some(serde_json::json!("after"))).expect("write"); + + assert!( + std::fs::symlink_metadata(&link) + .expect("link") + .file_type() + .is_symlink(), + "the symlink must survive the write" + ); + let raw: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&real).unwrap()).unwrap(); + assert_eq!(raw["model"], "after", "the target got the write"); + } + + /// A project file may not set anything that executes, redirects the + /// network, or touches credentials — and saying so beats ignoring it. + #[test] + fn a_project_file_is_refused_when_it_sets_something_it_may_not() { + // Every v1 setting is project-safe, so this asserts the gate is wired + // rather than that any particular key is blocked. + assert!( + SETTINGS.iter().all(|def| def.project_safe), + "update this test when the first user-scope-only setting lands" + ); + let (_dir, path) = temp_file("project-safe", r#"{"model":"opus"}"#); + assert!(project_layer(&path).is_ok()); + } +} diff --git a/crates/alan/src/core/settings/layers.rs b/crates/alan/src/core/settings/layers.rs new file mode 100644 index 0000000..4f91f4a --- /dev/null +++ b/crates/alan/src/core/settings/layers.rs @@ -0,0 +1,199 @@ +//! Layers and how they fold. +//! +//! Every field in a [`SettingsLayer`] is optional, where `None` means the source +//! has no opinion rather than that it chose the default. Folding in precedence +//! order gives [`Settings`](super::Settings), which has no optionals. + +use super::{DEFAULT_MODEL, Settings, Tools}; +use llm::ReasoningEffort; +use serde::Deserialize; + +/// Which source supplied a value. Ordered lowest-precedence first, so a value +/// coming from a *lower* layer than the one being edited can be taken over, +/// and one from a *higher* layer cannot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Layer { + Default, + Global, + Project, + Env, +} + +impl Layer { + pub fn label(self) -> &'static str { + match self { + Self::Default => "default", + Self::Global => "global", + Self::Project => "project", + Self::Env => "env", + } + } +} + +/// Every source in precedence order, unfolded so a single scope can be read. +#[derive(Debug, Clone)] +pub struct Layers(Vec<(Layer, SettingsLayer)>); + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SettingsLayer { + pub model: Option, + pub reasoning_effort: Option, + pub tools: Option, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ToolsLayer { + pub web_search: Option, + pub web_fetch: Option, +} + +impl SettingsLayer { + /// Nested groups merge field-wise, so setting one key under `tools` does + /// not erase the others. + fn overlay(self, higher: SettingsLayer) -> SettingsLayer { + SettingsLayer { + model: higher.model.or(self.model), + reasoning_effort: higher.reasoning_effort.or(self.reasoning_effort), + tools: match (self.tools, higher.tools) { + (Some(low), Some(high)) => Some(ToolsLayer { + web_search: high.web_search.or(low.web_search), + web_fetch: high.web_fetch.or(low.web_fetch), + }), + (low, high) => high.or(low), + }, + } + } + + pub(super) fn has(&self, key: &str) -> bool { + match key { + "model" => self.model.is_some(), + "reasoning_effort" => self.reasoning_effort.is_some(), + "tools.web_search" => self.tools.is_some_and(|t| t.web_search.is_some()), + "tools.web_fetch" => self.tools.is_some_and(|t| t.web_fetch.is_some()), + _ => false, + } + } +} + +impl From for Settings { + fn from(layer: SettingsLayer) -> Self { + let tools = layer.tools.unwrap_or_default(); + Self { + model: layer.model.unwrap_or_else(|| DEFAULT_MODEL.to_owned()), + reasoning_effort: layer.reasoning_effort.unwrap_or_default(), + tools: Tools { + web_search: tools.web_search.unwrap_or(false), + web_fetch: tools.web_fetch.unwrap_or(false), + }, + } + } +} + +impl Layers { + /// Takes each source by name, so precedence is decided here rather than at + /// every construction site. + pub(super) fn new(global: SettingsLayer, project: SettingsLayer, env: SettingsLayer) -> Self { + Self(vec![ + (Layer::Global, global), + (Layer::Project, project), + (Layer::Env, env), + ]) + } + + /// The values in force: every layer folded, lowest precedence first. + pub(super) fn resolve(&self) -> Settings { + fold(self.0.iter()) + } + + /// What would apply in `scope` if nothing above it interfered. + pub(super) fn resolve_as_of(&self, scope: Layer) -> Settings { + fold(self.0.iter().filter(|(source, _)| *source <= scope)) + } + + /// Which layer supplied a key's effective value: the highest one with an + /// opinion about it. + pub(super) fn origin_of(&self, key: &str) -> Layer { + self.0 + .iter() + .rev() + .find(|(_, layer)| layer.has(key)) + .map_or(Layer::Default, |(source, _)| *source) + } +} + +fn fold<'a>(layers: impl Iterator) -> Settings { + layers + .fold(SettingsLayer::default(), |merged, (_, layer)| { + merged.overlay(layer.clone()) + }) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layer(json: &str) -> SettingsLayer { + serde_json::from_str(json).expect("valid layer") + } + + /// A source with no opinion about anything. + fn silent() -> SettingsLayer { + SettingsLayer::default() + } + + #[test] + fn defaults_apply_when_every_layer_is_silent() { + let settings = Layers::new(silent(), silent(), silent()).resolve(); + assert_eq!(settings.model, DEFAULT_MODEL); + assert_eq!(settings.reasoning_effort, ReasoningEffort::Auto); + assert_eq!(settings.tools, Tools::default()); + } + + #[test] + fn a_higher_layer_wins_per_field_not_per_layer() { + let global = layer(r#"{"model":"opus","reasoning_effort":"high"}"#); + let project = layer(r#"{"model":"haiku"}"#); + + let settings = Layers::new(global, project, silent()).resolve(); + assert_eq!(settings.model, "haiku"); + // Untouched by the higher layer, so the lower one still applies. + assert_eq!(settings.reasoning_effort, ReasoningEffort::High); + } + + /// Without a field-wise merge, a project setting one tool would silently + /// switch the other back off. + #[test] + fn nested_groups_merge_rather_than_replace() { + let global = layer(r#"{"tools":{"web_search":true,"web_fetch":true}}"#); + let project = layer(r#"{"tools":{"web_search":false}}"#); + + let settings = Layers::new(global, project, silent()).resolve(); + assert!(!settings.tools.web_search); + assert!(settings.tools.web_fetch, "web_fetch must survive"); + } + + /// The distinction the whole layer design exists for: silence is not the + /// same as an explicit choice, even when the choice looks like a default. + #[test] + fn silence_and_an_explicit_none_resolve_differently() { + let chosen = layer(r#"{"reasoning_effort":"none"}"#); + + let silence = Layers::new(silent(), silent(), silent()).resolve(); + let explicit = Layers::new(chosen, silent(), silent()).resolve(); + + assert_eq!(silence.reasoning_effort, ReasoningEffort::Auto); + assert_eq!(explicit.reasoning_effort, ReasoningEffort::None); + } + + #[test] + fn env_outranks_the_file() { + let file = layer(r#"{"model":"from-file"}"#); + let env = layer(r#"{"model":"from-env"}"#); + + let settings = Layers::new(file, silent(), env).resolve(); + assert_eq!(settings.model, "from-env"); + } +} diff --git a/crates/alan/src/core/settings/mod.rs b/crates/alan/src/core/settings/mod.rs new file mode 100644 index 0000000..c029b40 --- /dev/null +++ b/crates/alan/src/core/settings/mod.rs @@ -0,0 +1,71 @@ +//! Layered settings: defaults, global file, project file, env — highest wins. +//! +//! Every field in a [`SettingsLayer`] is optional, where `None` means the +//! source has no opinion rather than that it chose the default. [`Settings`] is +//! the folded result and has no optionals. + +mod controller; +mod env; +mod files; +mod layers; +mod overlay; +mod schema; + +pub use controller::{Outcome, SettingsController}; +use files::{ + global_path, load_settings_layers, project_settings, project_target, repo_root, write_key, +}; +pub use layers::Layer; +use layers::{Layers, SettingsLayer, ToolsLayer}; +pub use overlay::{Marker, SettingsOverlay}; +use schema::{Kind, SETTINGS, SettingDef, Value}; + +use llm::{ReasoningEffort, ServerTool}; +use providers::{Model, ModelOptions, ProviderRegistry}; + +const DEFAULT_MODEL: &str = "openai/gpt-4o-mini"; + +#[derive(Debug, Clone, PartialEq)] +pub struct Settings { + pub model: String, + pub reasoning_effort: ReasoningEffort, + pub tools: Tools, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Tools { + pub web_search: bool, + pub web_fetch: bool, +} + +impl Default for Settings { + fn default() -> Self { + SettingsLayer::default().into() + } +} + +/// Used by startup and by live rebinding, so the two cannot disagree. +pub fn bind(providers: &ProviderRegistry, settings: &Settings) -> anyhow::Result { + let provider = providers + .providers() + .first() + .ok_or_else(|| anyhow::anyhow!("no provider configured"))?; + + let options = ModelOptions { + server_tools: provider + .server_tools() + .iter() + .filter(|tool| match tool.id.as_str() { + "openrouter:web_fetch" => settings.tools.web_fetch, + "openrouter:web_search" => settings.tools.web_search, + _ => false, + }) + .map(|tool| ServerTool { + kind: tool.id.clone(), + }) + .collect(), + reasoning_effort: settings.reasoning_effort, + }; + + Ok(provider.bind_with_options(&settings.model, options)?) +} diff --git a/crates/alan/src/core/settings/overlay.rs b/crates/alan/src/core/settings/overlay.rs new file mode 100644 index 0000000..f610796 --- /dev/null +++ b/crates/alan/src/core/settings/overlay.rs @@ -0,0 +1,427 @@ +//! State for the `/settings` list. Rendering lives in `views`. + +use super::{Kind, Layer, Outcome, SETTINGS, SettingDef, Settings, SettingsController, Value}; +use std::path::PathBuf; + +/// Whether editing this row does anything. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Marker { + SetHere, + /// From a lower layer; editing here takes over. + Inherited(Layer), + /// From a higher layer; editing here is stored but inert. + Overridden(Layer), +} + +pub struct Row { + pub label: &'static str, + pub help: &'static str, + /// This scope's value, or the one it would inherit. + pub value: String, + pub marker: Marker, + /// `Bool` and `Enum` cycle in place; the rest open a prompt. + pub cycles: bool, +} + +pub struct SettingsOverlay { + pub scope: Layer, + pub selected: usize, + /// A prompt is open for this row. The text lives in the shared editor. + pub editing: bool, + /// What a just-opened prompt should start from, taken once by the view. + pub seed: Option, +} + +impl SettingsOverlay { + /// Opens in project scope only when a project write has somewhere to go. + fn new(settings: &SettingsController) -> Self { + let has_project = + settings.project_file().is_some() || super::repo_root(&settings.cwd).is_some(); + Self { + scope: if has_project { + Layer::Project + } else { + Layer::Global + }, + selected: 0, + editing: false, + seed: None, + } + } + + fn def(&self) -> &'static SettingDef { + &SETTINGS[self.selected.min(SETTINGS.len() - 1)] + } + + fn move_by(&mut self, delta: isize) { + let count = SETTINGS.len() as isize; + self.selected = (self.selected as isize + delta).rem_euclid(count) as usize; + } + + fn toggle_scope(&mut self) { + self.scope = match self.scope { + Layer::Project => Layer::Global, + _ => Layer::Project, + }; + self.editing = false; + } + + fn rows(&self, settings: &SettingsController) -> Vec { + let in_scope = settings.as_of(self.scope); + SETTINGS + .iter() + .map(|def| { + let origin = settings.origin(def.key); + Row { + label: def.label, + help: def.help, + value: show((def.read)(&in_scope)), + marker: match origin.cmp(&self.scope) { + std::cmp::Ordering::Equal => Marker::SetHere, + std::cmp::Ordering::Less => Marker::Inherited(origin), + std::cmp::Ordering::Greater => Marker::Overridden(origin), + }, + cycles: matches!(def.kind, Kind::Bool | Kind::Enum(_)), + } + }) + .collect() + } + + fn shown_value(&self, in_scope: &Settings) -> String { + show((self.def().read)(in_scope)) + } + + /// `None` when the row needs a prompt rather than a cycle. + fn next_value(&self, current: &Settings) -> Option { + let def = self.def(); + match def.kind { + Kind::Bool => match (def.read)(current) { + Value::Bool(on) => Some(serde_json::Value::Bool(!on)), + _ => None, + }, + Kind::Enum(options) => { + let current = show((def.read)(current)); + let index = options.iter().position(|option| *option == current)?; + Some(serde_json::Value::String( + options[(index + 1) % options.len()].to_owned(), + )) + } + _ => None, + } + } + + /// An empty string clears the key, as `Backspace` does. + fn parse_edit(text: &str) -> Option { + let text = text.trim(); + (!text.is_empty()).then(|| serde_json::Value::String(text.to_owned())) + } +} + +fn show(value: Value) -> String { + match value { + Value::Bool(true) => "on".into(), + Value::Bool(false) => "off".into(), + Value::Text(text) => text, + Value::Enum(name) => name.into(), + } +} + +/// Driving the `/settings` list. Kept beside [`SettingsOverlay`] rather than +/// with the store, whose job is files and layers. +impl SettingsController { + pub fn overlay(&self) -> Option<&SettingsOverlay> { + self.overlay.as_ref() + } + + pub fn open(&mut self) { + self.overlay = Some(SettingsOverlay::new(self)); + } + + pub fn close(&mut self) { + self.overlay = None; + } + + /// Where the open overlay writes, and whether that file exists yet. + pub fn target(&self) -> Option { + self.overlay.as_ref().map(|o| self.path(o.scope)) + } + + /// A prompt is open for the selected row. + pub fn editing(&self) -> bool { + self.overlay.as_ref().is_some_and(|o| o.editing) + } + + pub fn rows(&self) -> Vec { + self.overlay + .as_ref() + .map(|overlay| overlay.rows(self)) + .unwrap_or_default() + } + + pub fn move_selection(&mut self, delta: isize) { + if let Some(overlay) = self.overlay.as_mut() { + overlay.move_by(delta); + } + } + + pub fn toggle_scope(&mut self) { + if let Some(overlay) = self.overlay.as_mut() { + overlay.toggle_scope(); + } + } + + /// Abandon a row's prompt, leaving the list open. + pub fn cancel_edit(&mut self) { + if let Some(overlay) = self.overlay.as_mut() { + overlay.editing = false; + } + } + + /// Take the value a just-opened prompt should start from. + pub fn take_seed(&mut self) -> Option { + self.overlay.as_mut()?.seed.take() + } + + /// Cycles the row, or opens a prompt when it needs typing. Both read the + /// scope's value, so what you cycle from is what the row shows. + pub fn activate(&mut self) -> Outcome { + let Some(overlay) = self.overlay.as_ref() else { + return Ok(false); + }; + let in_scope = self.as_of(overlay.scope); + let key = overlay.def().key; + match overlay.next_value(&in_scope) { + Some(value) => self.write(key, Some(value)), + None => { + let seed = overlay.shown_value(&in_scope); + if let Some(overlay) = self.overlay.as_mut() { + overlay.seed = Some(seed); + overlay.editing = true; + } + Ok(false) + } + } + } + + /// Only a row this scope owns can be cleared. + pub fn clear(&mut self) -> Outcome { + let Some(overlay) = self.overlay.as_ref() else { + return Ok(false); + }; + let key = overlay.def().key; + if self.origin(key) != overlay.scope { + return Ok(false); + } + self.write(key, None) + } + + pub fn submit_edit(&mut self, text: &str) -> Outcome { + let Some(overlay) = self.overlay.as_ref() else { + return Ok(false); + }; + let key = overlay.def().key; + let value = SettingsOverlay::parse_edit(text); + if let Some(overlay) = self.overlay.as_mut() { + overlay.editing = false; + } + self.write(key, value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn controller(global: &str, project: Option<&str>) -> (SettingsController, tempdirs::Dirs) { + let dirs = tempdirs::Dirs::new(); + dirs.write_global(global); + if let Some(project) = project { + dirs.write_project(project); + } + let controller = SettingsController::new(&dirs.home, &dirs.project).expect("valid"); + (controller, dirs) + } + + mod tempdirs { + use crate::core::settings::files::{SETTINGS_DIR, SETTINGS_FILE}; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + pub struct Dirs { + root: PathBuf, + pub home: PathBuf, + pub project: PathBuf, + } + + /// So a test leaves the filesystem as it found it, panic or not. + impl Drop for Dirs { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + impl Dirs { + pub fn new() -> Self { + // Counted: every test here builds one, and two sharing a name + // would delete each other's directory. + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "alan-overlay-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&root); + let home = root.join("home"); + let project = root.join("project"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(project.join(SETTINGS_DIR)).unwrap(); + std::fs::create_dir_all(project.join(".git")).unwrap(); + Self { + root, + home, + project, + } + } + pub fn write_global(&self, text: &str) { + std::fs::write(self.home.join(SETTINGS_FILE), text).unwrap(); + } + pub fn write_project(&self, text: &str) { + std::fs::write(self.project.join(SETTINGS_DIR).join(SETTINGS_FILE), text).unwrap(); + } + } + } + + fn row<'a>(rows: &'a [Row], label: &str) -> &'a Row { + rows.iter().find(|row| row.label == label).expect("row") + } + + #[test] + fn markers_distinguish_owned_inherited_and_overridden() { + let (settings, _dirs) = controller( + r#"{"model":"from-global","tools":{"web_fetch":true}}"#, + Some(r#"{"tools":{"web_search":true}}"#), + ); + let mut overlay = SettingsOverlay { + scope: Layer::Project, + selected: 0, + editing: false, + seed: None, + }; + let rows = overlay.rows(&settings); + + assert_eq!(row(&rows, "web search").marker, Marker::SetHere); + assert_eq!( + row(&rows, "model").marker, + Marker::Inherited(Layer::Global), + "a lower layer set it, so editing here would take over" + ); + + // From global scope the project file is now the one imposing a value. + overlay.toggle_scope(); + let rows = overlay.rows(&settings); + assert_eq!(row(&rows, "model").marker, Marker::SetHere); + assert_eq!( + row(&rows, "web search").marker, + Marker::Overridden(Layer::Project), + "editing here is stored but inert" + ); + } + + #[test] + fn a_row_shows_its_own_scopes_value_not_the_resolved_one() { + let (settings, _dirs) = controller( + r#"{"reasoning_effort":"low"}"#, + Some(r#"{"reasoning_effort":"max"}"#), + ); + let mut overlay = SettingsOverlay { + scope: Layer::Global, + selected: SETTINGS + .iter() + .position(|d| d.key == "reasoning_effort") + .unwrap(), + editing: false, + seed: None, + }; + + let rows = overlay.rows(&settings); + let effort = row(&rows, "reasoning effort"); + assert_eq!( + effort.value, "low", + "global's own value, not the resolved max" + ); + assert_eq!(effort.marker, Marker::Overridden(Layer::Project)); + + // And cycling starts from what the row showed. + assert_eq!( + overlay.next_value(&settings.as_of(Layer::Global)), + Some(serde_json::json!("medium")), + "cycles from low, not from max" + ); + + overlay.toggle_scope(); + assert_eq!( + row(&overlay.rows(&settings), "reasoning effort").value, + "max" + ); + } + + #[test] + fn enter_cycles_bools_and_enums_but_not_text() { + let (settings, _dirs) = controller(r#"{"reasoning_effort":"low"}"#, None); + let mut overlay = SettingsOverlay { + scope: Layer::Global, + selected: 0, + editing: false, + seed: None, + }; + + overlay.selected = SETTINGS + .iter() + .position(|d| d.key == "tools.web_search") + .unwrap(); + assert_eq!( + overlay.next_value(settings.current()), + Some(serde_json::Value::Bool(true)) + ); + + overlay.selected = SETTINGS + .iter() + .position(|d| d.key == "reasoning_effort") + .unwrap(); + assert_eq!( + overlay.next_value(settings.current()), + Some(serde_json::json!("medium")), + "cycles to the next option, not a toggle" + ); + + overlay.selected = SETTINGS.iter().position(|d| d.key == "model").unwrap(); + assert_eq!( + overlay.next_value(settings.current()), + None, + "text rows need a prompt" + ); + } + + #[test] + fn an_empty_edit_clears_the_key_rather_than_setting_an_empty_value() { + assert_eq!(SettingsOverlay::parse_edit(" "), None); + assert_eq!( + SettingsOverlay::parse_edit("opus"), + Some(serde_json::json!("opus")) + ); + } + + #[test] + fn selection_wraps_in_both_directions() { + let mut overlay = SettingsOverlay { + scope: Layer::Global, + selected: 0, + editing: false, + seed: None, + }; + overlay.move_by(-1); + assert_eq!(overlay.selected, SETTINGS.len() - 1); + overlay.move_by(1); + assert_eq!(overlay.selected, 0); + } +} diff --git a/crates/alan/src/core/settings/schema.rs b/crates/alan/src/core/settings/schema.rs new file mode 100644 index 0000000..1ea791c --- /dev/null +++ b/crates/alan/src/core/settings/schema.rs @@ -0,0 +1,110 @@ +//! One declaration per setting, read by the `/settings` list, validation, and +//! the project-scope guard. + +use super::Settings; +use llm::ReasoningEffort; + +/// `Bool` and `Enum` cycle on a keypress; the rest open a prompt. +pub enum Kind { + Bool, + Enum(&'static [&'static str]), + Text, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + Bool(bool), + Text(String), + Enum(&'static str), +} + +pub struct SettingDef { + /// Dotted path into the settings JSON object. + pub key: &'static str, + pub label: &'static str, + pub help: &'static str, + pub kind: Kind, + /// May a project's `.alan/settings.json` set this? `false` for anything + /// that can execute code, change an endpoint, or touch credentials. + pub project_safe: bool, + pub read: fn(&Settings) -> Value, +} + +/// Spelled by the enum itself, so a renamed level cannot desync from the list +/// the `/settings` row cycles through. +const EFFORTS: &[&str] = &[ + ReasoningEffort::Auto.as_str(), + ReasoningEffort::None.as_str(), + ReasoningEffort::Minimal.as_str(), + ReasoningEffort::Low.as_str(), + ReasoningEffort::Medium.as_str(), + ReasoningEffort::High.as_str(), + ReasoningEffort::XHigh.as_str(), + ReasoningEffort::Max.as_str(), +]; + +pub const SETTINGS: &[SettingDef] = &[ + SettingDef { + key: "model", + label: "model", + help: "Provider model id used for new sessions.", + kind: Kind::Text, + project_safe: true, + read: |s| Value::Text(s.model.clone()), + }, + SettingDef { + key: "reasoning_effort", + label: "reasoning effort", + help: "Hidden thinking spent before answering. `auto` defers to the model; `none` disables it.", + kind: Kind::Enum(EFFORTS), + project_safe: true, + read: |s| Value::Enum(s.reasoning_effort.as_str()), + }, + SettingDef { + key: "tools.web_search", + label: "web search", + help: "Offer the provider's web search tool.", + kind: Kind::Bool, + project_safe: true, + read: |s| Value::Bool(s.tools.web_search), + }, + SettingDef { + key: "tools.web_fetch", + label: "web fetch", + help: "Offer the provider's web fetch tool.", + kind: Kind::Bool, + project_safe: true, + read: |s| Value::Bool(s.tools.web_fetch), + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_are_unique() { + let mut keys: Vec<_> = SETTINGS.iter().map(|def| def.key).collect(); + keys.sort_unstable(); + let count = keys.len(); + keys.dedup(); + assert_eq!(keys.len(), count, "duplicate key in SETTINGS"); + } + + /// Or the UI could offer a value the resolver rejects. + #[test] + fn enum_options_all_parse() { + for def in SETTINGS { + let Kind::Enum(options) = def.kind else { + continue; + }; + for option in options { + assert!( + ReasoningEffort::parse(option).is_some(), + "{}: option {option:?} does not parse", + def.key + ); + } + } + } +} diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index 90697af..f24ecf6 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -2,6 +2,7 @@ mod core; mod logging; mod views; +use core::settings; use core::{Action, Controller, Overlay}; use crossterm::cursor::SetCursorStyle; use crossterm::event::{ @@ -10,16 +11,12 @@ use crossterm::event::{ PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use crossterm::execute; -use llm::ServerTool; use std::io::stdout; use std::time::Duration; use agent::{Agent, SessionManager, default_tools}; use futures_util::StreamExt; -use llm::ReasoningEffort; -use providers::{ - FileCredentialStore, ModelOptions, OpenRouterProvider, Provider, ProviderRegistry, -}; +use providers::{FileCredentialStore, OpenRouterProvider, Provider, ProviderRegistry}; use std::path::PathBuf; use std::sync::Arc; @@ -28,42 +25,38 @@ use crate::logging::init; #[tokio::main] async fn main() -> anyhow::Result<()> { let _guard = init().unwrap(); - let model_id = std::env::var("ALAN_MODEL").unwrap_or_else(|_| "openai/gpt-4o-mini".into()); - let credential_store = Arc::new(FileCredentialStore::new(auth_path()?)); + let alan_dir = alan_data_dir()?; + let cwd = std::env::current_dir()?; + + let settings = settings::SettingsController::new(&alan_dir, &cwd)?; + + let credential_store = Arc::new(FileCredentialStore::new(alan_dir.join("auth.json"))); let provider = OpenRouterProvider::from_store(credential_store.clone()) - .with_model(&model_id) + .with_model(&settings.current().model) .build()?; - let server_tools = enabled_server_tools(&provider)?; - let reasoning_effort = configured_reasoning_effort()?; - let model = provider.bind_with_options( - &model_id, - ModelOptions { - server_tools, - reasoning_effort, - }, - )?; let registry = ProviderRegistry::new([Arc::new(provider) as Arc]); - let session_manager = Arc::new(SessionManager::new(sessions_path()?)); + let model = settings::bind(®istry, settings.current())?; + let session_manager = Arc::new(SessionManager::new(alan_dir.join("sessions"))); let resumed_session = if let Some(session_id) = configured_session_id()? { - let cwd = std::env::current_dir()?; Some(session_manager.get_session(&session_id, &cwd).await?) } else { None }; let was_resumed = resumed_session.is_some(); - let current_dir = std::env::current_dir()?; let mut agent_builder = Agent::builder(model) .with_default_system_prompt() - .with_directory(current_dir) + .with_directory(cwd.clone()) .with_tools(default_tools()) .session_manager(session_manager); + if let Some(session) = resumed_session { agent_builder = agent_builder.resume_session(session); } let agent = agent_builder.build()?; - let mut app = Controller::with_runtime(agent, registry, credential_store); + let mut app = Controller::new(agent, registry, credential_store, settings); + if was_resumed { app.restore_session_history().await; } @@ -74,60 +67,6 @@ async fn main() -> anyhow::Result<()> { result } -fn enabled_server_tools(provider: &OpenRouterProvider) -> anyhow::Result> { - let mut enabled = Vec::new(); - for tool in provider.server_tools() { - let variable = match tool.id.as_str() { - "openrouter:web_fetch" => "ALAN_OPENROUTER_WEB_FETCH", - "openrouter:web_search" => "ALAN_OPENROUTER_WEB_SEARCH", - _ => continue, - }; - if parse_bool_env(variable)? { - enabled.push(ServerTool { - kind: tool.id.clone(), - }); - } - } - Ok(enabled) -} - -fn parse_bool_env(name: &str) -> anyhow::Result { - let Some(value) = std::env::var_os(name) else { - return Ok(false); - }; - match value.to_string_lossy().trim().to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "on" => Ok(true), - "0" | "false" | "no" | "off" => Ok(false), - value => Err(anyhow::anyhow!( - "{name} must be a boolean (true/false), got {value:?}" - )), - } -} -fn configured_reasoning_effort() -> anyhow::Result> { - let Some(value) = std::env::var_os("ALAN_REASONING_EFFORT") else { - return Ok(None); - }; - match value.to_string_lossy().trim().to_ascii_lowercase().as_str() { - "none" => Ok(None), - "minimal" => Ok(Some(ReasoningEffort::Minimal)), - "low" => Ok(Some(ReasoningEffort::Low)), - "medium" => Ok(Some(ReasoningEffort::Medium)), - "high" => Ok(Some(ReasoningEffort::High)), - "xhigh" => Ok(Some(ReasoningEffort::XHigh)), - "max" => Ok(Some(ReasoningEffort::Max)), - value => Err(anyhow::anyhow!( - "ALAN_REASONING_EFFORT must be one of none, minimal, low, medium, high, xhigh, max; got {value:?}" - )), - } -} -fn auth_path() -> anyhow::Result { - Ok(alan_data_dir()?.join("auth.json")) -} - -fn sessions_path() -> anyhow::Result { - Ok(alan_data_dir()?.join("sessions")) -} - fn alan_data_dir() -> anyhow::Result { let home = std::env::var_os("ALAN_HOME") .or_else(|| std::env::var_os("HOME")) @@ -184,14 +123,17 @@ async fn event_loop(app: &mut Controller) -> anyhow::Result<()> { break; }; let event = result?; - let command = if app.overlay() == Overlay::Login { - action_from_event(&event).and_then(|action| { - ui.apply(action, app.login_selection_active()) - }) - } else { - ui.handle_event(event, view.lines(), app.completion_mut()) + let command = match app.overlay() { + Overlay::None => ui.handle_event(event, view.lines(), app.completion_mut()), + overlay => { + let mode = app.input_mode(overlay); + action_from_event(&event).and_then(|a| ui.apply(a, mode)) + } }; let should_quit = command.is_some_and(|command| app.handle(command)); + if let Some(seed) = app.take_input_seed() { + ui.seed_input(seed); + } if ui.take_dirty() { terminal.draw(|frame| view.render(frame, app, &mut ui))?; } @@ -226,12 +168,9 @@ fn action_from_event(event: &Event) -> Option { KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => { Action::PasteOrAttachImage } - KeyCode::Tab | KeyCode::BackTab - if key.modifiers.contains(KeyModifiers::SHIFT) - || key.code == KeyCode::BackTab => - { - Action::TogglePlanMode - } + // Plain Tab is free here: this map is only consulted with an + // overlay open, and completion's Tab lives on the other path. + KeyCode::Tab | KeyCode::BackTab => Action::Cycle, KeyCode::Enter => Action::Submit, KeyCode::Esc => Action::ClearInput, KeyCode::Backspace => Action::Backspace, @@ -278,6 +217,23 @@ mod tests { ); } + /// The settings overlay offers "Tab to switch", and terminals report + /// Shift+Tab as either `BackTab` or a shifted `Tab`. + #[test] + fn every_tab_spelling_cycles() { + for key in [ + crossterm::event::KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), + crossterm::event::KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), + crossterm::event::KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE), + ] { + assert_eq!( + action_from_event(&Event::Key(key)), + Some(Action::Cycle), + "{key:?}" + ); + } + } + #[test] fn arrow_keys_scroll_or_navigate() { let up = Event::Key(crossterm::event::KeyEvent::new( diff --git a/crates/alan/src/views/components/login.rs b/crates/alan/src/views/components/login.rs index 684e087..1184c3c 100644 --- a/crates/alan/src/views/components/login.rs +++ b/crates/alan/src/views/components/login.rs @@ -1,3 +1,4 @@ +use super::centered_rect; use crate::core::{Controller, LoginState}; use crate::views::UiState; use crate::views::component::Component; @@ -132,19 +133,3 @@ fn prompt_message(prompt: &AuthPrompt) -> String { AuthPrompt::Select { message, .. } => message.clone(), } } - -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let horizontal: [Rect; 3] = Layout::horizontal([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .areas(area); - let vertical: [Rect; 3] = Layout::vertical([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .areas(horizontal[1]); - vertical[1] -} diff --git a/crates/alan/src/views/components/mod.rs b/crates/alan/src/views/components/mod.rs index 8dbdd05..1982723 100644 --- a/crates/alan/src/views/components/mod.rs +++ b/crates/alan/src/views/components/mod.rs @@ -3,9 +3,31 @@ mod footer; mod header; mod login; mod popup; +mod settings; pub use chat::Chat; pub use footer::Footer; pub use header::Header; pub use login::LoginOverlay; pub use popup::PopupList; +pub use settings::SettingsOverlayView; + +use ratatui::layout::{Constraint, Layout, Rect}; + +/// The middle `percent_x` × `percent_y` of `area`, for the overlays that float +/// above the transcript. +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let [_, middle, _] = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .areas(area); + let [_, center, _] = Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .areas(middle); + center +} diff --git a/crates/alan/src/views/components/settings.rs b/crates/alan/src/views/components/settings.rs new file mode 100644 index 0000000..cadfcf4 --- /dev/null +++ b/crates/alan/src/views/components/settings.rs @@ -0,0 +1,166 @@ +use super::centered_rect; +use crate::core::Controller; +use crate::core::settings::{Layer, Marker}; +use crate::views::UiState; +use crate::views::component::Component; +use crate::views::theme; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Margin, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::Paragraph; + +const LABEL_WIDTH: usize = 20; +const VALUE_WIDTH: usize = 28; + +#[derive(Debug, Default)] +pub struct SettingsOverlayView; + +impl Component for SettingsOverlayView { + fn render( + &mut self, + frame: &mut Frame, + area: Rect, + controller: &Controller, + state: &mut UiState, + ) { + let settings = controller.settings(); + let (Some(overlay), Some(path)) = (settings.overlay(), settings.target()) else { + return; + }; + + let area = centered_rect(78, 60, area); + frame.render_widget(ratatui::widgets::Clear, area); + frame.render_widget( + Paragraph::new("").style(Style::default().bg(theme::EDITOR_BG)), + area, + ); + + let [header_area, rows_area, footer_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(1), + ]) + .spacing(1) + .areas(area.inner(Margin { + horizontal: 2, + vertical: 1, + })); + + let scope = if overlay.scope == Layer::Project { + "project" + } else { + "global" + }; + + let exists = path.is_file(); + frame.render_widget( + Paragraph::new(Text::from(vec![ + Line::from(vec![ + Span::styled("/settings", Style::default().fg(theme::COMMAND_FG)), + Span::styled(" scope: ", Style::default().fg(theme::MUTED_FG)), + Span::styled(scope, Style::default().fg(theme::EDITOR_FG)), + Span::styled(" · Tab to switch", Style::default().fg(theme::MUTED_FG)), + ]), + Line::from(Span::styled( + if exists { + path.display().to_string() + } else { + format!("{} (not created yet)", path.display()) + }, + Style::default().fg(theme::MUTED_FG), + )), + ])), + header_area, + ); + + let rows = settings.rows(); + let lines: Vec = rows + .iter() + .enumerate() + .map(|(index, row)| { + let selected = index == overlay.selected; + if selected && overlay.editing { + let typed = state.input().to_owned(); + return Line::from(vec![ + Span::styled(" › ", Style::default().fg(theme::PROMPT_FG)), + Span::styled( + pad(row.label, LABEL_WIDTH), + Style::default().fg(theme::SELECTION_FG), + ), + Span::styled(typed, Style::default().fg(theme::SELECTION_FG)), + Span::styled("▌", Style::default().fg(theme::PROMPT_FG)), + Span::styled( + " Enter save · Esc cancel", + Style::default().fg(theme::MUTED_FG), + ), + ]) + .style(Style::default().bg(theme::SELECTION_BG)); + } + + let value = if row.cycles { + format!("‹ {} ›", row.value) + } else { + format!(" {}", row.value) + }; + let (marker, marker_style) = marker_span(&row.marker); + let line = Line::from(vec![ + Span::styled( + if selected { " › " } else { " " }, + Style::default().fg(theme::PROMPT_FG), + ), + Span::styled( + pad(row.label, LABEL_WIDTH), + Style::default().fg(theme::EDITOR_FG), + ), + Span::styled( + pad(&value, VALUE_WIDTH), + Style::default().fg(theme::EDITOR_FG), + ), + Span::styled(marker, marker_style), + ]); + if selected { + line.style(Style::default().bg(theme::SELECTION_BG)) + } else { + line + } + }) + .collect(); + frame.render_widget(Paragraph::new(Text::from(lines)), rows_area); + + let hint = rows + .get(overlay.selected) + .map(|row| row.help) + .unwrap_or_default(); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + hint, + Style::default().fg(theme::MUTED_FG), + ))), + footer_area, + ); + } +} + +fn marker_span(marker: &Marker) -> (String, Style) { + match marker { + Marker::SetHere => ( + "● set here".into(), + Style::default().fg(theme::TOOL_DONE_FG), + ), + Marker::Inherited(layer) => ( + format!("← {}", layer.label()), + Style::default().fg(theme::MUTED_FG), + ), + Marker::Overridden(layer) => ( + format!("⚠ {} wins", layer.label()), + Style::default().fg(theme::TOOL_ERROR_FG), + ), + } +} + +fn pad(text: &str, width: usize) -> String { + let shown: String = text.chars().take(width).collect(); + let used = shown.chars().count(); + format!("{shown}{}", " ".repeat(width.saturating_sub(used))) +} diff --git a/crates/alan/src/views/mod.rs b/crates/alan/src/views/mod.rs index 0213ba2..9cc58cb 100644 --- a/crates/alan/src/views/mod.rs +++ b/crates/alan/src/views/mod.rs @@ -10,10 +10,10 @@ mod theme; use crate::core::{ Accept, Action, Command, CompletionController, CompletionItem, Controller, ImageAttachment, - Overlay, Poll, SlashCommand, + InputMode, Overlay, Poll, SlashCommand, }; use base64::Engine; -use components::{Chat, Footer, Header, LoginOverlay}; +use components::{Chat, Footer, Header, LoginOverlay, SettingsOverlayView}; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -74,13 +74,13 @@ impl UiState { } } - pub fn apply(&mut self, action: Action, login_selection_active: bool) -> Option { + pub fn apply(&mut self, action: Action, mode: InputMode) -> Option { let command = match action { Action::Interrupt => { self.input.clear(); Some(Command::Interrupt) } - Action::TogglePlanMode => Some(Command::TogglePlanMode), + Action::Cycle => Some(Command::Cycle), Action::Resize => None, Action::Submit => { self.follow_output = true; @@ -103,11 +103,17 @@ impl UiState { } } Action::Backspace => { - self.input.pop(); - None + if mode == InputMode::Prompt { + self.input.pop(); + None + } else { + Some(Command::ClearSelection) + } } Action::Insert(character) => { - self.input.push(character); + if mode == InputMode::Prompt { + self.input.push(character); + } None } Action::Paste(text) => { @@ -120,22 +126,24 @@ impl UiState { } else { // No image on the clipboard: fall back to pasting text. match arboard::Clipboard::new().and_then(|mut c| c.get_text()) { - Ok(text) if !text.is_empty() => self.apply(Action::Paste(text), false), + Ok(text) if !text.is_empty() => { + self.apply(Action::Paste(text), InputMode::Prompt) + } _ => None, } } } Action::ScrollUp => { - if login_selection_active { - Some(Command::MoveLoginSelection(-1)) + if mode == InputMode::List { + Some(Command::MoveSelection(-1)) } else { self.scroll_by(-(self.viewport_height.max(1) as isize)); None } } Action::ScrollDown => { - if login_selection_active { - Some(Command::MoveLoginSelection(1)) + if mode == InputMode::List { + Some(Command::MoveSelection(1)) } else { self.scroll_by(self.viewport_height.max(1) as isize); None @@ -196,7 +204,7 @@ impl UiState { && key.modifiers.contains(KeyModifiers::SHIFT)) => { completion.dismiss(); - self.apply(Action::TogglePlanMode, false) + self.apply(Action::Cycle, InputMode::Prompt) } Event::Key(key) if is_multiline_enter(key) => { self.editor.insert_newline(); @@ -208,9 +216,11 @@ impl UiState { completion.dismiss(); self.submit_editor_or_accept() } - Event::Key(key) if key.code == KeyCode::PageUp => self.apply(Action::ScrollUp, false), + Event::Key(key) if key.code == KeyCode::PageUp => { + self.apply(Action::ScrollUp, InputMode::Prompt) + } Event::Key(key) if key.code == KeyCode::PageDown => { - self.apply(Action::ScrollDown, false) + self.apply(Action::ScrollDown, InputMode::Prompt) } Event::Key(key) if key.code == KeyCode::Char('c') @@ -390,8 +400,8 @@ impl UiState { rendered_lines: &[ratatui::text::Line<'static>], ) -> Option { match mouse.kind { - MouseEventKind::ScrollUp => self.apply(Action::MouseScrollUp, false), - MouseEventKind::ScrollDown => self.apply(Action::MouseScrollDown, false), + MouseEventKind::ScrollUp => self.apply(Action::MouseScrollUp, InputMode::Prompt), + MouseEventKind::ScrollDown => self.apply(Action::MouseScrollDown, InputMode::Prompt), MouseEventKind::Down(MouseButton::Left) => { if self.is_mouse_in_chat(mouse.column, mouse.row) { let now = Instant::now(); @@ -569,6 +579,13 @@ impl UiState { self.scroll_offset } + /// Pre-fill the overlay prompt so editing a value starts from it rather + /// than from an empty line. + pub fn seed_input(&mut self, text: String) { + self.input = text; + self.dirty = true; + } + pub(super) fn input(&self) -> &str { &self.input } @@ -735,6 +752,7 @@ pub struct AppView { chat: Chat, footer: Footer, login: LoginOverlay, + settings: SettingsOverlayView, } impl AppView { @@ -745,10 +763,18 @@ impl AppView { pub fn render(&mut self, frame: &mut Frame, controller: &Controller, state: &mut UiState) { use component::Component; - if controller.overlay() == Overlay::Login { - frame.render_widget(ratatui::widgets::Clear, frame.area()); - self.login.render(frame, frame.area(), controller, state); - return; + match controller.overlay() { + Overlay::Login => { + frame.render_widget(ratatui::widgets::Clear, frame.area()); + self.login.render(frame, frame.area(), controller, state); + return; + } + Overlay::Settings => { + frame.render_widget(ratatui::widgets::Clear, frame.area()); + self.settings.render(frame, frame.area(), controller, state); + return; + } + Overlay::None => {} } // Measure against the width the editor actually gets, not the frame's. diff --git a/crates/llm/src/apis/chat_completions/codec.rs b/crates/llm/src/apis/chat_completions/codec.rs index 21c626a..d5c6659 100644 --- a/crates/llm/src/apis/chat_completions/codec.rs +++ b/crates/llm/src/apis/chat_completions/codec.rs @@ -1,4 +1,6 @@ -use crate::{LlmError, LlmEvent, LlmRequest, Message, Role, StopReason, ToolSpec, Usage}; +use crate::{ + LlmError, LlmEvent, LlmRequest, Message, ReasoningEffort, Role, StopReason, ToolSpec, Usage, +}; use serde::{Deserialize, Serialize}; #[derive(Serialize)] @@ -159,10 +161,14 @@ pub(crate) struct StreamToolCall { pub(crate) fn serialize_request(request: &LlmRequest<'_>) -> Result { let messages = request.messages.iter().map(wire_message).collect(); let tools = request.tools.iter().map(wire_tool).collect(); - let reasoning = request.reasoning_effort.map(|effort| WireReasoning { - effort: effort.as_str().to_string(), - exclude: None, - }); + // How `Auto` reaches the provider is this protocol's business + let reasoning = match request.reasoning_effort { + ReasoningEffort::Auto => None, + effort => Some(WireReasoning { + effort: effort.as_str().to_string(), + exclude: None, + }), + }; serde_json::to_string(&Request { model: request.model_id, stream: true, @@ -364,7 +370,7 @@ fn role(role: Role) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::{RequestOptions, ToolDefinition, ToolSpec}; + use crate::{ReasoningEffort, RequestOptions, ToolDefinition, ToolSpec}; fn request<'a>( model_id: &'a str, @@ -378,7 +384,7 @@ mod tests { tools, options, credential: None, - reasoning_effort: None, + reasoning_effort: ReasoningEffort::Auto, } } @@ -420,13 +426,37 @@ mod tests { )]; let options = RequestOptions::default(); let mut request = request("model-a", &messages, &[], &options); - request.reasoning_effort = Some(crate::ReasoningEffort::High); + request.reasoning_effort = ReasoningEffort::High; let json: serde_json::Value = serde_json::from_str(&serialize_request(&request).unwrap()).unwrap(); assert_eq!(json["reasoning"]["effort"], "high"); assert_eq!(json["messages"][0]["reasoning_details"][0]["text"], "think"); } + /// `Auto` and `None` are opposites and must not produce the same request. + /// Omitting the field lets a reasoning model apply its own default, which + /// is typically enabled — so collapsing `none` into "omit" turns reasoning + /// *on* for someone who asked for it off. + #[test] + fn auto_omits_reasoning_but_none_disables_it_explicitly() { + let messages = [Message::user("hello")]; + let options = RequestOptions::default(); + + let mut request = request("model-a", &messages, &[], &options); + request.reasoning_effort = ReasoningEffort::Auto; + let json: serde_json::Value = + serde_json::from_str(&serialize_request(&request).unwrap()).unwrap(); + assert!( + json.get("reasoning").is_none(), + "Auto must omit the field entirely, got {json}" + ); + + request.reasoning_effort = ReasoningEffort::None; + let json: serde_json::Value = + serde_json::from_str(&serialize_request(&request).unwrap()).unwrap(); + assert_eq!(json["reasoning"]["effort"], "none"); + } + #[test] fn omits_empty_tools_and_unset_options() { let messages = [Message::user("hello")]; diff --git a/crates/llm/src/apis/chat_completions/mod.rs b/crates/llm/src/apis/chat_completions/mod.rs index 1948d0a..dad8948 100644 --- a/crates/llm/src/apis/chat_completions/mod.rs +++ b/crates/llm/src/apis/chat_completions/mod.rs @@ -142,7 +142,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::{Message, RequestOptions, ToolSpec}; + use crate::{Message, ReasoningEffort, RequestOptions, ToolSpec}; use futures_util::StreamExt; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -160,7 +160,7 @@ mod tests { tools, options, credential: None, - reasoning_effort: None, + reasoning_effort: ReasoningEffort::Auto, } } diff --git a/crates/llm/src/request.rs b/crates/llm/src/request.rs index 8d04526..319f90a 100644 --- a/crates/llm/src/request.rs +++ b/crates/llm/src/request.rs @@ -2,9 +2,11 @@ use crate::{Message, ToolSpec}; use serde::{Deserialize, Serialize}; use std::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ReasoningEffort { + #[default] + Auto, None, Minimal, Low, @@ -15,8 +17,21 @@ pub enum ReasoningEffort { } impl ReasoningEffort { + /// Every level, in the order a user interface should offer them. + pub const ALL: &'static [Self] = &[ + Self::Auto, + Self::None, + Self::Minimal, + Self::Low, + Self::Medium, + Self::High, + Self::XHigh, + Self::Max, + ]; + pub const fn as_str(&self) -> &'static str { match self { + Self::Auto => "auto", Self::None => "none", Self::Minimal => "minimal", Self::Low => "low", @@ -26,6 +41,14 @@ impl ReasoningEffort { Self::Max => "max", } } + + /// The inverse of [`as_str`](Self::as_str), so the two cannot drift. + pub fn parse(raw: &str) -> Option { + Self::ALL + .iter() + .copied() + .find(|effort| effort.as_str() == raw) + } } impl fmt::Display for ReasoningEffort { @@ -34,6 +57,22 @@ impl fmt::Display for ReasoningEffort { } } +#[cfg(test)] +mod reasoning_effort_tests { + use super::ReasoningEffort; + + /// `as_str` and the `rename_all` derive spell each level independently, and + /// a settings file written by one is read by the other. + #[test] + fn every_level_is_spelled_the_same_by_serde() { + for effort in ReasoningEffort::ALL { + let written = serde_json::to_string(effort).expect("serialize"); + assert_eq!(written, format!("\"{}\"", effort.as_str())); + assert_eq!(ReasoningEffort::parse(effort.as_str()), Some(*effort)); + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Credential { ApiKey(String), @@ -101,5 +140,5 @@ pub struct LlmRequest<'a> { pub tools: &'a [ToolSpec], pub options: &'a RequestOptions, pub credential: Option<&'a Credential>, - pub reasoning_effort: Option, + pub reasoning_effort: ReasoningEffort, } diff --git a/crates/providers/src/catalog.rs b/crates/providers/src/catalog.rs index 905de5c..b9c3d59 100644 --- a/crates/providers/src/catalog.rs +++ b/crates/providers/src/catalog.rs @@ -30,7 +30,7 @@ pub struct ModelCapabilities { pub tools: bool, pub vision: bool, #[serde(default)] - pub reasoning: Option, + pub reasoning: ReasoningEffort, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/crates/providers/src/lib.rs b/crates/providers/src/lib.rs index a105462..a0d9569 100644 --- a/crates/providers/src/lib.rs +++ b/crates/providers/src/lib.rs @@ -81,16 +81,4 @@ mod tests { assert_eq!(response.model.as_deref(), Some("test-model")); assert_eq!(provider.models().len(), 1); } - - #[test] - fn missing_model_is_reported() { - let provider = OpenRouterProvider::builder("key") - .with_model("known") - .build() - .unwrap(); - assert!(matches!( - provider.bind("missing"), - Err(ProviderError::ModelNotFound(_)) - )); - } } diff --git a/crates/providers/src/model.rs b/crates/providers/src/model.rs index f2a3337..e34a1dd 100644 --- a/crates/providers/src/model.rs +++ b/crates/providers/src/model.rs @@ -18,7 +18,7 @@ pub enum ModelError { #[derive(Clone, Default)] pub struct ModelOptions { pub server_tools: Vec, - pub reasoning_effort: Option, + pub reasoning_effort: ReasoningEffort, } #[derive(Clone)] @@ -27,7 +27,7 @@ pub struct Model { api: Arc, auth: Arc, server_tools: Vec, - reasoning_effort: Option, + reasoning_effort: ReasoningEffort, } impl Model { @@ -37,7 +37,12 @@ impl Model { auth: Arc, options: ModelOptions, ) -> Self { - let reasoning_effort = options.reasoning_effort.or(info.capabilities.reasoning); + // `Auto` means "no opinion", so it defers to whatever the catalog + // declares for this model. + let reasoning_effort = match options.reasoning_effort { + ReasoningEffort::Auto => info.capabilities.reasoning, + chosen => chosen, + }; Self { info, api, @@ -51,7 +56,7 @@ impl Model { &self.info } - pub fn reasoning_effort(&self) -> Option { + pub fn reasoning_effort(&self) -> ReasoningEffort { self.reasoning_effort } diff --git a/crates/providers/src/openrouter.rs b/crates/providers/src/openrouter.rs index ed70b69..df28fd2 100644 --- a/crates/providers/src/openrouter.rs +++ b/crates/providers/src/openrouter.rs @@ -6,7 +6,7 @@ use crate::{ ProviderError, ProviderId, ServerToolInfo, }; use async_trait::async_trait; -use llm::{ChatCompletionsApi, HttpClient, LlmApi}; +use llm::{ChatCompletionsApi, HttpClient, LlmApi, ReasoningEffort}; use reqwest::StatusCode; use std::{collections::HashMap, sync::Arc}; @@ -92,13 +92,7 @@ impl Provider for OpenRouterProvider { &self.server_tools } fn bind(&self, model_id: &str) -> Result { - bind_model( - &self.models, - &self.apis, - self.auth.clone(), - model_id, - ModelOptions::default(), - ) + self.bind_with_options(model_id, ModelOptions::default()) } fn bind_with_options( @@ -106,13 +100,14 @@ impl Provider for OpenRouterProvider { model_id: &str, options: ModelOptions, ) -> Result { - bind_model( - &self.models, - &self.apis, - self.auth.clone(), - model_id, - options, - ) + let info = self + .models + .iter() + .find(|model| model.id == model_id) + .cloned() + .unwrap_or_else(|| default_model(model_id)); + + bind_model(info, &self.apis, self.auth.clone(), options) } fn auth(&self) -> &dyn ProviderAuth { &self.login @@ -196,13 +191,61 @@ fn default_model(id: &str) -> ModelInfo { id: id.into(), name: id.into(), api: ApiId::ChatCompletions, - //TODO: Do we need this ? + // `reasoning` is the only field read: it is what `Auto` defers to. capabilities: ModelCapabilities { streaming: true, tools: true, vision: false, - reasoning: None, + reasoning: ReasoningEffort::Auto, }, pricing: Some(ModelPricing::default()), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Switching models mid-session binds an id the catalog has never seen. + /// Rejecting it here would mean only the startup model ever worked. + #[test] + fn binds_a_model_the_catalog_does_not_know() { + let provider = OpenRouterProvider::builder("key") + .with_model("openai/gpt-4o-mini") + .build() + .expect("build"); + + let bound = provider.bind("anthropic/claude-opus-4").expect("binds"); + assert_eq!(bound.info().id, "anthropic/claude-opus-4"); + } + + /// Guards the order of the lookup: synthesising first would silently + /// replace every configured model's capabilities with the defaults. + #[test] + fn a_known_model_keeps_its_catalog_entry() { + let info = ModelInfo { + provider: ProviderId::new("openrouter"), + id: "known".into(), + name: "Known".into(), + api: ApiId::ChatCompletions, + capabilities: ModelCapabilities { + streaming: true, + tools: true, + vision: true, + reasoning: ReasoningEffort::High, + }, + pricing: None, + }; + let provider = OpenRouterProvider::builder("key") + .with_models([info]) + .build() + .expect("build"); + + let bound = provider.bind("known").expect("binds"); + assert!( + bound.info().capabilities.vision, + "not the synthesised entry" + ); + assert_eq!(bound.reasoning_effort(), ReasoningEffort::High); + } +} diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index 5d88a4b..23dc186 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -33,7 +33,7 @@ pub trait Provider: Send + Sync { fn auth(&self) -> &dyn ProviderAuth; } -#[derive(Default)] +#[derive(Default, Clone)] pub struct ProviderRegistry { providers: Vec>, } @@ -52,18 +52,14 @@ impl ProviderRegistry { } } +/// Wires a model to the API that serves it. Finding the [`ModelInfo`] is the +/// provider's job, since only it knows what to do with an id it has never seen. pub(crate) fn bind_model( - models: &[ModelInfo], + info: ModelInfo, apis: &HashMap>, auth: Arc, - model_id: &str, options: ModelOptions, ) -> Result { - let info = models - .iter() - .find(|model| model.id == model_id) - .cloned() - .ok_or_else(|| ProviderError::ModelNotFound(model_id.into()))?; let api = apis .get(&info.api) .cloned()