mirror of
https://github.com/openai/codex.git
synced 2026-02-02 06:57:03 +00:00
Compare commits
7 Commits
alpha-cli
...
easong/com
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cf29c826d | ||
|
|
c9a9ac1ebf | ||
|
|
a073dba2e3 | ||
|
|
2a7f70c00b | ||
|
|
75ec3a2e36 | ||
|
|
267db87333 | ||
|
|
f6e9f782fa |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -847,6 +847,7 @@ dependencies = [
|
||||
"codex-login",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"futures",
|
||||
"image",
|
||||
"insta",
|
||||
"lazy_static",
|
||||
|
||||
@@ -177,7 +177,7 @@ pub fn model_supports_reasoning_summaries(config: &Config) -> bool {
|
||||
model.starts_with("o") || model.starts_with("codex")
|
||||
}
|
||||
|
||||
pub(crate) struct ResponseStream {
|
||||
pub struct ResponseStream {
|
||||
pub(crate) rx_event: mpsc::Receiver<Result<ResponseEvent>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
mod apply_patch;
|
||||
mod bash;
|
||||
mod chat_completions;
|
||||
mod client;
|
||||
mod client_common;
|
||||
pub mod client;
|
||||
pub mod client_common;
|
||||
pub mod codex;
|
||||
pub use codex::Codex;
|
||||
pub use codex::CodexSpawnOk;
|
||||
@@ -30,7 +30,7 @@ mod message_history;
|
||||
mod model_provider_info;
|
||||
pub use model_provider_info::ModelProviderInfo;
|
||||
pub use model_provider_info::WireApi;
|
||||
mod models;
|
||||
pub mod models;
|
||||
pub mod openai_api_key;
|
||||
mod openai_model_info;
|
||||
mod openai_tools;
|
||||
|
||||
@@ -188,7 +188,6 @@ pub struct ShellToolCallParams {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FunctionCallOutputPayload {
|
||||
pub content: String,
|
||||
#[expect(dead_code)]
|
||||
pub success: Option<bool>,
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ tui-textarea = "0.7.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.1"
|
||||
uuid = "1"
|
||||
futures = "0.3"
|
||||
|
||||
[features]
|
||||
fake-compact-model = []
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.43.1"
|
||||
|
||||
@@ -290,6 +290,11 @@ impl App<'_> {
|
||||
self.app_state = AppState::Chat { widget: new_widget };
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
SlashCommand::Compact => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.request_compact();
|
||||
}
|
||||
}
|
||||
SlashCommand::Quit => {
|
||||
break;
|
||||
}
|
||||
@@ -315,6 +320,39 @@ impl App<'_> {
|
||||
}
|
||||
}
|
||||
},
|
||||
AppEvent::CompactSummaryReady(summary) => {
|
||||
// Replace the current chat widget with a fresh one and show the summary.
|
||||
// Also bake the summary into the new session's base instructions so the
|
||||
// model uses it as context going forward.
|
||||
let mut cfg = self.config.clone();
|
||||
let prefix = "Conversation summary:";
|
||||
let appended = match cfg.base_instructions.take() {
|
||||
Some(existing) => format!("{existing}\n\n{prefix}\n{summary}\n"),
|
||||
None => format!("{prefix}\n{summary}\n"),
|
||||
};
|
||||
cfg.base_instructions = Some(appended);
|
||||
|
||||
let new_widget = Box::new(ChatWidget::new(
|
||||
cfg,
|
||||
self.app_event_tx.clone(),
|
||||
None,
|
||||
Vec::new(),
|
||||
));
|
||||
self.app_state = AppState::Chat { widget: new_widget };
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
// Echo the invoked command at the top of the new session
|
||||
// so the transcript clearly shows what was run.
|
||||
widget.echo_slash_command("/compact");
|
||||
widget.show_compact_summary(summary);
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
AppEvent::CompactSummaryFailed(message) => match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
widget.show_compact_error(message);
|
||||
}
|
||||
AppState::GitWarning { .. } => {}
|
||||
},
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
self.file_search.on_user_query(query);
|
||||
}
|
||||
|
||||
@@ -52,4 +52,14 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
|
||||
InsertHistory(Vec<Line<'static>>),
|
||||
|
||||
/// Result of a /compact request – the generated summary text. Handled by
|
||||
/// the app layer to replace the current chat widget with a fresh one that
|
||||
/// displays the summary at the top.
|
||||
CompactSummaryReady(String),
|
||||
|
||||
/// Failed to generate a compact summary. Contains a human-readable error
|
||||
/// message that will be surfaced in the conversation history.
|
||||
#[cfg_attr(feature = "fake-compact-model", allow(dead_code))]
|
||||
CompactSummaryFailed(String),
|
||||
}
|
||||
|
||||
@@ -651,7 +651,11 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
match &self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
let popup_height = popup.calculate_required_height(&area);
|
||||
let requested = popup.calculate_required_height(&area);
|
||||
// Reserve at least a few rows for the textarea so the user can see what they are typing.
|
||||
let min_textarea_height: u16 = 3;
|
||||
let max_popup_height = area.height.saturating_sub(min_textarea_height).max(1);
|
||||
let popup_height = requested.min(max_popup_height).max(1);
|
||||
|
||||
// Split the provided rect so that the popup is rendered at the
|
||||
// *top* and the textarea occupies the remaining space below.
|
||||
@@ -673,7 +677,10 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
self.textarea.render(textarea_rect, buf);
|
||||
}
|
||||
ActivePopup::File(popup) => {
|
||||
let popup_height = popup.calculate_required_height(&area);
|
||||
let requested = popup.calculate_required_height(&area);
|
||||
let min_textarea_height: u16 = 3;
|
||||
let max_popup_height = area.height.saturating_sub(min_textarea_height).max(1);
|
||||
let popup_height = requested.min(max_popup_height).max(1);
|
||||
|
||||
let popup_rect = Rect {
|
||||
x: area.x,
|
||||
@@ -1122,6 +1129,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Unit tests for the slash popup live in command_popup.rs where we can
|
||||
// directly inspect the filtered list.
|
||||
|
||||
#[test]
|
||||
fn test_partial_placeholder_deletion() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -14,6 +14,7 @@ use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
use std::cell::Cell as StdCell;
|
||||
|
||||
const MAX_POPUP_ROWS: usize = 5;
|
||||
/// Ideally this is enough to show the longest command name.
|
||||
@@ -25,6 +26,13 @@ pub(crate) struct CommandPopup {
|
||||
command_filter: String,
|
||||
all_commands: Vec<(&'static str, SlashCommand)>,
|
||||
selected_idx: Option<usize>,
|
||||
/// Index into the filtered command list that indicates the first visible
|
||||
/// row in the popup. Ensures the selection remains visible when the list
|
||||
/// exceeds MAX_POPUP_ROWS.
|
||||
scroll_top: usize,
|
||||
/// Number of command rows that fit into the popup given the current
|
||||
/// terminal size. Updated on each render.
|
||||
visible_rows: StdCell<usize>,
|
||||
}
|
||||
|
||||
impl CommandPopup {
|
||||
@@ -33,6 +41,8 @@ impl CommandPopup {
|
||||
command_filter: String::new(),
|
||||
all_commands: built_in_slash_commands(),
|
||||
selected_idx: None,
|
||||
scroll_top: 0,
|
||||
visible_rows: StdCell::new(MAX_POPUP_ROWS),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,29 +53,43 @@ impl CommandPopup {
|
||||
pub(crate) fn on_composer_text_change(&mut self, text: String) {
|
||||
let first_line = text.lines().next().unwrap_or("");
|
||||
|
||||
if let Some(stripped) = first_line.strip_prefix('/') {
|
||||
// Extract the *first* token (sequence of non-whitespace
|
||||
// characters) after the slash so that `/clear something` still
|
||||
// shows the help for `/clear`.
|
||||
// Compute new filter token.
|
||||
let new_filter = if let Some(stripped) = first_line.strip_prefix('/') {
|
||||
let token = stripped.trim_start();
|
||||
let cmd_token = token.split_whitespace().next().unwrap_or("");
|
||||
|
||||
// Update the filter keeping the original case (commands are all
|
||||
// lower-case for now but this may change in the future).
|
||||
self.command_filter = cmd_token.to_string();
|
||||
token.split_whitespace().next().unwrap_or("")
|
||||
} else {
|
||||
// The composer no longer starts with '/'. Reset the filter so the
|
||||
// popup shows the *full* command list if it is still displayed
|
||||
// for some reason.
|
||||
self.command_filter.clear();
|
||||
}
|
||||
|
||||
// Reset or clamp selected index based on new filtered list.
|
||||
let matches_len = self.filtered_commands().len();
|
||||
self.selected_idx = match matches_len {
|
||||
0 => None,
|
||||
_ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
|
||||
""
|
||||
};
|
||||
|
||||
let prev_filter = self.command_filter.clone();
|
||||
self.command_filter = new_filter.to_string();
|
||||
|
||||
let matches_len = self.filtered_commands().len();
|
||||
let window = self.visible_rows.get().max(1);
|
||||
|
||||
if self.command_filter == prev_filter {
|
||||
// Keep selection/scroll positions stable, but clamp to bounds.
|
||||
if matches_len == 0 {
|
||||
self.selected_idx = None;
|
||||
self.scroll_top = 0;
|
||||
} else if let Some(idx) = self.selected_idx {
|
||||
let clamped = idx.min(matches_len - 1);
|
||||
self.selected_idx = Some(clamped);
|
||||
// Ensure scroll_top is within bounds too.
|
||||
let max_scroll = matches_len.saturating_sub(window);
|
||||
self.scroll_top = self.scroll_top.min(max_scroll);
|
||||
if clamped < self.scroll_top {
|
||||
self.scroll_top = clamped;
|
||||
}
|
||||
} else {
|
||||
self.selected_idx = Some(0);
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
} else {
|
||||
// Filter changed – reset to top.
|
||||
self.selected_idx = if matches_len == 0 { None } else { Some(0) };
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the preferred height of the popup. This is the number of
|
||||
@@ -100,18 +124,29 @@ impl CommandPopup {
|
||||
|
||||
/// Move the selection cursor one step up.
|
||||
pub(crate) fn move_up(&mut self) {
|
||||
if let Some(len) = self.filtered_commands().len().checked_sub(1) {
|
||||
if len == usize::MAX {
|
||||
return;
|
||||
}
|
||||
let matches_len = self.filtered_commands().len();
|
||||
let window = self.visible_rows.get().max(1);
|
||||
if matches_len == 0 {
|
||||
self.selected_idx = None;
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(idx) = self.selected_idx {
|
||||
if idx > 0 {
|
||||
self.selected_idx = Some(idx - 1);
|
||||
match self.selected_idx {
|
||||
Some(0) | None => {
|
||||
// Wrap to last element.
|
||||
let last = matches_len - 1;
|
||||
self.selected_idx = Some(last);
|
||||
let max_scroll = matches_len.saturating_sub(window);
|
||||
self.scroll_top = max_scroll;
|
||||
}
|
||||
Some(idx) => {
|
||||
let new_idx = idx - 1;
|
||||
self.selected_idx = Some(new_idx);
|
||||
if new_idx < self.scroll_top {
|
||||
self.scroll_top = new_idx;
|
||||
}
|
||||
}
|
||||
} else if !self.filtered_commands().is_empty() {
|
||||
self.selected_idx = Some(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,14 +158,25 @@ impl CommandPopup {
|
||||
return;
|
||||
}
|
||||
|
||||
let window = self.visible_rows.get().max(1);
|
||||
match self.selected_idx {
|
||||
Some(idx) if idx + 1 < matches_len => {
|
||||
self.selected_idx = Some(idx + 1);
|
||||
}
|
||||
None => {
|
||||
self.selected_idx = Some(0);
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
Some(idx) => {
|
||||
if idx + 1 < matches_len {
|
||||
let new_idx = idx + 1;
|
||||
self.selected_idx = Some(new_idx);
|
||||
if new_idx >= self.scroll_top + window {
|
||||
self.scroll_top = new_idx + 1 - window;
|
||||
}
|
||||
} else {
|
||||
// Wrap to first.
|
||||
self.selected_idx = Some(0);
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +192,19 @@ impl WidgetRef for CommandPopup {
|
||||
let matches = self.filtered_commands();
|
||||
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
let visible_matches: Vec<&SlashCommand> =
|
||||
matches.into_iter().take(MAX_POPUP_ROWS).collect();
|
||||
// Determine how many rows we can render in the current area (minus border lines).
|
||||
let mut visible_rows = area.height.saturating_sub(2) as usize;
|
||||
if visible_rows == 0 {
|
||||
visible_rows = 1; // Always show at least one row.
|
||||
}
|
||||
// Persist for key handlers so we can scroll properly.
|
||||
self.visible_rows.set(visible_rows);
|
||||
|
||||
let visible_matches: Vec<&SlashCommand> = matches
|
||||
.into_iter()
|
||||
.skip(self.scroll_top)
|
||||
.take(visible_rows)
|
||||
.collect();
|
||||
|
||||
if visible_matches.is_empty() {
|
||||
rows.push(Row::new(vec![
|
||||
@@ -157,8 +214,9 @@ impl WidgetRef for CommandPopup {
|
||||
} else {
|
||||
let default_style = Style::default();
|
||||
let command_style = Style::default().fg(Color::LightBlue);
|
||||
for (idx, cmd) in visible_matches.iter().enumerate() {
|
||||
let (cmd_style, desc_style) = if Some(idx) == self.selected_idx {
|
||||
for (visible_idx, cmd) in visible_matches.iter().enumerate() {
|
||||
let absolute_idx = self.scroll_top + visible_idx;
|
||||
let (cmd_style, desc_style) = if Some(absolute_idx) == self.selected_idx {
|
||||
(
|
||||
command_style.bg(Color::DarkGray),
|
||||
default_style.bg(Color::DarkGray),
|
||||
@@ -190,3 +248,28 @@ impl WidgetRef for CommandPopup {
|
||||
table.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filtered_commands_include_compact_when_no_filter() {
|
||||
let mut popup = CommandPopup::new();
|
||||
popup.on_composer_text_change("/".to_string());
|
||||
let cmds = popup.filtered_commands();
|
||||
let names: Vec<&str> = cmds.iter().map(|c| c.command()).collect();
|
||||
assert!(names.contains(&"compact"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_commands_only_compact_for_c_prefix() {
|
||||
let mut popup = CommandPopup::new();
|
||||
popup.on_composer_text_change("/c".to_string());
|
||||
let cmds = popup.filtered_commands();
|
||||
// Depending on future commands this might include others starting with c.
|
||||
// For now ensure that compact is among the top filtered results.
|
||||
let names: Vec<&str> = cmds.iter().map(|c| c.command()).collect();
|
||||
assert!(names.contains(&"compact"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(not(feature = "fake-compact-model"))]
|
||||
use codex_core::client::ModelClient;
|
||||
#[cfg(not(feature = "fake-compact-model"))]
|
||||
use codex_core::client_common::Prompt;
|
||||
#[cfg(not(feature = "fake-compact-model"))]
|
||||
use codex_core::client_common::ResponseEvent;
|
||||
use codex_core::codex_wrapper::CodexConversation;
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
use codex_core::config::Config;
|
||||
#[cfg(not(feature = "fake-compact-model"))]
|
||||
use codex_core::models::ContentItem;
|
||||
#[cfg(not(feature = "fake-compact-model"))]
|
||||
use codex_core::models::ResponseItem;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
@@ -25,7 +35,6 @@ use codex_core::protocol::TokenUsage;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
@@ -42,6 +51,73 @@ use crate::history_cell::PatchEventType;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use codex_file_search::FileMatch;
|
||||
|
||||
#[cfg(all(test, feature = "fake-compact-model"))]
|
||||
mod fake_compact_tests {
|
||||
use super::*;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::Duration;
|
||||
|
||||
fn build_test_config() -> Config {
|
||||
let cfg = ConfigToml::default();
|
||||
let overrides = ConfigOverrides {
|
||||
model: None,
|
||||
cwd: Some(std::env::temp_dir()),
|
||||
approval_policy: None,
|
||||
sandbox_mode: None,
|
||||
model_provider: None,
|
||||
config_profile: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
base_instructions: None,
|
||||
include_plan_tool: None,
|
||||
};
|
||||
let home = std::env::temp_dir().join("codex_fake_model_tests");
|
||||
let _ = std::fs::create_dir_all(&home);
|
||||
match Config::load_from_base_config_with_overrides(cfg, overrides, home) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => panic!("failed to build test config: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn request_compact_uses_fake_model_and_emits_event() {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
|
||||
let config = build_test_config();
|
||||
let mut widget = ChatWidget::new_for_tests(config, sender.clone());
|
||||
widget
|
||||
.conversation_history
|
||||
.add_user_message("User: hello".to_string());
|
||||
widget
|
||||
.conversation_history
|
||||
.add_agent_message(&widget.config, "Assistant: hi".to_string());
|
||||
|
||||
widget.request_compact();
|
||||
|
||||
// Wait for the CompactSummaryReady event.
|
||||
let summary = match wait_for_summary(rx) {
|
||||
Some(s) => s,
|
||||
None => panic!("no summary event"),
|
||||
};
|
||||
assert!(summary.contains("FAKE SUMMARY"));
|
||||
assert!(summary.contains("hello"));
|
||||
}
|
||||
|
||||
fn wait_for_summary(rx: Receiver<AppEvent>) -> Option<String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if let Ok(AppEvent::CompactSummaryReady(s)) = rx.recv_timeout(Duration::from_millis(50))
|
||||
{
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
@@ -143,6 +219,173 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off a background task to generate a compact summary of the
|
||||
/// conversation, then surface either the summary (replacing the current
|
||||
/// session) or an error message.
|
||||
pub(crate) fn request_compact(&mut self) {
|
||||
// Extract plain-text representation of the conversation.
|
||||
let convo_text = self.conversation_history.to_compact_summary_text();
|
||||
if convo_text.trim().is_empty() {
|
||||
// Nothing to summarize – surface a friendly message.
|
||||
self.conversation_history
|
||||
.add_background_event("Conversation is empty – nothing to compact.".to_string());
|
||||
self.emit_last_history_entry();
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show status indicator while the background task runs.
|
||||
self.bottom_pane.set_task_running(true);
|
||||
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
|
||||
#[cfg(feature = "fake-compact-model")]
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
sleep(Duration::from_millis(5)).await;
|
||||
let summary = Self::fake_compact_summary(&convo_text);
|
||||
app_event_tx.send(crate::app_event::AppEvent::CompactSummaryReady(summary));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "fake-compact-model"))]
|
||||
{
|
||||
let config = self.config.clone();
|
||||
let provider = config.model_provider.clone();
|
||||
let effort = config.model_reasoning_effort;
|
||||
let summary_pref = config.model_reasoning_summary;
|
||||
let session_id = uuid::Uuid::new_v4();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let client = ModelClient::new(
|
||||
std::sync::Arc::new(config.clone()),
|
||||
provider,
|
||||
effort,
|
||||
summary_pref,
|
||||
session_id,
|
||||
);
|
||||
|
||||
const SYSTEM_PROMPT: &str = "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.";
|
||||
|
||||
let mut prompt = Prompt {
|
||||
base_instructions_override: Some(SYSTEM_PROMPT.to_string()),
|
||||
user_instructions: None,
|
||||
store: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let user_content = format!(
|
||||
"Here is the conversation so far:\n{convo_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."
|
||||
);
|
||||
|
||||
prompt.input.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: user_content }],
|
||||
});
|
||||
|
||||
let mut summary = String::new();
|
||||
let res = async {
|
||||
let mut stream = client.stream(&prompt).await?;
|
||||
use futures::StreamExt;
|
||||
let mut got_final_item = false;
|
||||
while let Some(ev) = stream.next().await {
|
||||
match ev {
|
||||
Ok(ResponseEvent::OutputTextDelta(delta)) => {
|
||||
if !got_final_item {
|
||||
summary.push_str(&delta);
|
||||
}
|
||||
}
|
||||
Ok(ResponseEvent::OutputItemDone(ResponseItem::Message {
|
||||
content,
|
||||
..
|
||||
})) => {
|
||||
// Prefer the fully provided final item over any previously streamed
|
||||
// deltas to avoid duplicating content.
|
||||
let mut final_text = String::new();
|
||||
for c in content {
|
||||
if let ContentItem::OutputText { text } = c {
|
||||
final_text.push_str(&text);
|
||||
}
|
||||
}
|
||||
if !final_text.is_empty() {
|
||||
summary = final_text;
|
||||
got_final_item = true;
|
||||
}
|
||||
}
|
||||
Ok(ResponseEvent::OutputItemDone(_)) => {}
|
||||
Ok(ResponseEvent::Completed { .. }) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok::<(), codex_core::error::CodexErr>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(()) => {
|
||||
if summary.trim().is_empty() {
|
||||
app_event_tx.send(crate::app_event::AppEvent::CompactSummaryFailed(
|
||||
"Model did not return a summary".to_string(),
|
||||
));
|
||||
} else {
|
||||
app_event_tx
|
||||
.send(crate::app_event::AppEvent::CompactSummaryReady(summary));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app_event_tx.send(crate::app_event::AppEvent::CompactSummaryFailed(
|
||||
format!("Failed to generate compact summary: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Display the generated compact summary at the top of a fresh session.
|
||||
pub(crate) fn show_compact_summary(&mut self, summary: String) {
|
||||
self.conversation_history
|
||||
.add_agent_message(&self.config, summary);
|
||||
self.emit_last_history_entry();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_compact_error(&mut self, message: String) {
|
||||
self.conversation_history.add_error(message);
|
||||
self.emit_last_history_entry();
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(feature = "fake-compact-model")]
|
||||
fn fake_compact_summary(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let head = lines.iter().take(3).copied().collect::<Vec<_>>().join("\n");
|
||||
format!("FAKE SUMMARY ({} lines)\n{}", lines.len(), head)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "fake-compact-model"))]
|
||||
pub(crate) fn new_for_tests(config: Config, app_event_tx: AppEventSender) -> Self {
|
||||
let (codex_op_tx, _rx) = unbounded_channel::<Op>();
|
||||
Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
codex_op_tx,
|
||||
conversation_history: ConversationHistoryWidget::new(),
|
||||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||||
app_event_tx,
|
||||
has_input_focus: true,
|
||||
}),
|
||||
config,
|
||||
initial_user_message: None,
|
||||
token_usage: TokenUsage::default(),
|
||||
reasoning_buffer: String::new(),
|
||||
answer_buffer: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
|
||||
@@ -451,6 +694,15 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Echo a slash command invocation into the transcript so users can see
|
||||
/// which command was executed.
|
||||
pub(crate) fn echo_slash_command(&mut self, cmd: &str) {
|
||||
self.conversation_history
|
||||
.add_background_event(format!("`{cmd}`"));
|
||||
self.emit_last_history_entry();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn handle_scroll_delta(&mut self, scroll_delta: i32) {
|
||||
// If the user is trying to scroll exactly one line, we let them, but
|
||||
// otherwise we assume they are trying to scroll in larger increments.
|
||||
@@ -513,7 +765,7 @@ impl WidgetRef for &ChatWidget<'_> {
|
||||
// In the hybrid inline viewport mode we only draw the interactive
|
||||
// bottom pane; history entries are injected directly into scrollback
|
||||
// via `Terminal::insert_before`.
|
||||
(&self.bottom_pane).render(area, buf);
|
||||
(&self.bottom_pane).render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,38 @@ impl ConversationHistoryWidget {
|
||||
self.scroll_position = usize::MAX;
|
||||
}
|
||||
|
||||
/// Produce a plain-text representation of the conversation suitable for
|
||||
/// feeding to a model to generate a compact summary. Only user and
|
||||
/// assistant messages are included; tool calls, diffs, errors and other
|
||||
/// background events are omitted.
|
||||
pub(crate) fn to_compact_summary_text(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for entry in &self.entries {
|
||||
match &entry.cell {
|
||||
HistoryCell::UserPrompt { view } => {
|
||||
let text = lines_to_plain_string(&view.lines);
|
||||
if !text.trim().is_empty() {
|
||||
out.push_str("user: ");
|
||||
out.push_str(text.trim());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
HistoryCell::AgentMessage { view } => {
|
||||
let text = lines_to_plain_string(&view.lines);
|
||||
if !text.trim().is_empty() {
|
||||
out.push_str("assistant: ");
|
||||
out.push_str(text.trim());
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Skip other entry types.
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Note `model` could differ from `config.model` if the agent decided to
|
||||
/// use a different model than the one requested by the user.
|
||||
pub fn add_session_info(&mut self, config: &Config, event: SessionConfiguredEvent) {
|
||||
@@ -253,6 +285,19 @@ impl ConversationHistoryWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn lines_to_plain_string(lines: &[Line<'static>]) -> String {
|
||||
let mut s = String::new();
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
for span in &line.spans {
|
||||
s.push_str(span.content.as_ref());
|
||||
}
|
||||
if idx + 1 < lines.len() {
|
||||
s.push('\n');
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
impl WidgetRef for ConversationHistoryWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let (title, border_style) = if self.has_input_focus {
|
||||
@@ -427,3 +472,39 @@ impl WidgetRef for ConversationHistoryWidget {
|
||||
pub(crate) const fn wrap_cfg() -> ratatui::widgets::Wrap {
|
||||
ratatui::widgets::Wrap { trim: false }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::text_block::TextBlock;
|
||||
use std::cell::Cell as StdCell2; // avoid clash with Cell type alias above
|
||||
|
||||
#[test]
|
||||
fn compact_summary_text_includes_user_and_assistant() {
|
||||
let mut history = ConversationHistoryWidget::new();
|
||||
// Manually construct entries to avoid depending on Config state.
|
||||
history.entries.push(Entry {
|
||||
cell: HistoryCell::UserPrompt {
|
||||
view: TextBlock::new(vec![Line::from("Hello world")]),
|
||||
},
|
||||
line_count: StdCell2::new(0),
|
||||
});
|
||||
history.entries.push(Entry {
|
||||
cell: HistoryCell::AgentMessage {
|
||||
view: TextBlock::new(vec![Line::from("Hi there")]),
|
||||
},
|
||||
line_count: StdCell2::new(0),
|
||||
});
|
||||
history.entries.push(Entry {
|
||||
cell: HistoryCell::BackgroundEvent {
|
||||
view: TextBlock::new(vec![Line::from("ignored")]),
|
||||
},
|
||||
line_count: StdCell2::new(0),
|
||||
});
|
||||
|
||||
let summary = history.to_compact_summary_text();
|
||||
assert!(summary.contains("user: Hello world"));
|
||||
assert!(summary.contains("assistant: Hi there"));
|
||||
assert!(!summary.contains("ignored"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ pub enum SlashCommand {
|
||||
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
|
||||
// more frequently used commands should be listed first.
|
||||
New,
|
||||
/// Generate a concise summary of the current conversation and replace the
|
||||
/// history with that summary so you can continue with a fresh context.
|
||||
Compact,
|
||||
Diff,
|
||||
Quit,
|
||||
}
|
||||
@@ -22,6 +25,7 @@ impl SlashCommand {
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
SlashCommand::New => "Start a new chat.",
|
||||
SlashCommand::Compact => "Clear conversation history but keep a summary in context.",
|
||||
SlashCommand::Quit => "Exit the application.",
|
||||
SlashCommand::Diff => {
|
||||
"Show git diff of the working directory (including untracked files)"
|
||||
@@ -40,3 +44,18 @@ impl SlashCommand {
|
||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn menu_includes_compact() {
|
||||
let cmds = built_in_slash_commands();
|
||||
let names: Vec<&str> = cmds.iter().map(|(n, _)| *n).collect();
|
||||
assert!(
|
||||
names.contains(&"compact"),
|
||||
"/compact must be present in the slash menu"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user