diff --git a/crates/coco-tui/examples/session_command_palette.json b/crates/coco-tui/examples/session_command_palette.json index a0a3c43..dc3608a 100644 --- a/crates/coco-tui/examples/session_command_palette.json +++ b/crates/coco-tui/examples/session_command_palette.json @@ -14,6 +14,10 @@ "name": "Switch Session", "shortcut": "" }, + { + "name": "Regenerate Session Summary", + "shortcut": null + }, { "name": "Switch Theme", "shortcut": "" diff --git a/crates/coco-tui/src/actions.rs b/crates/coco-tui/src/actions.rs index 22bbc1a..f1883f0 100644 --- a/crates/coco-tui/src/actions.rs +++ b/crates/coco-tui/src/actions.rs @@ -77,6 +77,7 @@ impl From for Action { pub enum CommandPaletteAction { NewSession, Transcript, + RegenerateSessionSummary, RestoreSession(PersistentSessionMetadata), SwitchTheme(String), SwitchModel(Option), diff --git a/crates/coco-tui/src/components/chat.rs b/crates/coco-tui/src/components/chat.rs index fa1e3f9..13c7571 100644 --- a/crates/coco-tui/src/components/chat.rs +++ b/crates/coco-tui/src/components/chat.rs @@ -181,6 +181,7 @@ enum TranscriptScope { } const CTRL_C_WINDOW: Duration = Duration::from_secs(2); +const SESSION_SUMMARY_MAX_LEN: usize = 80; #[derive(Debug, Default)] struct CancellationGuard { last_hit: State>, @@ -317,7 +318,7 @@ impl Chat<'static> { }); } - fn schedule_save_task(&mut self, save_at: Instant) { + fn schedule_save_task(&mut self, save_at: Instant, force_summary_refresh: bool) { // Cancel existing timer if any if let Some(token) = self.token_schedule_session_save.take() { token.cancel(); @@ -348,18 +349,60 @@ impl Chat<'static> { return; } - let now = time::OffsetDateTime::now_utc(); - let persistent_session = crate::session::PersistentSession { - name: state.name.clone(), - inner: session::save_related(&state, messages), - created_at: state.created_at, - updated_at: now, - }; - tokio::select! { _ = token.cancelled() => (), _ = tokio::time::sleep_until(save_at) => { - if let Err(e) = crate::session::save_session(&session_dir, persistent_session).await { + let summary = if force_summary_refresh { + generate_session_summary( + &state.messages, + state.model_override.clone(), + state.thinking_enabled, + ) + .await + } else { + let metadata_filename = + session::PersistentSessionMetadata::metadata_filename_for_created_at( + state.created_at, + ); + let metadata_path = session_dir.join(&metadata_filename); + let metadata_exists = match tokio::fs::try_exists(&metadata_path).await { + Ok(exists) => exists, + Err(err) => { + warn!(?err, "failed to check session metadata"); + true + } + }; + if metadata_exists { + match session::load_session_metadata(&session_dir, &metadata_filename) + .await + { + Ok(metadata) => metadata.summary, + Err(err) => { + warn!(?err, "failed to load session metadata"); + None + } + } + } else { + generate_session_summary( + &state.messages, + state.model_override.clone(), + state.thinking_enabled, + ) + .await + } + }; + let now = time::OffsetDateTime::now_utc(); + let persistent_session = crate::session::PersistentSession { + name: state.name.clone(), + inner: session::save_related(&state, messages), + created_at: state.created_at, + updated_at: now, + }; + + if let Err(e) = + crate::session::save_session(&session_dir, persistent_session, summary) + .await + { warn!(?e, "failed to save session"); } else { debug!("Session saved successfully"); @@ -370,11 +413,16 @@ impl Chat<'static> { } fn save_at(&mut self, save_at: Instant) { - self.schedule_save_task(save_at); + self.schedule_save_task(save_at, false); } fn save_now(&mut self) { - self.schedule_save_task(Instant::now()); + self.schedule_save_task(Instant::now(), false); + } + + fn regenerate_session_summary(&mut self) { + self.state.write_untracked().updated_at = OffsetDateTime::now_utc(); + self.schedule_save_task(Instant::now(), true); } fn restore_last_session(&mut self) { @@ -1961,6 +2009,10 @@ impl Component for Chat<'static> { self.update_focus(Focus::InputBlur); self.open_transcript(); } + CommandPaletteAction::RegenerateSessionSummary => { + self.update_focus(Focus::InputBlur); + self.regenerate_session_summary(); + } CommandPaletteAction::RestoreSession(metadata) => { self.update_focus(Focus::InputBlur); if !self.messages.is_empty() { @@ -2120,6 +2172,80 @@ fn add_usage(total: &mut UsageStats, delta: &UsageStats) { } } +fn session_summary_prompt() -> String { + format!( + "Write a short session summary for a session switcher list. Return a single line (max {SESSION_SUMMARY_MAX_LEN} chars), plain text only, no quotes. Focus on the user's goal and progress. If there is no meaningful content, return an empty string." + ) +} + +async fn generate_session_summary( + messages: &[ChatMessage], + model_override: Option, + thinking_enabled: bool, +) -> Option { + if messages.is_empty() { + return None; + } + + let config = global::config().await; + let config_dir = config.config_dir.clone(); + let workspace_dir = global::workspace_dir().to_path_buf(); + let mut summary_agent = Agent::new(config); + summary_agent + .setup_system_prompt_async(&config_dir, &workspace_dir) + .await; + summary_agent.apply_tool_policies(Some(&[]), None); + summary_agent.set_model_override(model_override); + summary_agent.set_thinking_enabled(thinking_enabled); + summary_agent.restore_messages(messages).await; + + let response = match summary_agent + .chat(ChatMessage::user(ChatContent::Text( + session_summary_prompt(), + ))) + .await + { + Ok(response) => response, + Err(err) => { + warn!(?err, "failed to generate session summary"); + return None; + } + }; + let raw = extract_text_response(&response.message); + sanitize_session_summary(&raw) +} + +fn extract_text_response(message: &ChatMessage) -> String { + match &message.content { + ChatContent::Text(text) => text.clone(), + ChatContent::Multiple(blocks) => blocks + .iter() + .filter_map(|block| { + if let ChatBlock::Text { text } = block { + Some(text.as_str()) + } else { + None + } + }) + .collect::>() + .join("\n"), + } +} + +fn sanitize_session_summary(raw: &str) -> Option { + let collapsed = raw.split_whitespace().collect::>().join(" "); + let trimmed = collapsed.trim(); + if trimmed.is_empty() { + return None; + } + let summary: String = trimmed.chars().take(SESSION_SUMMARY_MAX_LEN).collect(); + if summary.is_empty() { + None + } else { + Some(summary) + } +} + const TOOL_RESULT_MAX_BYTES: usize = 80 * 1024; const TOOL_RESULT_TRUNCATION_SUFFIX: &str = "\n... (truncated)"; @@ -2715,4 +2841,23 @@ mod tests { Some(true) ); } + + #[test] + fn sanitize_session_summary_collapses_whitespace() { + let raw = " Hello world\nsecond line "; + let summary = sanitize_session_summary(raw).expect("expected summary"); + assert_eq!(summary, "Hello world second line"); + } + + #[test] + fn sanitize_session_summary_truncates() { + let raw = "a".repeat(SESSION_SUMMARY_MAX_LEN + 20); + let summary = sanitize_session_summary(&raw).expect("expected summary"); + assert_eq!(summary.len(), SESSION_SUMMARY_MAX_LEN); + } + + #[test] + fn sanitize_session_summary_empty_returns_none() { + assert!(sanitize_session_summary(" \n\t").is_none()); + } } diff --git a/crates/coco-tui/src/components/command_palette.rs b/crates/coco-tui/src/components/command_palette.rs index 0e80d25..86cc979 100644 --- a/crates/coco-tui/src/components/command_palette.rs +++ b/crates/coco-tui/src/components/command_palette.rs @@ -27,6 +27,7 @@ use crate::{ const COMMAND_NEW_SESSION: &str = "New Session"; const COMMAND_TRANSCRIPT: &str = "Transcript"; const COMMAND_SWITCH_SESSION: &str = "Switch Session"; +const COMMAND_REGENERATE_SUMMARY: &str = "Regenerate Session Summary"; const COMMAND_SWITCH_THEME: &str = "Switch Theme"; const COMMAND_SWITCH_MODEL: &str = "Switch Model"; const COMMAND_SHELL: &str = "Shell"; @@ -305,6 +306,10 @@ impl CommandPalette { name: COMMAND_SWITCH_SESSION.to_string(), shortcut: Some("".to_string()), }, + Command { + name: COMMAND_REGENERATE_SUMMARY.to_string(), + shortcut: None, + }, Command { name: COMMAND_SWITCH_THEME.to_string(), shortcut: Some("".to_string()), @@ -541,7 +546,15 @@ impl CommandPalette { .updated_at .format(&Rfc3339) .unwrap_or_else(|_| "unknown".to_string()); - format!("{} [{}]", metadata.name, updated_at) + let summary = metadata + .summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + match summary { + Some(summary) => format!("{} [{}]", summary, updated_at), + None => format!("{} [{}]", metadata.name, updated_at), + } } fn on_enter(&mut self) -> Option { @@ -556,6 +569,9 @@ impl CommandPalette { self.open_session_switcher(); None } + Some(COMMAND_REGENERATE_SUMMARY) => { + Some(CommandPaletteAction::RegenerateSessionSummary) + } Some(COMMAND_SWITCH_THEME) => { self.open_theme_switcher(); None diff --git a/crates/coco-tui/src/session/fs.rs b/crates/coco-tui/src/session/fs.rs index b21d3e5..ba8e872 100644 --- a/crates/coco-tui/src/session/fs.rs +++ b/crates/coco-tui/src/session/fs.rs @@ -10,6 +10,8 @@ use crate::error::Result; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersistentSessionMetadata { pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, #[serde(with = "time::serde::rfc3339")] pub created_at: time::OffsetDateTime, #[serde(with = "time::serde::rfc3339")] @@ -24,6 +26,10 @@ impl PersistentSessionMetadata { pub fn metadata_filename(&self) -> String { format!("{}.metadata.json", self.created_at.unix_timestamp_nanos()) } + + pub fn metadata_filename_for_created_at(created_at: time::OffsetDateTime) -> String { + format!("{}.metadata.json", created_at.unix_timestamp_nanos()) + } } #[derive(Serialize, Deserialize)] @@ -44,13 +50,14 @@ impl PersistentSession { pub fn to_metadata(&self) -> PersistentSessionMetadata { PersistentSessionMetadata { name: self.name.clone(), + summary: None, created_at: self.created_at, updated_at: self.updated_at, } } } -async fn load_session_metadata( +pub async fn load_session_metadata( session_dir: &Path, filename: &str, ) -> Result { @@ -116,7 +123,11 @@ pub async fn list_session(session_dir: &Path) -> Result Result<()> { +pub async fn save_session( + session_dir: &Path, + session: PersistentSession, + summary: Option, +) -> Result<()> { // Save full session let json = serde_json::to_string_pretty(&session).whatever_context("failed to serialize session")?; @@ -127,7 +138,18 @@ pub async fn save_session(session_dir: &Path, session: PersistentSession) -> Res .whatever_context("failed to write session to file")?; // Save metadata - let metadata = session.to_metadata(); + let mut metadata = session.to_metadata(); + let summary = match summary { + Some(summary) => Some(summary), + None => { + let filename = metadata.metadata_filename(); + load_session_metadata(session_dir, &filename) + .await + .ok() + .and_then(|metadata| metadata.summary) + } + }; + metadata.summary = summary; let metadata_json = serde_json::to_string_pretty(&metadata).whatever_context("failed to serialize metadata")?;