Add /compact command to Rust CLI

This commit is contained in:
aibrahim-oai
2025-07-09 13:37:15 -07:00
parent 4a15ebc1ca
commit 39401b49cf
6 changed files with 118 additions and 0 deletions

View File

@@ -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"

View File

@@ -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);

View File

@@ -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<UserMessage>,
token_usage: TokenUsage,
transcript: Vec<TranscriptEntry>,
}
#[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).

View File

@@ -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<Message<'a>>,
}
/// 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<String> {
let conversation_text = transcript
.iter()
.map(|e| format!("{}: {}", e.role_str(), e.text))
.collect::<Vec<_>>()
.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())
}
}

View File

@@ -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;

View File

@@ -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.",
}
}