aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/view/mod.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/view/mod.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/view/mod.rs')
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs37
1 files changed, 35 insertions, 2 deletions
diff --git a/crates/atuin-ai/src/tui/view/mod.rs b/crates/atuin-ai/src/tui/view/mod.rs
index ee5483d8..565a0597 100644
--- a/crates/atuin-ai/src/tui/view/mod.rs
+++ b/crates/atuin-ai/src/tui/view/mod.rs
@@ -8,6 +8,7 @@ use ratatui_core::style::{Color, Modifier, Style};
use crate::tools::{ClientToolCall, TrackedTool};
use crate::tui::components::select::SelectOption;
+use crate::tui::components::session_continue::SessionContinue;
use crate::tui::events::{AiTuiEvent, PermissionResult};
use super::components::atuin_ai::AtuinAi;
@@ -29,7 +30,10 @@ mod turn;
pub(crate) fn ai_view(state: &Session) -> Elements {
let mut turn_builder = turn::TurnBuilder::new(&state.tool_tracker);
- for event in &state.conversation.events {
+ for event in &state.archived_view_events {
+ turn_builder.add_event(event);
+ }
+ for event in &state.conversation.events[state.view_start_index..] {
turn_builder.add_event(event);
}
let turns = turn_builder.build();
@@ -46,6 +50,10 @@ pub(crate) fn ai_view(state: &Session) -> Elements {
pending_confirmation: state.interaction.confirmation_pending,
has_executing_preview: state.tool_tracker.has_executing_preview(),
) {
+ #(if state.is_resumed && (!state.is_exiting() || !turns.is_empty()) {
+ SessionContinue(key: "continuation-notice", continued_at: state.last_event_time)
+ })
+
#(for (index, turn) in turns.iter().enumerate() {
#(match turn {
turn::UiTurn::User { events } => {
@@ -70,6 +78,13 @@ pub(crate) fn ai_view(state: &Session) -> Elements {
fn input_view(state: &Session) -> Elements {
let asking_tool = state.tool_tracker.asking_for_permission();
let in_git_project = state.in_git_project;
+ let slash_results = state
+ .interaction
+ .slash_command_search_results
+ .iter()
+ .take(4)
+ .collect::<Vec<_>>();
+ let first_slash_result = slash_results.first().cloned();
element! {
#(if let Some(tc) = asking_tool {
@@ -84,6 +99,7 @@ fn input_view(state: &Session) -> Elements {
title_right: "Atuin AI",
footer: state.footer_text(),
active: state.interaction.mode == AppMode::Input && !state.interaction.confirmation_pending,
+ slash_suggestion: first_slash_result.cloned()
)
#(if state.interaction.is_input_blank && state.conversation.has_any_command() && state.interaction.mode == AppMode::Input {
@@ -93,6 +109,23 @@ fn input_view(state: &Session) -> Elements {
Text { Span(text: "[Enter] Execute suggested command [Tab] Insert Command", style: Style::default().fg(Color::Gray)) }
})
})
+
+ #(if !slash_results.is_empty() {
+ #(for (i, result) in slash_results.iter().enumerate() {
+ Text {
+ Span(text: format!("/{}", &result.command.name[..result.span.0]), style: Style::default().fg(Color::Blue))
+ Span(text: &result.command.name[result.span.0..result.span.1], style: Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED))
+ Span(text: format!("{}", &result.command.name[result.span.1..]), style: Style::default().fg(Color::Blue))
+ Span(text: " - ")
+ Span(text: &result.command.description)
+
+ #(if i == 0 {
+ Span(text: " [Tab] Insert", style: Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC).dim())
+ })
+ }
+
+ })
+ })
}
})
}
@@ -270,7 +303,7 @@ fn out_of_band_turn_view(events: &[turn::UiEvent]) -> Elements {
element! {
View {
Text {
- Span(text: "System", style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD))
+ Span(text: " System ", style: Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD).add_modifier(Modifier::REVERSED))
}
#(for event in events {
#(match event {