Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion crates/atuin-ai/src/commands/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions crates/atuin-ai/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ pub(crate) struct ViewState {

// ─── View-only ──────────────────────────────────────────────
pub archived_events: Vec<ConversationEvent>,
/// Open /model picker, if any (mirrors `AgentContext::model_picker`).
pub model_picker: Option<crate::fsm::ModelPicker>,
/// Model alias currently in effect (`None` = server default).
pub model: Option<String>,

// ─── Pre-computed for rendering ────────────────────────────
pub turns: Vec<turn::UiTurn>,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -414,6 +421,8 @@ fn sync_view_state(handle: &Handle<ViewState>, 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.
Expand Down Expand Up @@ -462,6 +471,8 @@ fn sync_view_state(handle: &Handle<ViewState>, 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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)));
});
}
Comment thread
BinaryMuse marked this conversation as resolved.

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()) {
Expand Down
4 changes: 4 additions & 0 deletions crates/atuin-ai/src/fsm/effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub(crate) enum Effect {
name: String,
arguments: Option<String>,
},
/// Fetch the available model list from the server.
FetchModels,

// ─── Persistence ────────────────────────────────────────────
/// Persist current conversation state to disk.
Expand All @@ -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,

Expand Down
9 changes: 9 additions & 0 deletions crates/atuin-ai/src/fsm/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<ModelList, String>),
/// 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
Expand Down
85 changes: 84 additions & 1 deletion crates/atuin-ai/src/fsm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -109,6 +119,14 @@ pub(crate) struct AgentContext {
pub capabilities: Vec<String>,
/// 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<String>,
/// Model list fetched this invocation. Later /model calls reuse it
/// instead of re-hitting the server.
pub models_cache: Option<crate::models::ModelList>,
/// Open /model picker, if any.
pub model_picker: Option<ModelPicker>,

// ─── View state (owned by FSM for atomic transitions) ───────
/// Index into events where the current TUI invocation starts.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 }]
Expand Down Expand Up @@ -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<Effect> {
// A message submitted while the picker was loading dismisses it.
self.ctx.model_picker = None;
self.ctx
.events
.push(ConversationEvent::UserMessage { content: msg });
Expand Down
Loading
Loading