diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs index d40902c3543..2701744b085 100644 --- a/crates/atuin-ai/src/commands/inline.rs +++ b/crates/atuin-ai/src/commands/inline.rs @@ -179,7 +179,7 @@ async fn run_inline_tui( .await?; // ─── Build FSM ─────────────────────────────────────────────── - let (session_mgr, fsm, file_tracker, edit_permissions) = if let Some(stored) = resumable { + let (session_mgr, mut fsm, file_tracker, edit_permissions) = if let Some(stored) = resumable { debug!(session_id = %stored.id, "resuming AI session"); let (mgr, mut events, server_sid, last_event_ts, invocation_id) = SessionManager::resume(Box::new(service), &stored).await?; @@ -239,6 +239,16 @@ async fn run_inline_tui( (mgr, fsm, Default::default(), Default::default()) }; + // `ai.model` is read once at startup, so /model in another running + // session doesn't retarget this one mid-conversation. + fsm.ctx.model = settings + .ai + .model + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from); + // ─── Snapshot store ───────────────────────────────────────── let snapshot_dir = atuin_common::utils::data_dir() .join("ai") @@ -400,6 +410,8 @@ fn build_view_state( last_event_time: fsm.ctx.last_event_time, in_git_project, archived_events, + model_picker: fsm.ctx.model_picker.clone(), + model: fsm.ctx.model.clone(), turns, has_command, committed_turn_count: 0, diff --git a/crates/atuin-ai/src/driver.rs b/crates/atuin-ai/src/driver.rs index 9df3fbf872f..13ea6c9b548 100644 --- a/crates/atuin-ai/src/driver.rs +++ b/crates/atuin-ai/src/driver.rs @@ -83,6 +83,10 @@ pub(crate) struct ViewState { // ─── View-only ────────────────────────────────────────────── pub archived_events: Vec, + /// Open /model picker, if any (mirrors `AgentContext::model_picker`). + pub model_picker: Option, + /// Model alias currently in effect (`None` = server default). + pub model: Option, // ─── Pre-computed for rendering ──────────────────────────── pub turns: Vec, @@ -255,6 +259,8 @@ fn translate_tui_event( Some(Event::ExecuteCommand) } else if input == "/new" { Some(Event::NewSession) + } else if input == "/model" || input.starts_with("/model ") { + Some(Event::OpenModelPicker) } else if input.starts_with('/') { if let Some((skill_name, arguments)) = resolve_skill_name(&input, handle) { Some(Event::RequestSkillLoad { @@ -329,6 +335,7 @@ fn translate_tui_event( }; Some(Event::PermissionUserChoice { tool_id, choice }) } + AiTuiEvent::SelectModel(alias) => Some(Event::ModelSelected(alias)), AiTuiEvent::SlashCommand(cmd) => { if let Some((skill_name, arguments)) = resolve_skill_name(&cmd, handle) { Some(Event::RequestSkillLoad { @@ -414,6 +421,8 @@ fn sync_view_state(handle: &Handle, fsm: &AgentFsm, in_git_project: b let is_resumed = fsm.ctx.is_resumed; let last_event_time = fsm.ctx.last_event_time; let archived_events = fsm.ctx.archived_events.clone(); + let model_picker = fsm.ctx.model_picker.clone(); + let model = fsm.ctx.model.clone(); // Inject streaming text as a synthetic event for live rendering. // The FSM commits text to events on stream end; this makes it visible during streaming. @@ -462,6 +471,8 @@ fn sync_view_state(handle: &Handle, fsm: &AgentFsm, in_git_project: b vs.last_event_time = last_event_time; vs.in_git_project = in_git_project; vs.archived_events = archived_events; + vs.model_picker = model_picker; + vs.model = model; vs.turns = turns; vs.has_command = has_command; vs.archived_turn_count = archived_turn_count; @@ -505,6 +516,7 @@ fn execute_effect(effect: &Effect, ctx: DriverContext) { &app.capabilities, app.daemon_enabled, fsm.ctx.invocation_id.clone(), + fsm.ctx.model.clone(), ); tokio::spawn(async move { run_stream_bridge( @@ -850,6 +862,27 @@ fn execute_effect(effect: &Effect, ctx: DriverContext) { io.edit_permissions.grant(path.clone()); } + Effect::FetchModels => { + let tx = tx.clone(); + let endpoint = io.app_ctx.endpoint.clone(); + let token = io.app_ctx.token.clone(); + tokio::spawn(async move { + let result = crate::models::fetch_models(&endpoint, &token) + .await + .map_err(|e| e.to_string()); + let _ = tx.send(DriverEvent::Fsm(Event::ModelListLoaded(result))); + }); + } + + Effect::SaveModelSelection { alias } => { + let alias = alias.clone(); + tokio::spawn(async move { + if let Err(e) = crate::models::save_model_selection(&alias).await { + tracing::error!("Failed to save model selection: {e}"); + } + }); + } + Effect::ArchiveSession => { let rt = tokio::runtime::Handle::current(); if let Err(e) = rt.block_on(io.session_mgr.archive_and_reset()) { diff --git a/crates/atuin-ai/src/fsm/effects.rs b/crates/atuin-ai/src/fsm/effects.rs index adc9628e54f..e43089e52eb 100644 --- a/crates/atuin-ai/src/fsm/effects.rs +++ b/crates/atuin-ai/src/fsm/effects.rs @@ -50,6 +50,8 @@ pub(crate) enum Effect { name: String, arguments: Option, }, + /// Fetch the available model list from the server. + FetchModels, // ─── Persistence ──────────────────────────────────────────── /// Persist current conversation state to disk. @@ -62,6 +64,8 @@ pub(crate) enum Effect { }, /// Cache a session-scoped file permission grant. CacheSessionGrant { path: PathBuf }, + /// Persist the selected model alias to `ai.model` in config.toml. + SaveModelSelection { alias: String }, /// Archive current session and start fresh (IO only — state already updated by FSM). ArchiveSession, diff --git a/crates/atuin-ai/src/fsm/events.rs b/crates/atuin-ai/src/fsm/events.rs index f78b82a84ef..bb1e70043cd 100644 --- a/crates/atuin-ai/src/fsm/events.rs +++ b/crates/atuin-ai/src/fsm/events.rs @@ -2,6 +2,7 @@ use serde_json::Value; +use crate::models::ModelList; use crate::tools::ToolOutcome; /// Events that drive state transitions in the agent FSM. @@ -107,6 +108,14 @@ pub(crate) enum Event { content: String, }, + // ─── Model selection ──────────────────────────────────────── + /// User ran /model — open the model picker. + OpenModelPicker, + /// The model list fetch finished (spawned by FetchModels). + ModelListLoaded(Result), + /// User picked a model from the picker. + ModelSelected(String), + // ─── Skills ──────────────────────────────────────────────── /// User invoked a skill via /skill-name. FSM emits a LoadSkill /// effect; the driver loads the content asynchronously and sends diff --git a/crates/atuin-ai/src/fsm/mod.rs b/crates/atuin-ai/src/fsm/mod.rs index 3b9b6f02fc6..87cf5432d10 100644 --- a/crates/atuin-ai/src/fsm/mod.rs +++ b/crates/atuin-ai/src/fsm/mod.rs @@ -81,6 +81,16 @@ pub(crate) struct PendingConfirmation { pub timeout_id: u64, } +/// The /model picker, rendered by the view when present on the context. +/// +/// While `Loading` the input box stays visible (there'd be no focusable +/// component otherwise); `Ready` swaps it for the selection list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ModelPicker { + Loading, + Ready(crate::models::ModelList), +} + // ============================================================================ // Context // ============================================================================ @@ -109,6 +119,14 @@ pub(crate) struct AgentContext { pub capabilities: Vec, /// Unique invocation ID for this CLI invocation. pub invocation_id: String, + /// Model alias sent with chat requests. `None` = server default. + /// Seeded from `ai.model` at startup; updated by the /model picker. + pub model: Option, + /// Model list fetched this invocation. Later /model calls reuse it + /// instead of re-hitting the server. + pub models_cache: Option, + /// Open /model picker, if any. + pub model_picker: Option, // ─── View state (owned by FSM for atomic transitions) ─────── /// Index into events where the current TUI invocation starts. @@ -159,6 +177,9 @@ impl AgentFsm { next_timeout_id: 0, capabilities, invocation_id, + model: None, + models_cache: None, + model_picker: None, view_start_index: 0, is_resumed: false, last_event_time: None, @@ -189,6 +210,9 @@ impl AgentFsm { next_timeout_id: 0, capabilities, invocation_id, + model: None, + models_cache: None, + model_picker: None, view_start_index, is_resumed, last_event_time, @@ -277,7 +301,12 @@ impl AgentFsm { } (AgentState::Idle { confirmation: None }, Event::Cancel) => { - vec![Effect::ExitApp(ExitAction::Cancel)] + if self.ctx.model_picker.is_some() { + self.ctx.model_picker = None; + vec![] + } else { + vec![Effect::ExitApp(ExitAction::Cancel)] + } } (AgentState::Idle { .. }, Event::ConfirmationTimeout { timeout_id }) => { @@ -301,6 +330,7 @@ impl AgentFsm { self.ctx.current_turn_tool_ids.clear(); self.ctx.view_start_index = 0; self.ctx.is_resumed = false; + self.ctx.model_picker = None; // Add OOB indicator for the new session self.ctx.events.push(ConversationEvent::OutOfBandOutput { @@ -318,6 +348,33 @@ impl AgentFsm { vec![] } + (AgentState::Idle { .. }, Event::OpenModelPicker) => { + if let Some(list) = self.ctx.models_cache.clone() { + self.ctx.model_picker = Some(ModelPicker::Ready(list)); + vec![] + } else { + self.ctx.model_picker = Some(ModelPicker::Loading); + vec![Effect::FetchModels] + } + } + + (AgentState::Idle { .. }, Event::ModelSelected(alias)) => { + self.ctx.model_picker = None; + self.ctx.model = Some(alias.clone()); + let display = self + .ctx + .models_cache + .as_ref() + .and_then(|list| list.models.iter().find(|m| m.alias == alias)) + .map(|m| m.name.clone()) + .unwrap_or_else(|| alias.clone()); + self.handle_slash_command( + "/model", + &format!("Model set to {display} for this and future sessions."), + ); + vec![Effect::SaveModelSelection { alias }] + } + ( AgentState::Idle { .. }, Event::SkillLoaded { @@ -620,6 +677,30 @@ impl AgentFsm { vec![] } + // The fetch may finish after the picker was dismissed (user + // submitted a message or hit Esc while loading) — always cache + // the list, but only surface UI if the picker is still waiting. + (_, Event::ModelListLoaded(result)) => { + match result { + Ok(list) => { + self.ctx.models_cache = Some(list.clone()); + if self.ctx.model_picker == Some(ModelPicker::Loading) { + self.ctx.model_picker = Some(ModelPicker::Ready(list)); + } + } + Err(e) => { + if self.ctx.model_picker == Some(ModelPicker::Loading) { + self.ctx.model_picker = None; + self.handle_slash_command( + "/model", + &format!("Could not load the model list: {e}"), + ); + } + } + } + vec![] + } + // RequestSkillLoad during non-idle: still emit the effect (_, Event::RequestSkillLoad { name, arguments }) => { vec![Effect::LoadSkill { name, arguments }] @@ -653,6 +734,8 @@ impl AgentFsm { /// Start a new turn: push user message, build messages, emit StartStream. fn start_turn(&mut self, msg: String) -> Vec { + // A message submitted while the picker was loading dismisses it. + self.ctx.model_picker = None; self.ctx .events .push(ConversationEvent::UserMessage { content: msg }); diff --git a/crates/atuin-ai/src/fsm/tests.rs b/crates/atuin-ai/src/fsm/tests.rs index 51c239150f4..7cd96750afd 100644 --- a/crates/atuin-ai/src/fsm/tests.rs +++ b/crates/atuin-ai/src/fsm/tests.rs @@ -888,3 +888,144 @@ fn user_interrupt_clears_timeout_mappings_for_aborted_tools() { assert!(fsm.ctx.tool_timeout_ids.is_empty()); } + +// ============================================================================ +// Model picker +// ============================================================================ + +fn model_list() -> crate::models::ModelList { + crate::models::ModelList { + default: "fast".to_string(), + models: vec![ + crate::models::ModelInfo { + alias: "fast".to_string(), + name: "Comet".to_string(), + description: "Fastest model".to_string(), + }, + crate::models::ModelInfo { + alias: "deep".to_string(), + name: "Constellation".to_string(), + description: "Deeper reasoning".to_string(), + }, + ], + } +} + +#[test] +fn open_model_picker_fetches_when_uncached() { + let mut fsm = new_fsm(); + + let effects = fsm.handle(Event::OpenModelPicker); + + assert_eq!(fsm.ctx.model_picker, Some(ModelPicker::Loading)); + assert!(matches!(effects[..], [Effect::FetchModels])); +} + +#[test] +fn open_model_picker_reuses_cache_without_fetching() { + let mut fsm = new_fsm(); + fsm.ctx.models_cache = Some(model_list()); + + let effects = fsm.handle(Event::OpenModelPicker); + + assert_eq!(fsm.ctx.model_picker, Some(ModelPicker::Ready(model_list()))); + assert!(effects.is_empty()); +} + +#[test] +fn model_list_loaded_populates_picker_and_cache() { + let mut fsm = new_fsm(); + fsm.handle(Event::OpenModelPicker); + + let effects = fsm.handle(Event::ModelListLoaded(Ok(model_list()))); + + assert_eq!(fsm.ctx.models_cache, Some(model_list())); + assert_eq!(fsm.ctx.model_picker, Some(ModelPicker::Ready(model_list()))); + assert!(effects.is_empty()); +} + +#[test] +fn model_list_loaded_after_dismissal_caches_but_keeps_picker_closed() { + let mut fsm = new_fsm(); + fsm.handle(Event::OpenModelPicker); + fsm.handle(Event::Cancel); // dismiss while loading + + fsm.handle(Event::ModelListLoaded(Ok(model_list()))); + + assert_eq!(fsm.ctx.models_cache, Some(model_list())); + assert_eq!(fsm.ctx.model_picker, None); +} + +#[test] +fn model_list_load_failure_closes_picker_with_message() { + let mut fsm = new_fsm(); + fsm.handle(Event::OpenModelPicker); + + fsm.handle(Event::ModelListLoaded(Err("boom".to_string()))); + + assert_eq!(fsm.ctx.model_picker, None); + assert!(fsm.ctx.models_cache.is_none()); + assert!(fsm.ctx.events.iter().any(|e| matches!( + e, + ConversationEvent::OutOfBandOutput { content, .. } if content.contains("boom") + ))); +} + +#[test] +fn model_selected_sets_model_and_persists() { + let mut fsm = new_fsm(); + fsm.handle(Event::OpenModelPicker); + fsm.handle(Event::ModelListLoaded(Ok(model_list()))); + + let effects = fsm.handle(Event::ModelSelected("deep".to_string())); + + assert_eq!(fsm.ctx.model, Some("deep".to_string())); + assert_eq!(fsm.ctx.model_picker, None); + assert!(matches!( + &effects[..], + [Effect::SaveModelSelection { alias }] if alias == "deep" + )); + // Confirmation names the model, not just the alias + assert!(fsm.ctx.events.iter().any(|e| matches!( + e, + ConversationEvent::OutOfBandOutput { content, .. } if content.contains("Constellation") + ))); +} + +#[test] +fn cancel_closes_picker_instead_of_exiting() { + let mut fsm = new_fsm(); + fsm.handle(Event::OpenModelPicker); + + let effects = fsm.handle(Event::Cancel); + + assert_eq!(fsm.ctx.model_picker, None); + assert!(effects.is_empty()); + + // A second Cancel with no picker open exits as usual + let effects = fsm.handle(Event::Cancel); + assert!(matches!(effects[..], [Effect::ExitApp(ExitAction::Cancel)])); +} + +#[test] +fn user_submit_dismisses_loading_picker() { + let mut fsm = new_fsm(); + fsm.handle(Event::OpenModelPicker); + + fsm.handle(Event::UserSubmit("hello".into())); + + assert_eq!(fsm.ctx.model_picker, None); +} + +#[test] +fn selected_model_survives_new_session() { + let mut fsm = new_fsm(); + fsm.ctx.models_cache = Some(model_list()); + fsm.handle(Event::OpenModelPicker); + fsm.handle(Event::ModelSelected("deep".to_string())); + + fsm.handle(Event::NewSession); + + assert_eq!(fsm.ctx.model, Some("deep".to_string())); + assert_eq!(fsm.ctx.models_cache, Some(model_list())); +} diff --git a/crates/atuin-ai/src/lib.rs b/crates/atuin-ai/src/lib.rs index f972d4ff690..319dcfa4560 100644 --- a/crates/atuin-ai/src/lib.rs +++ b/crates/atuin-ai/src/lib.rs @@ -8,6 +8,7 @@ pub(crate) mod event_serde; pub(crate) mod file_tracker; pub(crate) mod fsm; pub(crate) mod history_format; +pub(crate) mod models; pub(crate) mod permissions; pub(crate) mod session; pub(crate) mod skills; diff --git a/crates/atuin-ai/src/models.rs b/crates/atuin-ai/src/models.rs new file mode 100644 index 00000000000..0795bc88b5c --- /dev/null +++ b/crates/atuin-ai/src/models.rs @@ -0,0 +1,70 @@ +//! Model listing and selection. +//! +//! The hub exposes the models available to this user at `/api/cli/models`. +//! Aliases are what we send on the wire; names and descriptions are for +//! display in the `/model` picker. + +use std::time::Duration; + +use eyre::{Context, Result}; +use reqwest::header::USER_AGENT; +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct ModelInfo { + pub alias: String, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct ModelList { + /// Alias the server uses when a request doesn't specify a model. + pub default: String, + pub models: Vec, +} + +/// Fetch the models available to this user. Sent authenticated because the +/// server includes feature-flag-gated models only for entitled users. +pub(crate) async fn fetch_models(endpoint: &str, token: &str) -> Result { + atuin_common::tls::ensure_crypto_provider(); + let url = crate::stream::hub_url(endpoint, "/api/cli/models")?; + + let response = reqwest::Client::new() + .get(url) + .header(USER_AGENT, crate::stream::APP_USER_AGENT) + .bearer_auth(token) + .timeout(Duration::from_secs(10)) + .send() + .await + .context("failed to fetch model list")?; + + let status = response.status(); + if !status.is_success() { + eyre::bail!("model list request failed ({status})"); + } + + response + .json::() + .await + .context("failed to parse model list") +} + +/// Persist the chosen alias to `ai.model` in config.toml so it becomes the +/// default for future sessions. Already-running sessions keep the model they +/// read at startup. +pub(crate) async fn save_model_selection(alias: &str) -> Result<()> { + let config_file = atuin_client::settings::Settings::get_config_path()?; + let config_str = tokio::fs::read_to_string(&config_file) + .await + .unwrap_or_default(); + let mut doc = config_str.parse::()?; + + if !doc.contains_key("ai") { + doc["ai"] = toml_edit::table(); + } + doc["ai"]["model"] = toml_edit::value(alias); + + tokio::fs::write(&config_file, doc.to_string()).await?; + Ok(()) +} diff --git a/crates/atuin-ai/src/stream.rs b/crates/atuin-ai/src/stream.rs index 81b4f55097e..467695f3024 100644 --- a/crates/atuin-ai/src/stream.rs +++ b/crates/atuin-ai/src/stream.rs @@ -16,7 +16,7 @@ use reqwest::header::USER_AGENT; use crate::context::ClientContext; -static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION")); +pub(crate) static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION")); /// Frames that alter the stream lifecycle — terminal or state-changing. #[derive(Debug, Clone)] @@ -58,6 +58,9 @@ pub(crate) struct ChatRequest { pub session_id: Option, pub capabilities: Vec, pub invocation_id: String, + /// Model alias to request. `None` omits the key so the server default + /// applies (and tracks server-side default changes without a client update). + pub model: Option, } impl ChatRequest { @@ -67,6 +70,7 @@ impl ChatRequest { capabilities: &AiCapabilities, history_output_available: bool, invocation_id: String, + model: Option, ) -> Self { let mut caps = vec![ "client_invocations".to_string(), @@ -102,6 +106,7 @@ impl ChatRequest { session_id, capabilities: caps, invocation_id, + model, } } } @@ -147,13 +152,10 @@ pub(crate) fn create_chat_stream( } } - if let Ok(model) = std::env::var("ATUIN_AI__MODEL") - && !model.trim().is_empty() { - config["model"] = serde_json::json!(model.trim()); - + if let Some(ref model) = request.model { + config["model"] = serde_json::json!(model); } - let mut request_body = serde_json::json!({ "messages": request.messages, "context": context, @@ -284,7 +286,7 @@ pub(crate) fn create_chat_stream( }) } -fn hub_url(base: &str, path: &str) -> Result { +pub(crate) fn hub_url(base: &str, path: &str) -> Result { let base_with_slash = if base.ends_with('/') { base.to_string() } else { diff --git a/crates/atuin-ai/src/tui/events.rs b/crates/atuin-ai/src/tui/events.rs index abcb1bd9bcf..ede49ca5866 100644 --- a/crates/atuin-ai/src/tui/events.rs +++ b/crates/atuin-ai/src/tui/events.rs @@ -15,6 +15,8 @@ pub(crate) enum AiTuiEvent { SlashCommand(String), /// User selected a permission SelectPermission(PermissionResult), + /// User picked a model alias from the /model picker + SelectModel(String), /// Cancel active generation or streaming (Esc during Generating/Streaming) CancelGeneration, /// Execute the suggested command diff --git a/crates/atuin-ai/src/tui/slash.rs b/crates/atuin-ai/src/tui/slash.rs index 464d3a48bb8..3de9a07132e 100644 --- a/crates/atuin-ai/src/tui/slash.rs +++ b/crates/atuin-ai/src/tui/slash.rs @@ -73,6 +73,10 @@ impl Default for SlashCommandRegistry { fn default() -> Self { let mut registry = Self::new(); registry.register(SlashCommand::new("help", "Show help information")); + registry.register(SlashCommand::new( + "model", + "Select the AI model to use for this and future sessions", + )); registry.register(SlashCommand::new( "new", "Start a new conversation, archiving the current one", diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs index b594cedf9d5..13d65b6cbfa 100644 --- a/crates/atuin-ai/src/tui/view/mod.rs +++ b/crates/atuin-ai/src/tui/view/mod.rs @@ -6,7 +6,7 @@ use eye_declare::{ use ratatui_core::style::{Color, Modifier, Style}; use crate::driver::ViewState; -use crate::fsm::{AgentState, StreamPhase}; +use crate::fsm::{AgentState, ModelPicker, StreamPhase}; use crate::tools::{ClientToolCall, HistorySearchFilterMode, ToolPreview}; use crate::tui::components::select::SelectOption; use crate::tui::components::session_continue::SessionContinue; @@ -115,12 +115,36 @@ fn input_view(state: &ViewState) -> Elements { .collect::>(); let first_slash_result = slash_results.first().cloned(); + // While the model list loads, the input box stays up (with a spinner + // line) so a focusable component always exists; once Ready, the Select + // replaces the input box like the permission prompt does. + let ready_picker = match &state.model_picker { + Some(ModelPicker::Ready(list)) => Some(list), + _ => None, + }; + let picker_loading = matches!(state.model_picker, Some(ModelPicker::Loading)); + element! { #(if let Some(tc) = asking_tool { #(tool_call_view(tc, in_git_project)) }) - #(if asking_tool.is_none() { + #(if let Some(list) = ready_picker { + #(if asking_tool.is_none() { + #(model_picker_view(list, state.model.as_deref())) + }) + }) + + #(if picker_loading { + View(key: "model-picker-loading", padding_top: Cells::from(1)) { + Spinner( + label: "Loading models…", + label_style: Style::default().fg(Color::Gray), + ) + } + }) + + #(if asking_tool.is_none() && ready_picker.is_none() { View(key: "input-box", padding_top: Cells::from(1)) { InputBox( key: "input", @@ -160,6 +184,40 @@ fn input_view(state: &ViewState) -> Elements { } } +/// Render the /model picker: one row per model, the in-use model marked. +/// `current` is the session's explicit selection; when unset, the server +/// default is what's actually in use, so mark that row instead. +fn model_picker_view(list: &crate::models::ModelList, current: Option<&str>) -> Elements { + let in_use = current.unwrap_or(&list.default); + let options: Vec = list + .models + .iter() + .map(|m| { + let marker = if m.alias == in_use { " (current)" } else { "" }; + SelectOption::builder() + .label(format!("{} — {}{}", m.name, m.description, marker)) + .value(m.alias.clone()) + .build() + }) + .collect(); + + element! { + View(key: "model-picker", padding_left: Cells::from(2), padding_top: Cells::from(1)) { + Text { + Span(text: "Select a model:", style: Style::default().add_modifier(Modifier::BOLD)) + } + View(padding_left: Cells::from(2)) { + Select(options: options, on_select: Box::new(move |option: &SelectOption| { + Some(AiTuiEvent::SelectModel(option.value.clone())) + }) as Box Option + Send + Sync>) + } + Text { + Span(text: "[Esc] Cancel", style: Style::default().fg(Color::DarkGray)) + } + } + } +} + fn tool_call_view(tool_call: &crate::fsm::tools::TrackedTool, in_git_project: bool) -> Elements { let verb = tool_call.tool.descriptor().display_verb; let tool_desc = match &tool_call.tool { diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 1be6f363290..ee8dd483f06 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -670,6 +670,9 @@ pub struct Ai { /// The maximum time in minutes that an AI session can be automatically resumed. pub session_continue_minutes: i64, + /// The AI model to use for AI chats, based on the Atuin AI model alias. + pub model: Option, + /// Deprecated: use opening.send_cwd instead. Kept for backwards compatibility. #[serde(default)] pub send_cwd: Option, diff --git a/docs/docs/ai/settings.md b/docs/docs/ai/settings.md index edc54aaf765..f78a0ca5927 100644 --- a/docs/docs/ai/settings.md +++ b/docs/docs/ai/settings.md @@ -8,6 +8,12 @@ Default: `false` Whether or not the AI feature are enabled. When set to `false`, the question mark keybinding will output a message with instructions to run `atuin setup` to enable the feature. +### model + +Default: unset + +The Atuin AI model to use for new sessions. If unset, the default model will be used. You can see the available models by running `/model` inside the Atuin AI interface. + ### db_path Default: `ai_sessions.db` in the Atuin data directory.