use super::*; /// Asks for a title short enough to fit a sidebar row. const TITLE_INSTRUCTION: &str = "Give this conversation a short title of at most six words. \ Reply with the title alone: no quotes, no trailing period, no explanation."; const TITLE_MAX_TOKENS: i32 = 48; const TITLE_MAX_CHARS: usize = 60; /// A one-shot generation that produces a session title. It runs against the /// transient KV cache, so it never creates or touches a stored session. #[cfg(target_os = "macos")] pub(super) struct TitleRequest { session_id: i32, /// Title the session had when the request was queued. If it changed since, /// somebody renamed it by hand and that newer intent wins. expected: String, active: ActiveGeneration, content: String, } #[derive(Clone, Debug)] pub(crate) struct ChatMessage { pub(super) id: i32, pub(super) user: bool, pub(super) tool: bool, pub(super) reasoning: Option, pub(super) reasoning_complete: bool, pub(super) reasoning_open: bool, pub(super) content: String, pub(super) markdown: Vec, } impl ChatMessage { pub(super) fn append(&mut self, reasoning: bool, chunk: &str) { if reasoning { self.reasoning.get_or_insert_default().push_str(chunk); } else { self.reasoning_complete = true; self.content.push_str(chunk); } } pub(super) fn refresh_markdown(&mut self) { if !self.user && !self.tool { let visible = crate::agent::visible_content(&self.content); let content = if self.reasoning.is_some() { visible.trim_start() } else { visible }; self.markdown = markdown::parse(content).collect(); } } } impl From for ChatMessage { fn from(message: StoredMessage) -> Self { let mut message = Self { id: message.id, user: message.user, tool: message.tool, reasoning: message.reasoning, reasoning_complete: message.reasoning_complete, reasoning_open: false, content: message.content, markdown: Vec::new(), }; message.refresh_markdown(); message } } impl App { pub(super) fn start_generation(&mut self) { if self.generating || self.selected_project.is_none() { return; } let prompt = self.composer.trim().to_owned(); if prompt.is_empty() { return; } let model = match ModelChoice::from_id(&self.preferences.selected_model) { Some(model) => model, None => { self.error = Some("The selected model is not supported.".into()); return; } }; let effective = self.preferences.generation().and_then(|generation| { self.preferences.runtime().and_then(|runtime| { crate::settings::effective_settings(model, &generation, &runtime, &models_path()) }) }); let mut effective = match effective { Ok(settings) => settings, Err(error) => { self.error = Some(error); return; } }; effective.turn.system_prompt = crate::agent::system_prompt(model, &effective.turn.system_prompt); let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; #[cfg(target_os = "macos")] let mut messages = self .conversation .iter() .map(|message| ChatTurn { user: message.user, tool: message.tool, skip_previous_eos: false, reasoning: message.reasoning.clone(), reasoning_complete: message.reasoning_complete, content: message.content.clone(), }) .collect::>(); #[cfg(target_os = "macos")] messages.push(ChatTurn { user: true, tool: false, skip_previous_eos: false, reasoning: None, reasoning_complete: true, content: prompt.clone(), }); #[cfg(target_os = "macos")] { // A draft session only reaches the database once there is a turn to store. let opening_turn = self.selected_session.is_none(); let session_id = match self.selected_session { Some(session_id) => session_id, None => { let Some(project_id) = self.selected_project else { return; }; match self.persist_session(project_id) { Ok(session_id) => session_id, Err(error) => { self.error = Some(format!("Could not create the session: {error}")); return; } } } }; let Some(service) = &self.generation_service else { self.error = Some("The model runtime is unavailable.".into()); return; }; let Some(database) = &mut self.database else { return; }; let saved = match database.start_chat_turn(session_id, &prompt, assistant_reasoning) { Ok(turn) => turn, Err(error) => { self.error = Some(format!("Could not save the chat turn: {error}")); return; } }; let idle_timeout = Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60); self.active_generation = match service.generate( effective.engine, effective.turn, messages, CheckpointTarget::Local(session_checkpoint_path(session_id)), idle_timeout, ) { Ok(active) => Some(active), Err(error) => { self.generation_service = None; self.error = Some(error); return; } }; let user = ChatMessage::from(saved.0); let mut assistant = ChatMessage::from(saved.1); assistant.reasoning_open = assistant_reasoning; self.composer.clear(); self.conversation.push(user); self.conversation.push(assistant); self.generating = true; self.tokens_per_second = None; self.error = None; if opening_turn { // Queued behind the reply, so the answer is never delayed by it. self.request_first_title(session_id); } } #[cfg(not(target_os = "macos"))] { let _ = effective; self.error = Some("Local Metal generation requires macOS.".into()); return; } } pub(super) fn poll_generation(&mut self) -> bool { #[cfg(target_os = "macos")] if let Some(active) = &self.active_tools { match crate::agent::try_tool_result(active) { Ok(Some(result)) => { let cancelled = active.cancel.load(Ordering::Relaxed); self.active_tools = None; if cancelled { self.generating = false; return false; } if let Err(error) = self.continue_after_tool_result(&result) { self.generating = false; self.error = Some(error); } return true; } Ok(None) => return false, Err(error) => { self.active_tools = None; self.generating = false; self.error = Some(error); return false; } } } #[cfg(target_os = "macos")] let Some(active) = &mut self.active_generation else { self.generating = false; return false; }; #[cfg(target_os = "macos")] let mut transcript_changed = false; #[cfg(target_os = "macos")] let mut context_changed = false; #[cfg(target_os = "macos")] loop { match active.events.try_recv() { Ok(GenerationEvent::Loading) => {} Ok(GenerationEvent::Chunk { reasoning, content }) => { if let Some(message) = self.conversation.last_mut() && !message.user { message.append(reasoning, &content); transcript_changed = true; } } Ok(GenerationEvent::Context { used, limit, tokens_per_second, }) => { self.context_used = used; self.context_limit = limit; self.tokens_per_second = tokens_per_second; context_changed = true; } Ok(GenerationEvent::Finished(result)) => { match result { Ok(_) => { let model = ModelChoice::from_id(&self.preferences.selected_model) .unwrap_or_default(); let content = self .conversation .last() .map(|message| message.content.clone()) .unwrap_or_default(); match crate::agent::parse_tool_calls(model, &content) { Ok((_, calls)) if !calls.is_empty() => { if let Err(error) = self.start_agent_tools(calls) { self.generating = false; self.error = Some(error); } } Ok(_) => self.generating = false, Err(error) => { self.active_tools = Some(crate::agent::error_async(error)); } } } Err(error) => { self.generating = false; self.error = Some(error); } } self.active_generation = None; break; } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { self.generating = false; self.active_generation = None; self.error = Some("The model runtime stopped unexpectedly.".into()); break; } } } #[cfg(target_os = "macos")] if transcript_changed && let Some(message) = self.conversation.last_mut() { message.refresh_markdown(); } #[cfg(target_os = "macos")] if transcript_changed && let Some(message) = self.conversation.last() && let Some(database) = &mut self.database && let Err(error) = database.update_message( message.id, message.reasoning.as_deref(), message.reasoning_complete, &message.content, ) { if let Some(active) = &self.active_generation { active.cancel.store(true, Ordering::Relaxed); } self.error = Some(format!("Could not save generated chat text: {error}")); } #[cfg(target_os = "macos")] if context_changed && let Some(session_id) = self.selected_session && let Some(database) = &mut self.database { if let Err(error) = database.update_session_context( session_id, self.context_used, self.context_limit, self.tokens_per_second, ) { self.error = Some(format!("Could not save context usage: {error}")); } else if let Some(session) = self .projects .iter_mut() .flat_map(|project| &mut project.sessions) .find(|session| session.id == session_id) { session.context_used = self.context_used as i32; session.context_limit = self.context_limit as i32; session.last_tokens_per_second = self.tokens_per_second; } } #[cfg(target_os = "macos")] return transcript_changed; #[cfg(not(target_os = "macos"))] false } #[cfg(target_os = "macos")] fn start_agent_tools(&mut self, calls: Vec) -> Result<(), String> { let session_id = self .selected_session .ok_or_else(|| "The active session is unavailable.".to_owned())?; if self.agent_tools.as_ref().map(|(id, _)| *id) != Some(session_id) { let project_id = self .selected_project .ok_or_else(|| "The active project is unavailable.".to_owned())?; let root = self .projects .iter() .find(|project| project.project.id == project_id) .map(|project| PathBuf::from(&project.project.path)) .ok_or_else(|| "The active project is unavailable.".to_owned())?; let tools = crate::agent::Tools::new(&root, self.preferences.context_tokens)?; self.agent_tools = Some((session_id, Arc::new(Mutex::new(tools)))); } let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1); self.active_tools = Some(crate::agent::execute_async(tools, calls)); Ok(()) } #[cfg(target_os = "macos")] fn continue_after_tool_result(&mut self, result: &str) -> Result<(), String> { let session_id = self .selected_session .ok_or_else(|| "The active session is unavailable.".to_owned())?; let model = ModelChoice::from_id(&self.preferences.selected_model) .ok_or_else(|| "The selected model is not supported.".to_owned())?; let generation = self.preferences.generation()?; let runtime = self.preferences.runtime()?; let mut effective = crate::settings::effective_settings(model, &generation, &runtime, &models_path())?; effective.turn.system_prompt = crate::agent::system_prompt(model, &effective.turn.system_prompt); let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct; let saved = self .database .as_mut() .ok_or_else(|| "The project database is unavailable.".to_owned())? .continue_tool_turn(session_id, result, assistant_reasoning) .map_err(|error| format!("Could not save the tool turn: {error}"))?; self.conversation.push(ChatMessage::from(saved.0)); let messages = self .conversation .iter() .map(|message| ChatTurn { user: message.user, tool: message.tool, skip_previous_eos: false, reasoning: message.reasoning.clone(), reasoning_complete: message.reasoning_complete, content: message.content.clone(), }) .collect(); let mut assistant = ChatMessage::from(saved.1); assistant.reasoning_open = assistant_reasoning; self.conversation.push(assistant); let idle_timeout = Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60); self.active_generation = Some( self.generation_service .as_ref() .ok_or_else(|| "The model runtime is unavailable.".to_owned())? .generate( effective.engine, effective.turn, messages, CheckpointTarget::Local(session_checkpoint_path(session_id)), idle_timeout, )?, ); self.tokens_per_second = None; Ok(()) } /// Asks the model for a session title without opening a session for it: the /// turn runs against the shared transient KV cache and only its text is kept. /// /// Reports failures to the caller instead of the error banner: the automatic /// first-chat titling must stay silent, while the menu action shows them. #[cfg(target_os = "macos")] fn request_title(&mut self, session_id: i32) -> Result<(), String> { if self.active_titling.is_some() { return Err("A title is already being generated.".into()); } let model = ModelChoice::from_id(&self.preferences.selected_model) .ok_or_else(|| "The selected model is not supported.".to_owned())?; let mut effective = self.preferences.generation().and_then(|generation| { self.preferences.runtime().and_then(|runtime| { crate::settings::effective_settings(model, &generation, &runtime, &models_path()) }) })?; let database = self .database .as_mut() .ok_or_else(|| "The project database is unavailable.".to_owned())?; let stored = database .load_messages(session_id) .map_err(|error| format!("Could not read the session: {error}"))?; // The assistant row is inserted empty when a turn starts; an empty turn // is never useful context, and skipping it is what lets the automatic // pass summarize the user's opening message on its own. let mut messages = stored .into_iter() .filter(|message| !message.content.trim().is_empty()) .map(|message| ChatTurn { user: message.user, tool: message.tool, skip_previous_eos: false, reasoning: None, reasoning_complete: true, content: message.content, }) .collect::>(); if messages.is_empty() { return Err("This session has no messages to summarize yet.".into()); } messages.push(ChatTurn { user: true, tool: false, skip_previous_eos: false, reasoning: None, reasoning_complete: true, content: TITLE_INSTRUCTION.to_owned(), }); effective.turn.max_generated_tokens = TITLE_MAX_TOKENS; effective.turn.reasoning_mode = ReasoningMode::Direct; let service = self .generation_service .as_ref() .ok_or_else(|| "The model runtime is unavailable.".to_owned())?; let idle_timeout = Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60); match service.generate( effective.engine, effective.turn, messages, CheckpointTarget::OneShot(transient_cache_path()), idle_timeout, ) { Ok(active) => { self.active_titling = Some(TitleRequest { session_id, expected: self.session_title(session_id).unwrap_or_default(), active, content: String::new(), }); Ok(()) } Err(error) => { self.generation_service = None; Err(error) } } } /// The "Retitle with AI" menu action, which reports why it could not run. #[cfg(target_os = "macos")] pub(super) fn retitle_session(&mut self, session_id: i32) { match self.request_title(session_id) { Ok(()) => self.error = None, Err(error) => self.error = Some(error), } } /// Titles a session from its opening message. Queued behind the reply that /// is already running, and skipped without complaint if it cannot start — /// the user did not ask for this, so it must never interrupt them. #[cfg(target_os = "macos")] pub(super) fn request_first_title(&mut self, session_id: i32) { let _ = self.request_title(session_id); } /// Drains the one-shot title generation. Returns true while it is running so /// the caller keeps ticking. #[cfg(target_os = "macos")] pub(super) fn poll_titling(&mut self) -> bool { let Some(mut request) = self.active_titling.take() else { return false; }; let failure = loop { match request.active.events.try_recv() { Ok(GenerationEvent::Chunk { reasoning: false, content, }) => request.content.push_str(&content), Ok(GenerationEvent::Finished(Err(error))) => break Some(error), Ok(GenerationEvent::Finished(Ok(_))) => break None, Ok(_) => {} Err(TryRecvError::Empty) => { self.active_titling = Some(request); return true; } Err(TryRecvError::Disconnected) => { break Some("The model runtime stopped unexpectedly.".to_owned()); } } }; if let Some(error) = failure { self.error = Some(format!("Could not generate a title: {error}")); } else if let Some(title) = session_title(&request.content) { self.apply_title(&request, &title); } else { self.error = Some("The model did not return a usable title.".into()); } false } #[cfg(target_os = "macos")] fn session_title(&self, session_id: i32) -> Option { self.projects .iter() .flat_map(|project| &project.sessions) .find(|session| session.id == session_id) .map(|session| session.title.clone()) } #[cfg(target_os = "macos")] fn apply_title(&mut self, request: &TitleRequest, title: &str) { let Some(current) = self.session_title(request.session_id) else { return; }; if current != request.expected { return; } let Some(database) = &mut self.database else { return; }; match database.rename_session(request.session_id, title) { Ok(()) => { self.error = None; self.reload_projects(); } Err(error) => self.error = Some(format!("Could not save the session title: {error}")), } } } /// Reduces a model reply to a single sidebar-sized line, or `None` if nothing /// usable came back. pub(super) fn session_title(reply: &str) -> Option { let title = reply .lines() .map(str::trim) .find(|line| !line.is_empty())? .trim_matches(|character: char| { character.is_whitespace() || matches!(character, '"' | '\'' | '`' | '*' | '#' | '.') }) .trim(); if title.is_empty() { return None; } Some(match title.char_indices().nth(TITLE_MAX_CHARS) { Some((index, _)) => format!("{}…", title[..index].trim_end()), None => title.to_owned(), }) }