aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/state.rs
diff options
context:
space:
mode:
authorMichelle Tilley <michelle@michelletilley.net>2026-04-14 16:03:08 -0700
committerGitHub <noreply@github.com>2026-04-15 00:03:08 +0100
commitfd188da879d977ca847f10708c39dd4801a204c4 (patch)
tree592bfe2644f8bd9be3563f176eabf29e55fa9a9b /crates/atuin-ai/src/tui/state.rs
parentfix: dependency fix (#3414) (diff)
downloadatuin-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.rs337
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;