aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui/components
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/components
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/components')
-rw-r--r--crates/atuin-ai/src/tui/components/input_box.rs16
-rw-r--r--crates/atuin-ai/src/tui/components/mod.rs1
-rw-r--r--crates/atuin-ai/src/tui/components/session_continue.rs49
3 files changed, 65 insertions, 1 deletions
diff --git a/crates/atuin-ai/src/tui/components/input_box.rs b/crates/atuin-ai/src/tui/components/input_box.rs
index f5e0fe2b..6e041418 100644
--- a/crates/atuin-ai/src/tui/components/input_box.rs
+++ b/crates/atuin-ai/src/tui/components/input_box.rs
@@ -19,7 +19,7 @@ use ratatui_core::{
};
use tui_textarea::TextArea;
-use crate::tui::events::AiTuiEvent;
+use crate::tui::{events::AiTuiEvent, slash::SlashCommandSearchResult};
/// A bordered text input box backed by tui-textarea.
///
@@ -35,6 +35,8 @@ pub(crate) struct InputBox {
pub footer: String,
/// Whether the input is currently active (shows cursor, accepts input)
pub active: bool,
+ /// If the user has typed a slash command, this holds the best match for it.
+ pub slash_suggestion: Option<SlashCommandSearchResult>,
}
pub(crate) struct InputBoxState {
@@ -129,6 +131,18 @@ fn input_box(
textarea.insert_newline();
return EventResult::Consumed;
}
+ crossterm::event::KeyCode::Tab if props.slash_suggestion.is_some() => {
+ // If there's a slash command suggestion, Tab accepts it.
+ if let Some(suggestion) = &props.slash_suggestion {
+ textarea.clear();
+ textarea.insert_str(format!("/{}", suggestion.command.name));
+ // Manually trigger an input update event so the slash suggestion box can update immediately
+ if let Some(ref tx) = state.tx {
+ let _ = tx.send(AiTuiEvent::InputUpdated(textarea.lines().join("\n")));
+ }
+ return EventResult::Consumed;
+ }
+ }
crossterm::event::KeyCode::Enter => {
if key.modifiers.contains(KeyModifiers::SHIFT) {
textarea.insert_newline();
diff --git a/crates/atuin-ai/src/tui/components/mod.rs b/crates/atuin-ai/src/tui/components/mod.rs
index 3458327d..9959dbad 100644
--- a/crates/atuin-ai/src/tui/components/mod.rs
+++ b/crates/atuin-ai/src/tui/components/mod.rs
@@ -2,3 +2,4 @@ pub(crate) mod atuin_ai;
pub(crate) mod input_box;
pub(crate) mod markdown;
pub(crate) mod select;
+pub(crate) mod session_continue;
diff --git a/crates/atuin-ai/src/tui/components/session_continue.rs b/crates/atuin-ai/src/tui/components/session_continue.rs
new file mode 100644
index 00000000..bfbfb191
--- /dev/null
+++ b/crates/atuin-ai/src/tui/components/session_continue.rs
@@ -0,0 +1,49 @@
+use chrono_humanize::HumanTime;
+use eye_declare::{Elements, Hooks, Span, Text, component, element, props};
+use ratatui::style::{Color, Modifier, Style};
+
+#[props]
+pub(crate) struct SessionContinue {
+ pub continued_at: Option<chrono::DateTime<chrono::Utc>>,
+}
+
+#[derive(Default)]
+pub(crate) struct SessionContinueState {
+ /// Frozen on mount so the label doesn't change on every render.
+ label: Option<String>,
+}
+
+#[component(props = SessionContinue, state = SessionContinueState)]
+fn session_continue(
+ _props: &SessionContinue,
+ state: &SessionContinueState,
+ hooks: &mut Hooks<SessionContinue, SessionContinueState>,
+) -> Elements {
+ hooks.use_mount(|props, state| {
+ state.label = Some(match props.continued_at {
+ Some(t) => {
+ let human = HumanTime::from(t - chrono::Utc::now());
+ format!(
+ " Continuing previous session (last active {human}) - type /new to start a new session"
+ )
+ }
+ None => {
+ " Continuing previous session - type /new to start a new session".to_string()
+ }
+ });
+ });
+
+ let resume_label = state
+ .label
+ .as_deref()
+ .unwrap_or(" Continuing previous session - type /new to start a new session");
+
+ element! {
+ Text {
+ Span(
+ text: resume_label,
+ style: Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
+ )
+ }
+ }
+}