Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/coco-tui/examples/session_command_palette.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"name": "Switch Session",
"shortcut": "<C-s>"
},
{
"name": "Regenerate Session Summary",
"shortcut": null
},
{
"name": "Switch Theme",
"shortcut": "<C-l>"
Expand Down
1 change: 1 addition & 0 deletions crates/coco-tui/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ impl From<ToolAction> for Action {
pub enum CommandPaletteAction {
NewSession,
Transcript,
RegenerateSessionSummary,
RestoreSession(PersistentSessionMetadata),
SwitchTheme(String),
SwitchModel(Option<String>),
Expand Down
169 changes: 157 additions & 12 deletions crates/coco-tui/src/components/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Instant>>,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand All @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<String>,
thinking_enabled: bool,
) -> Option<String> {
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::<Vec<_>>()
.join("\n"),
}
}

fn sanitize_session_summary(raw: &str) -> Option<String> {
let collapsed = raw.split_whitespace().collect::<Vec<_>>().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)";

Expand Down Expand Up @@ -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());
}
}
18 changes: 17 additions & 1 deletion crates/coco-tui/src/components/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -305,6 +306,10 @@ impl CommandPalette {
name: COMMAND_SWITCH_SESSION.to_string(),
shortcut: Some("<C-s>".to_string()),
},
Command {
name: COMMAND_REGENERATE_SUMMARY.to_string(),
shortcut: None,
},
Command {
name: COMMAND_SWITCH_THEME.to_string(),
shortcut: Some("<C-l>".to_string()),
Expand Down Expand Up @@ -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<CommandPaletteAction> {
Expand All @@ -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
Expand Down
28 changes: 25 additions & 3 deletions crates/coco-tui/src/session/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: time::OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
Expand All @@ -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)]
Expand All @@ -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<PersistentSessionMetadata> {
Expand Down Expand Up @@ -116,7 +123,11 @@ pub async fn list_session(session_dir: &Path) -> Result<Vec<PersistentSessionMet
Ok(sessions)
}

pub async fn save_session(session_dir: &Path, session: PersistentSession) -> Result<()> {
pub async fn save_session(
session_dir: &Path,
session: PersistentSession,
summary: Option<String>,
) -> Result<()> {
// Save full session
let json =
serde_json::to_string_pretty(&session).whatever_context("failed to serialize session")?;
Expand All @@ -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")?;

Expand Down