Add /compact command to Rust CLI

compact

tests

working
This commit is contained in:
aibrahim-oai
2025-07-09 13:37:15 -07:00
committed by Ahmed Ibrahim
parent 268267b59e
commit 3a4f5435e8
12 changed files with 275 additions and 2 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -794,6 +794,8 @@ dependencies = [
"ratatui",
"ratatui-image",
"regex-lite",
"reqwest",
"serde",
"serde_json",
"shlex",
"strum 0.27.1",

View File

@@ -200,6 +200,23 @@ impl Session {
.map(PathBuf::from)
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
}
/// Erases all previous messages from the conversation history (zdr_transcript), if present.
pub fn erase_conversation_history(&self) {
let mut state = self.state.lock().unwrap();
if let Some(transcript) = state.zdr_transcript.as_mut() {
transcript.clear();
}
// When using the experimental OpenAI Responses API with server-side
// storage enabled, `previous_response_id` is used to let the model
// access the earlier part of the conversation **without** having to
// resend the full transcript. To truly wipe all historical context
// we must drop this identifier as well, otherwise the backend will
// still be able to retrieve the prior messages via the ID even
// though our local transcript has been cleared. See
// https://platform.openai.com/docs/guides/responses for details.
state.previous_response_id = None;
}
}
/// Mutable state of the agent
@@ -549,6 +566,11 @@ async fn submission_loop(
debug!(?sub, "Submission");
match sub.op {
Op::EraseConversationHistory => {
if let Some(sess) = sess.as_ref() {
sess.erase_conversation_history();
}
}
Op::Interrupt => {
let sess = match sess.as_ref() {
Some(sess) => sess,

View File

@@ -35,6 +35,37 @@ impl ConversationHistory {
}
}
}
/// Clears the conversation history.
pub(crate) fn clear(&mut self) {
self.items.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ResponseItem;
#[test]
fn clear_removes_all_items() {
let mut hist = ConversationHistory::new();
use crate::models::ContentItem;
let items = vec![ResponseItem::Message {
role: "user".into(),
content: vec![ContentItem::InputText { text: "hello".into() }],
}];
hist.record_items(items.iter());
assert_eq!(hist.contents().len(), 1, "sanity item should be present");
hist.clear();
assert!(hist.contents().is_empty(), "all items should be removed");
}
}
/// Anything that is not a system message or "reasoning" message is considered

View File

@@ -33,6 +33,8 @@ pub struct Submission {
#[allow(clippy::large_enum_variant)]
#[non_exhaustive]
pub enum Op {
/// Erase all conversation history for the current session.
EraseConversationHistory,
/// Configure the model session.
ConfigureSession {
/// Provider identifier ("openai", "openrouter", ...).

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.start_compact();
}
}
},
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
@@ -286,6 +291,11 @@ impl<'a> App<'a> {
widget.apply_file_search_result(query, matches);
}
}
AppEvent::CompactComplete(result) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.apply_compact_summary(result);
}
}
}
}
terminal.clear()?;

View File

@@ -42,4 +42,7 @@ pub(crate) enum AppEvent {
query: String,
matches: Vec<FileMatch>,
},
/// Result of the asynchronous `/compact` summarization.
CompactComplete(Result<String, String>),
}

View File

@@ -35,6 +35,9 @@ use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::InputResult;
use crate::compact::Role;
use crate::compact::TranscriptEntry;
use crate::compact::generate_compact_summary;
use crate::conversation_history_widget::ConversationHistoryWidget;
use crate::history_cell::PatchEventType;
use crate::user_approval_widget::ApprovalRequest;
@@ -49,6 +52,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 +139,7 @@ impl ChatWidget<'_> {
initial_images,
),
token_usage: TokenUsage::default(),
transcript: Vec::new(),
}
}
@@ -207,7 +212,13 @@ impl ChatWidget<'_> {
// Only show text portion in conversation history for now.
if !text.is_empty() {
self.conversation_history.add_user_message(text);
// Forward a *copy* of the text to the history widget so we can
// still use the original value afterwards.
self.conversation_history.add_user_message(text.clone());
self.transcript.push(TranscriptEntry {
role: Role::User,
text,
});
}
self.conversation_history.scroll_to_bottom();
}
@@ -234,8 +245,14 @@ impl ChatWidget<'_> {
self.request_redraw();
}
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
// Preserve `message` for the transcript after adding it to the
// conversation history.
self.conversation_history
.add_agent_message(&self.config, message);
.add_agent_message(&self.config, message.clone());
self.transcript.push(TranscriptEntry {
role: Role::Assistant,
text: message,
});
self.request_redraw();
}
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
@@ -410,6 +427,88 @@ impl ChatWidget<'_> {
self.bottom_pane.on_file_search_result(query, matches);
}
// (removed deprecated synchronous `compact` implementation)
/// Kick off an asynchronous summarization of the current transcript.
/// Returns immediately so the UI stays responsive.
pub(crate) fn start_compact(&mut self) {
// Show status indicator immediately.
self.bottom_pane.set_task_running(true);
self.bottom_pane
.update_status_text("Summarizing context…".to_string());
self.request_redraw();
// Clone data required for the background task.
let transcript = self.transcript.clone();
let model = self.config.model.clone();
let config_clone = self.config.clone();
let app_event_tx = self.app_event_tx.clone();
// Spawn the summarization on a blocking thread to avoid CPU-bound work
// stalling the async runtime (and thus the UI).
tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(async move {
let result =
generate_compact_summary(&transcript, &model, &config_clone).await;
let evt = match result {
Ok(summary) => AppEvent::CompactComplete(Ok(summary)),
Err(e) => AppEvent::CompactComplete(Err(format!("{e}"))),
};
app_event_tx.send(evt);
});
});
}
/// Apply the completed summary returned by the background task.
pub(crate) fn apply_compact_summary(&mut self, result: Result<String, String>) {
match result {
Ok(summary) => {
self.conversation_history.clear_agent_history();
self.transcript.clear();
// clear session history in backend
self.submit_op(Op::EraseConversationHistory);
self.conversation_history
.add_agent_message(&self.config, summary.clone());
self.transcript = vec![TranscriptEntry {
role: Role::Assistant,
text: summary,
}];
// Re-configure the Codex session so that the backend agent starts with
// a clean conversation context.
let instructions = self.config.instructions.clone();
let op = Op::ConfigureSession {
provider: self.config.model_provider.clone(),
model: self.config.model.clone(),
model_reasoning_effort: self.config.model_reasoning_effort,
model_reasoning_summary: self.config.model_reasoning_summary,
instructions,
approval_policy: self.config.approval_policy,
sandbox_policy: self.config.sandbox_policy.clone(),
disable_response_storage: self.config.disable_response_storage,
notify: self.config.notify.clone(),
cwd: self.config.cwd.clone(),
};
self.submit_op(op);
// Reset the recorded token usage because we start a fresh
// conversation context. This ensures the *context remaining*
// indicator in the composer is updated immediately.
self.token_usage = TokenUsage::default();
self.bottom_pane
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
}
Err(msg) => {
self.conversation_history.add_error(msg);
}
}
// Hide status indicator and refresh UI.
self.bottom_pane.set_task_running(false);
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,91 @@
use anyhow::Result;
use anyhow::anyhow;
use codex_core::config::Config;
use codex_core::openai_api_key::get_openai_api_key;
use serde::Serialize;
#[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

@@ -198,6 +198,10 @@ impl ConversationHistoryWidget {
self.add_to_history(HistoryCell::new_agent_message(config, message));
}
pub fn clear_agent_history(&mut self) {
self.clear_all();
}
pub fn add_agent_reasoning(&mut self, config: &Config, text: String) {
self.add_to_history(HistoryCell::new_agent_reasoning(config, text));
}
@@ -249,6 +253,10 @@ impl ConversationHistoryWidget {
});
}
fn clear_all(&mut self) {
self.entries.clear();
}
pub fn record_completed_exec_command(
&mut self,
call_id: 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.",
}
}