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/state.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/state.rs')
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 337 |
1 files changed, 203 insertions, 134 deletions
diff --git a/crates/atuin-ai/src/tui/state.rs b/crates/atuin-ai/src/tui/state.rs index 37200025..a012386a 100644 --- a/crates/atuin-ai/src/tui/state.rs +++ b/crates/atuin-ai/src/tui/state.rs @@ -5,7 +5,10 @@ use tokio::task::AbortHandle; -use crate::tools::{ClientToolCall, ToolOutcome, ToolTracker}; +use crate::{ + tools::{ClientToolCall, ToolOutcome, ToolTracker}, + tui::slash::{SlashCommandRegistry, SlashCommandSearchResult}, +}; /// Streaming status indicators from server #[derive(Debug, Clone, PartialEq, Eq)] @@ -57,9 +60,25 @@ pub(crate) enum ConversationEvent { command: Option<String>, content: String, }, + /// Context injected for the LLM that is not rendered in the TUI. + /// Converted to a user message in the API protocol. + SystemContext { content: String }, } impl ConversationEvent { + /// Whether this event represents actual conversation content sent to the API. + /// Used to determine if a resumed session has meaningful context. + pub(crate) fn is_api_content(&self) -> bool { + match self { + ConversationEvent::UserMessage { .. } => true, + ConversationEvent::Text { .. } => true, + ConversationEvent::ToolCall { .. } => true, + ConversationEvent::ToolResult { .. } => true, + ConversationEvent::OutOfBandOutput { .. } => false, + ConversationEvent::SystemContext { .. } => false, + } + } + /// Extract command from a suggest_command tool call pub(crate) fn as_command(&self) -> Option<&str> { if let ConversationEvent::ToolCall { name, input, .. } = self @@ -111,131 +130,6 @@ impl Conversation { } } - /// Convert conversation events to Claude API message format - pub fn events_to_messages(&self) -> Vec<serde_json::Value> { - let mut messages = Vec::new(); - let mut i = 0; - let events = &self.events; - - while i < events.len() { - match &events[i] { - ConversationEvent::UserMessage { content } => { - messages.push(serde_json::json!({ - "role": "user", - "content": content - })); - i += 1; - } - ConversationEvent::Text { content } => { - // Check if the next event(s) are ToolCalls — if so, combine - // into a single assistant message with mixed content blocks. - let next_is_tool_call = events - .get(i + 1) - .is_some_and(|e| matches!(e, ConversationEvent::ToolCall { .. })); - - if next_is_tool_call { - let mut content_blocks = Vec::new(); - - if !content.is_empty() { - content_blocks.push(serde_json::json!({ - "type": "text", - "text": content - })); - } - - while let Some(ConversationEvent::ToolCall { - id, name, input, .. - }) = events.get(i + 1) - { - content_blocks.push(serde_json::json!({ - "type": "tool_use", - "id": id, - "name": name, - "input": input - })); - i += 1; - } - - messages.push(serde_json::json!({ - "role": "assistant", - "content": content_blocks - })); - i += 1; - } else { - messages.push(serde_json::json!({ - "role": "assistant", - "content": content - })); - i += 1; - } - } - ConversationEvent::ToolCall { .. } => { - // ToolCalls without preceding Text (shouldn't normally happen, - // but handle defensively) - let mut tool_uses = Vec::new(); - while i < events.len() { - if let ConversationEvent::ToolCall { - id, name, input, .. - } = &events[i] - { - tool_uses.push(serde_json::json!({ - "type": "tool_use", - "id": id, - "name": name, - "input": input - })); - i += 1; - } else { - break; - } - } - messages.push(serde_json::json!({ - "role": "assistant", - "content": tool_uses - })); - } - ConversationEvent::ToolResult { - tool_use_id, - content, - is_error, - remote, - content_length, - } => { - let tool_result = if *remote { - let mut obj = serde_json::json!({ - "type": "tool_result", - "tool_use_id": tool_use_id, - "remote": true, - "is_error": is_error - }); - if let Some(len) = content_length { - obj["content_length"] = serde_json::json!(len); - } - obj - } else { - serde_json::json!({ - "type": "tool_result", - "tool_use_id": tool_use_id, - "content": content, - "is_error": is_error - }) - }; - messages.push(serde_json::json!({ - "role": "user", - "content": [tool_result] - })); - i += 1; - } - ConversationEvent::OutOfBandOutput { .. } => { - // Out-of-band output is not sent to the server, so we don't need to add it to the messages - i += 1; - } - } - } - - messages - } - /// Get the most recent command from events pub fn current_command(&self) -> Option<&str> { self.events.iter().rev().find_map(|e| e.as_command()) @@ -343,15 +237,22 @@ impl Conversation { } /// Handle a slash command - pub fn handle_slash_command(&mut self, command: &str) { + pub fn handle_slash_command(&mut self, command: &str, registry: &SlashCommandRegistry) { match command.trim() { "/help" => { - let content = include_str!("./content/help.md"); + let commands = registry + .get_commands() + .iter() + .map(|cmd| format!("- `/{}` - {}", cmd.name, cmd.description)) + .collect::<Vec<_>>() + .join("\n"); + + let content = include_str!("./content/help.md").replace("{commands}", &commands); self.events.push(ConversationEvent::OutOfBandOutput { name: "System".to_string(), command: Some("/help".to_string()), - content: content.to_string(), + content, }); } _ => self.events.push(ConversationEvent::OutOfBandOutput { @@ -363,6 +264,147 @@ impl Conversation { } } +/// Convert a slice of conversation events to Claude API message format. +/// +/// This is the canonical event-to-message conversion, used by the context window +/// builder to convert turn slices independently. The logic handles combining +/// adjacent Text + ToolCall events into single assistant messages with mixed +/// content blocks. +pub(crate) fn events_to_messages(events: &[ConversationEvent]) -> Vec<serde_json::Value> { + let mut messages = Vec::new(); + let mut i = 0; + + while i < events.len() { + match &events[i] { + ConversationEvent::UserMessage { content } => { + messages.push(serde_json::json!({ + "role": "user", + "content": content + })); + i += 1; + } + ConversationEvent::Text { content } if content.is_empty() => { + // Skip empty text events (e.g. streaming buffer before + // any data arrived). + i += 1; + } + ConversationEvent::Text { content } => { + // Check if the next event(s) are ToolCalls — if so, combine + // into a single assistant message with mixed content blocks. + let next_is_tool_call = events + .get(i + 1) + .is_some_and(|e| matches!(e, ConversationEvent::ToolCall { .. })); + + if next_is_tool_call { + let mut content_blocks = Vec::new(); + + if !content.is_empty() { + content_blocks.push(serde_json::json!({ + "type": "text", + "text": content + })); + } + + while let Some(ConversationEvent::ToolCall { + id, name, input, .. + }) = events.get(i + 1) + { + content_blocks.push(serde_json::json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input + })); + i += 1; + } + + messages.push(serde_json::json!({ + "role": "assistant", + "content": content_blocks + })); + i += 1; + } else { + messages.push(serde_json::json!({ + "role": "assistant", + "content": content + })); + i += 1; + } + } + ConversationEvent::ToolCall { .. } => { + // ToolCalls without preceding Text (shouldn't normally happen, + // but handle defensively) + let mut tool_uses = Vec::new(); + while i < events.len() { + if let ConversationEvent::ToolCall { + id, name, input, .. + } = &events[i] + { + tool_uses.push(serde_json::json!({ + "type": "tool_use", + "id": id, + "name": name, + "input": input + })); + i += 1; + } else { + break; + } + } + messages.push(serde_json::json!({ + "role": "assistant", + "content": tool_uses + })); + } + ConversationEvent::ToolResult { + tool_use_id, + content, + is_error, + remote, + content_length, + } => { + let tool_result = if *remote { + let mut obj = serde_json::json!({ + "type": "tool_result", + "tool_use_id": tool_use_id, + "remote": true, + "is_error": is_error + }); + if let Some(len) = content_length { + obj["content_length"] = serde_json::json!(len); + } + obj + } else { + serde_json::json!({ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content, + "is_error": is_error + }) + }; + messages.push(serde_json::json!({ + "role": "user", + "content": [tool_result] + })); + i += 1; + } + ConversationEvent::OutOfBandOutput { .. } => { + // Out-of-band output is not sent to the server + i += 1; + } + ConversationEvent::SystemContext { content } => { + messages.push(serde_json::json!({ + "role": "user", + "content": content + })); + i += 1; + } + } + } + + messages +} + /// Ephemeral UI/presentation state #[derive(Debug)] pub(crate) struct Interaction { @@ -370,6 +412,10 @@ pub(crate) struct Interaction { pub mode: AppMode, /// Whether the input is blank pub is_input_blank: bool, + /// The currently in-progress slash command (if any) + pub slash_command_input: Option<String>, + /// Search results for the current slash command input + pub slash_command_search_results: Vec<SlashCommandSearchResult>, /// True when user has pressed Enter once on a dangerous command pub confirmation_pending: bool, /// Current streaming status @@ -385,6 +431,8 @@ impl Interaction { Self { mode: AppMode::Input, is_input_blank: false, + slash_command_input: None, + slash_command_search_results: Vec::new(), confirmation_pending: false, streaming_status: None, was_interrupted: false, @@ -410,10 +458,26 @@ pub(crate) struct Session { pub exit_action: Option<ExitAction>, /// Abort handle for the active streaming task, if any pub stream_abort: Option<AbortHandle>, + /// Index into `conversation.events` where the current TUI invocation starts. + /// Events before this index are historical context sent to the API but not + /// rendered in the TUI. + pub view_start_index: usize, + /// Whether this session was resumed from a prior invocation. + pub is_resumed: bool, + /// Time of the last event from a previous invocation when resuming a session + pub last_event_time: Option<chrono::DateTime<chrono::Utc>>, + /// Events from archived sessions that are still rendered on screen but no + /// longer sent to the API. Accumulated by `/new` commands within a single + /// TUI lifetime. + pub archived_view_events: Vec<ConversationEvent>, + /// A registry of available slash commands + pub slash_registry: SlashCommandRegistry, + /// The unique ID for this invocation + pub invocation_id: String, } impl Session { - pub fn new(in_git_project: bool) -> Self { + pub fn new(in_git_project: bool, invocation_id: Option<String>) -> Self { Self { conversation: Conversation::new(), interaction: Interaction::new(), @@ -421,6 +485,12 @@ impl Session { in_git_project, exit_action: None, stream_abort: None, + view_start_index: 0, + is_resumed: false, + last_event_time: None, + archived_view_events: Vec::new(), + slash_registry: Default::default(), + invocation_id: invocation_id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string()), } } @@ -455,11 +525,10 @@ impl Session { // ===== Streaming lifecycle methods ===== /// Start streaming response. - /// Pushes an empty Text event that will be mutated in-place as chunks arrive. + /// The Text event for streamed content is created lazily by + /// `append_streaming_text` when the first chunk arrives, so we + /// don't leave an empty assistant turn in the conversation. pub fn start_streaming(&mut self) { - self.conversation.events.push(ConversationEvent::Text { - content: String::new(), - }); self.interaction.streaming_status = None; self.interaction.was_interrupted = false; self.interaction.mode = AppMode::Streaming; |
