diff options
| author | Michelle Tilley <michelle@michelletilley.net> | 2026-04-14 16:03:08 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-15 00:03:08 +0100 |
| commit | fd188da879d977ca847f10708c39dd4801a204c4 (patch) | |
| tree | 592bfe2644f8bd9be3563f176eabf29e55fa9a9b /crates/atuin-ai/src/tui/dispatch.rs | |
| parent | fix: dependency fix (#3414) (diff) | |
| download | atuin-fd188da879d977ca847f10708c39dd4801a204c4.zip | |
feat: Allow resuming previous AI sessions (#3407)
This PR introduces session continuation to Atuin AI.
* Conversations with Atuin AI are stored in a local SQLite database
* Upon startup, Atuin AI tries to find a session to resume based on its
directory/workspace and the time since the last event
* If found, Atuin AI will show a note that the session has been resumed,
and an event is added to help the LLM know where the invocation
boundaries are
* If not, Atuin AI will create a new conversation
* The user can create a new conversation with `/new`
* The new setting `ai.session_continue_minutes`, which defaults to `60`,
controls how old the last event in a session can be before it's no
longer considered for automatic resuming.
<img width="1055" height="593" alt="image"
src="https://github.com/user-attachments/assets/3f9ff01a-ef64-44a9-b0e2-3a4252c5746f"
/>
## Architecture
A new `SessionService` trait defines an API contract for a service that
can manage session data. `LocalSessionService` implements this, with
`DaemonSessionService` a possible future extension point.
`SessionManager` owns a `dyn SessionService` and delegates as
appropriate.
Diffstat (limited to 'crates/atuin-ai/src/tui/dispatch.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/dispatch.rs | 117 |
1 files changed, 108 insertions, 9 deletions
diff --git a/crates/atuin-ai/src/tui/dispatch.rs b/crates/atuin-ai/src/tui/dispatch.rs index b3e84757..ee2bbe74 100644 --- a/crates/atuin-ai/src/tui/dispatch.rs +++ b/crates/atuin-ai/src/tui/dispatch.rs @@ -2,14 +2,16 @@ use std::path::PathBuf; use std::sync::mpsc; use crate::context::{AppContext, ClientContext}; +use crate::context_window::ContextWindowBuilder; use crate::permissions::check::PermissionResponse; use crate::permissions::resolver::PermissionResolver; use crate::permissions::rule::Rule; use crate::permissions::writer::{self, RuleDisposition}; +use crate::session::SessionManager; use crate::stream::{ChatRequest, run_chat_stream}; use crate::tools::{ClientToolCall, ToolPhase}; use crate::tui::events::{AiTuiEvent, PermissionResult}; -use crate::tui::state::{ExitAction, Session}; +use crate::tui::state::{ConversationEvent, ExitAction, Session}; use eye_declare::Handle; use tokio::task::JoinHandle; @@ -19,6 +21,7 @@ pub(crate) fn dispatch( tx: &mpsc::Sender<AiTuiEvent>, app_ctx: &AppContext, client_ctx: &ClientContext, + session_mgr: &mut SessionManager, ) { match event { AiTuiEvent::ContinueAfterTools => { @@ -28,7 +31,7 @@ pub(crate) fn dispatch( on_input_updated(handle, input); } AiTuiEvent::SubmitInput(input) => { - on_submit_input(handle, tx, app_ctx, client_ctx, input); + on_submit_input(handle, tx, app_ctx, client_ctx, input, session_mgr); } AiTuiEvent::SlashCommand(cmd) => { on_slash_command(handle, cmd); @@ -61,6 +64,35 @@ pub(crate) fn dispatch( on_exit(handle); } } + + // Persist any new conversation events after each dispatch cycle. + persist_session(handle, session_mgr); +} + +/// Persist new events and the server session ID if it has changed. +/// Called from the dispatch thread (sync), bridges to async via the tokio handle. +fn persist_session(handle: &Handle<Session>, session_mgr: &mut SessionManager) { + let Ok((events, server_sid)) = handle + .fetch(|state| { + ( + state.conversation.events.clone(), + state.conversation.session_id.clone(), + ) + }) + .blocking_recv() + else { + return; + }; + + let rt = tokio::runtime::Handle::current(); + if let Err(e) = rt.block_on(session_mgr.persist_events(&events)) { + tracing::warn!("failed to persist session events: {e}"); + } + if let Some(ref sid) = server_sid + && let Err(e) = rt.block_on(session_mgr.persist_server_session_id(sid)) + { + tracing::warn!("failed to persist server session ID: {e}"); + } } fn launch_stream( @@ -78,9 +110,10 @@ fn launch_stream( handle.update(move |state| { (setup)(state); state.start_streaming(); - let messages = state.conversation.events_to_messages(); + let messages = + ContextWindowBuilder::with_default_budget().build(&state.conversation.events); let sid = state.conversation.session_id.clone(); - let request = ChatRequest::new(messages, sid, &caps); + let request = ChatRequest::new(messages, sid, &caps, state.invocation_id.clone()); let task: JoinHandle<()> = tokio::spawn(async move { run_chat_stream(h2, tx2, app, cc, request).await; }); @@ -98,10 +131,30 @@ fn on_continue_after_tools( } fn on_input_updated(handle: &Handle<Session>, input: String) { - let input_blank = input.trim().is_empty(); + let input_blank = input.is_empty(); + let slash_command = if input.starts_with('/') { + Some(input.trim_start_matches('/').to_string()) + } else { + None + }; handle.update(move |state| { state.interaction.is_input_blank = input_blank; + state.interaction.slash_command_input = slash_command; + + if let Some(query) = state.interaction.slash_command_input.as_ref() { + let mut results = state.slash_registry.search_fuzzy(query); + + results.sort_by(|a, b| { + b.relevance + .partial_cmp(&a.relevance) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + state.interaction.slash_command_search_results = results; + } else { + state.interaction.slash_command_search_results.clear(); + } }); } @@ -111,7 +164,13 @@ fn on_submit_input( app_ctx: &AppContext, client_ctx: &ClientContext, input: String, + session_mgr: &mut SessionManager, ) { + handle.update(move |state| { + state.interaction.slash_command_input = None; + state.interaction.slash_command_search_results.clear(); + }); + let input = input.trim().to_string(); if input.is_empty() { let h2 = handle.clone(); @@ -129,9 +188,15 @@ fn on_submit_input( } if input.starts_with('/') { - handle.update(move |state| { - state.conversation.handle_slash_command(&input); - }); + if input.trim() == "/new" { + on_new_session(handle, session_mgr); + } else { + handle.update(move |state| { + state + .conversation + .handle_slash_command(&input, &state.slash_registry); + }); + } return; } @@ -144,7 +209,9 @@ fn on_submit_input( fn on_slash_command(handle: &Handle<Session>, command: String) { handle.update(move |state| { - state.conversation.handle_slash_command(&command); + state + .conversation + .handle_slash_command(&command, &state.slash_registry); }); } @@ -533,6 +600,38 @@ fn on_retry( }); } +fn on_new_session(handle: &Handle<Session>, session_mgr: &mut SessionManager) { + let rt = tokio::runtime::Handle::current(); + + if let Err(e) = rt.block_on(session_mgr.archive_and_reset()) { + tracing::warn!("failed to start new session: {e}"); + return; + } + + handle.update(|state| { + // Move the current invocation's visible events to the archived view + // so they remain on screen but are no longer sent to the API. + let visible_events: Vec<ConversationEvent> = + state.conversation.events[state.view_start_index..].to_vec(); + state.archived_view_events.extend(visible_events); + + state.conversation.events.clear(); + state.conversation.session_id = None; + state.tool_tracker = crate::tools::ToolTracker::new(); + state.view_start_index = 0; + state.is_resumed = false; + state.last_event_time = None; + state + .conversation + .events + .push(ConversationEvent::OutOfBandOutput { + name: "System".to_string(), + command: Some("/new".to_string()), + content: "Started a new session.".to_string(), + }); + }); +} + fn on_exit(handle: &Handle<Session>) { let h2 = handle.clone(); handle.update(move |state| { |
