aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/tui
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-ai/src/tui')
-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
-rw-r--r--crates/atuin-ai/src/tui/content/help.md3
-rw-r--r--crates/atuin-ai/src/tui/dispatch.rs117
-rw-r--r--crates/atuin-ai/src/tui/mod.rs3
-rw-r--r--crates/atuin-ai/src/tui/slash.rs79
-rw-r--r--crates/atuin-ai/src/tui/state.rs337
-rw-r--r--crates/atuin-ai/src/tui/view/mod.rs37
-rw-r--r--crates/atuin-ai/src/tui/view/turn.rs3
10 files changed, 498 insertions, 147 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),
+ )
+ }
+ }
+}
diff --git a/crates/atuin-ai/src/tui/content/help.md b/crates/atuin-ai/src/tui/content/help.md
index 654aea40..d6623ac9 100644
--- a/crates/atuin-ai/src/tui/content/help.md
+++ b/crates/atuin-ai/src/tui/content/help.md
@@ -1,3 +1,6 @@
Welcome to Atuin AI, an AI assistant in your terminal. You can ask it to generate a shell command for you, or ask general terminal or software questions.
+Commands:
+{commands}
+
For more information, see [https://docs.atuin.sh/cli/ai/introduction/](https://docs.atuin.sh/cli/ai/introduction/)
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| {
diff --git a/crates/atuin-ai/src/tui/mod.rs b/crates/atuin-ai/src/tui/mod.rs
index afd63312..05a040a1 100644
--- a/crates/atuin-ai/src/tui/mod.rs
+++ b/crates/atuin-ai/src/tui/mod.rs
@@ -1,7 +1,8 @@
pub(crate) mod components;
pub(crate) mod dispatch;
pub(crate) mod events;
+pub(crate) mod slash;
pub(crate) mod state;
pub(crate) mod view;
-pub(crate) use state::{ConversationEvent, Session};
+pub(crate) use state::{ConversationEvent, Session, events_to_messages};
diff --git a/crates/atuin-ai/src/tui/slash.rs b/crates/atuin-ai/src/tui/slash.rs
new file mode 100644
index 00000000..7d5e6fa8
--- /dev/null
+++ b/crates/atuin-ai/src/tui/slash.rs
@@ -0,0 +1,79 @@
+#[derive(Debug, Clone)]
+pub(crate) struct SlashCommand {
+ pub name: String,
+ pub description: String,
+}
+
+impl SlashCommand {
+ pub fn new(name: &str, description: &str) -> Self {
+ Self {
+ name: name.to_string(),
+ description: description.to_string(),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct SlashCommandRegistry {
+ commands: Vec<SlashCommand>,
+}
+
+#[derive(Debug, Clone)]
+pub(crate) struct SlashCommandSearchResult {
+ pub command: SlashCommand,
+ pub relevance: f32,
+ pub span: (usize, usize),
+}
+
+impl SlashCommandRegistry {
+ pub fn new() -> Self {
+ Self {
+ commands: Vec::new(),
+ }
+ }
+
+ pub fn register(&mut self, command: SlashCommand) {
+ self.commands.push(command);
+ }
+
+ pub fn get_commands(&self) -> &[SlashCommand] {
+ &self.commands
+ }
+
+ pub fn search_fuzzy(&self, query: &str) -> Vec<SlashCommandSearchResult> {
+ let query_lower = query.to_lowercase();
+
+ self.commands
+ .iter()
+ .filter_map(|command| {
+ let name_lower = command.name.to_lowercase();
+ if let Some(start) = name_lower.find(&query_lower as &str) {
+ let end = start + query_lower.len();
+ Some((command, start, end))
+ } else {
+ None
+ }
+ })
+ .map(|(command, start, end)| {
+ SlashCommandSearchResult {
+ command: command.clone(),
+ relevance: 1.0, // Simple relevance score for now
+ span: (start, end),
+ }
+ })
+ .collect()
+ }
+}
+
+impl Default for SlashCommandRegistry {
+ fn default() -> Self {
+ let mut registry = Self::new();
+ registry.register(SlashCommand::new("help", "Show help information"));
+ registry.register(SlashCommand::new(
+ "new",
+ "Start a new conversation, archiving the current one",
+ ));
+
+ registry
+ }
+}
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;
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 {
diff --git a/crates/atuin-ai/src/tui/view/turn.rs b/crates/atuin-ai/src/tui/view/turn.rs
index 7369f151..a2555dc6 100644
--- a/crates/atuin-ai/src/tui/view/turn.rs
+++ b/crates/atuin-ai/src/tui/view/turn.rs
@@ -170,6 +170,9 @@ impl<'a> TurnBuilder<'a> {
} => {
self.add_out_of_band_output(name, command.as_deref(), content);
}
+ ConversationEvent::SystemContext { .. } => {
+ // Not rendered in the TUI — only sent to the API
+ }
}
}