diff options
Diffstat (limited to 'crates/atuin-ai/src/tui')
| -rw-r--r-- | crates/atuin-ai/src/tui/components/input_box.rs | 16 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/mod.rs | 1 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/components/session_continue.rs | 49 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/content/help.md | 3 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/dispatch.rs | 117 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/mod.rs | 3 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/slash.rs | 79 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/state.rs | 337 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/mod.rs | 37 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view/turn.rs | 3 |
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 + } } } |
