diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 20b0156186..7679ac7e77 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -59,6 +59,8 @@ tui-markdown = "0.3.3" tui-textarea = "0.7.0" unicode-segmentation = "1.12.0" uuid = "1" +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } [dev-dependencies] pretty_assertions = "1" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4b8b9b7812..d94841af9e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -277,6 +277,11 @@ impl<'a> App<'a> { widget.add_diff_output(text); } } + SlashCommand::Compact => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.compact().await; + } + } }, AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0b623132b5..f5df749b48 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -39,6 +39,7 @@ use crate::conversation_history_widget::ConversationHistoryWidget; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; use codex_file_search::FileMatch; +use crate::compact::{generate_compact_summary, Role, TranscriptEntry}; pub(crate) struct ChatWidget<'a> { app_event_tx: AppEventSender, @@ -49,6 +50,7 @@ pub(crate) struct ChatWidget<'a> { config: Config, initial_user_message: Option, token_usage: TokenUsage, + transcript: Vec, } #[derive(Clone, Copy, Eq, PartialEq)] @@ -135,6 +137,7 @@ impl ChatWidget<'_> { initial_images, ), token_usage: TokenUsage::default(), + transcript: Vec::new(), } } @@ -208,6 +211,7 @@ impl ChatWidget<'_> { // Only show text portion in conversation history for now. if !text.is_empty() { self.conversation_history.add_user_message(text); + self.transcript.push(TranscriptEntry { role: Role::User, text }); } self.conversation_history.scroll_to_bottom(); } @@ -236,6 +240,7 @@ impl ChatWidget<'_> { EventMsg::AgentMessage(AgentMessageEvent { message }) => { self.conversation_history .add_agent_message(&self.config, message); + self.transcript.push(TranscriptEntry { role: Role::Assistant, text: message }); self.request_redraw(); } EventMsg::AgentReasoning(AgentReasoningEvent { text }) => { @@ -410,6 +415,19 @@ impl ChatWidget<'_> { self.bottom_pane.on_file_search_result(query, matches); } + pub(crate) async fn compact(&mut self) { + let Ok(summary) = generate_compact_summary(&self.transcript, &self.config.model, &self.config).await else { + self.conversation_history.add_error("Failed to compact context".to_string()); + self.request_redraw(); + return; + }; + + self.conversation_history = ConversationHistoryWidget::new(); + self.conversation_history.add_agent_message(&self.config, summary.clone()); + self.transcript = vec![TranscriptEntry { role: Role::Assistant, text: summary }]; + self.request_redraw(); + } + /// Handle Ctrl-C key press. /// Returns true if the key press was handled, false if it was not. /// If the key press was not handled, the caller should handle it (likely by exiting the process). diff --git a/codex-rs/tui/src/compact.rs b/codex-rs/tui/src/compact.rs new file mode 100644 index 0000000000..8ae3f9f2e9 --- /dev/null +++ b/codex-rs/tui/src/compact.rs @@ -0,0 +1,90 @@ +use anyhow::{anyhow, Result}; +use serde::Serialize; +use codex_core::config::Config; +use codex_core::openai_api_key::get_openai_api_key; + +#[derive(Clone)] +pub enum Role { + User, + Assistant, +} + +#[derive(Clone)] +pub struct TranscriptEntry { + pub role: Role, + pub text: String, +} + +impl TranscriptEntry { + fn role_str(&self) -> &'static str { + match self.role { + Role::User => "user", + Role::Assistant => "assistant", + } + } +} + +#[derive(Serialize)] +struct Message<'a> { + role: &'a str, + content: String, +} + +#[derive(Serialize)] +struct Payload<'a> { + model: &'a str, + messages: Vec>, +} + +/// Generate a concise summary of the provided transcript using the OpenAI chat +/// completions API. +pub async fn generate_compact_summary( + transcript: &[TranscriptEntry], + model: &str, + config: &Config, +) -> Result { + let conversation_text = transcript + .iter() + .map(|e| format!("{}: {}", e.role_str(), e.text)) + .collect::>() + .join("\n"); + + let messages = vec![ + Message { + role: "assistant", + content: "You are an expert coding assistant. Your goal is to generate a concise, structured summary of the conversation below that captures all essential information needed to continue development after context replacement. Include tasks performed, code areas modified or reviewed, key decisions or assumptions, test results or errors, and outstanding tasks or next steps.".to_string(), + }, + Message { + role: "user", + content: format!( + "Here is the conversation so far:\n{conversation_text}\n\nPlease summarize this conversation, covering:\n1. Tasks performed and outcomes\n2. Code files, modules, or functions modified or examined\n3. Important decisions or assumptions made\n4. Errors encountered and test or build results\n5. Remaining tasks, open questions, or next steps\nProvide the summary in a clear, concise format." + ), + }, + ]; + + let api_key = get_openai_api_key().ok_or_else(|| anyhow!("OpenAI API key not set"))?; + let client = reqwest::Client::new(); + let base = config.model_provider.base_url.trim_end_matches('/'); + let url = format!("{}/chat/completions", base); + + let payload = Payload { model, messages }; + let res = client + .post(url) + .bearer_auth(api_key) + .json(&payload) + .send() + .await?; + + let body: serde_json::Value = res.json().await?; + if let Some(summary) = body + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|v| v.as_str()) + { + Ok(summary.to_string()) + } else { + Ok("Unable to generate summary.".to_string()) + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 07ddbc4168..df970ed65e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -26,6 +26,7 @@ mod bottom_pane; mod cell_widget; mod chatwidget; mod citation_regex; +mod compact; mod cli; mod conversation_history_widget; mod exec_command; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bb72ce561c..4e8fa9ae8b 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. New, Diff, + Compact, Quit, ToggleMouseMode, } @@ -30,6 +31,7 @@ impl SlashCommand { SlashCommand::Diff => { "Show git diff of the working directory (including untracked files)" } + SlashCommand::Compact => "Condense context into a summary.", } }