mirror of
https://github.com/openai/codex.git
synced 2026-04-25 17:01:01 +03:00
Compare commits
12 Commits
rust-v0.14
...
pr1889
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fde2b273d1 | ||
|
|
6cef86f05b | ||
|
|
8262ba58b2 | ||
|
|
081caa5a6b | ||
|
|
4344537742 | ||
|
|
64f2f2eca2 | ||
|
|
ae88b69b09 | ||
|
|
ffe24991b7 | ||
|
|
dc468d563f | ||
|
|
3e8bcf0247 | ||
|
|
cda39e417f | ||
|
|
d642b07fcc |
33
.github/actions/codex/src/process-label.ts
vendored
33
.github/actions/codex/src/process-label.ts
vendored
@@ -91,7 +91,38 @@ async function processLabel(
|
||||
labelConfig: LabelConfig,
|
||||
): Promise<void> {
|
||||
const template = labelConfig.getPromptTemplate();
|
||||
const populatedTemplate = await renderPromptTemplate(template, ctx);
|
||||
|
||||
// If this is a review label, prepend explicit PR-diff scoping guidance to
|
||||
// reduce out-of-scope feedback. Do this before rendering so placeholders in
|
||||
// the guidance (e.g., {CODEX_ACTION_GITHUB_EVENT_PATH}) are substituted.
|
||||
const isReview = label.toLowerCase().includes("review");
|
||||
const reviewScopeGuidance = `
|
||||
PR Diff Scope
|
||||
- Only review changes between the PR's merge-base and head; do not comment on commits or files outside this range.
|
||||
- Derive the base/head SHAs from the event JSON at {CODEX_ACTION_GITHUB_EVENT_PATH}, then compute and use the PR diff for all analysis and comments.
|
||||
|
||||
Commands to determine scope
|
||||
- Resolve SHAs:
|
||||
- BASE_SHA=$(jq -r '.pull_request.base.sha // .pull_request.base.ref' "{CODEX_ACTION_GITHUB_EVENT_PATH}")
|
||||
- HEAD_SHA=$(jq -r '.pull_request.head.sha // .pull_request.head.ref' "{CODEX_ACTION_GITHUB_EVENT_PATH}")
|
||||
- BASE_SHA=$(git rev-parse "$BASE_SHA")
|
||||
- HEAD_SHA=$(git rev-parse "$HEAD_SHA")
|
||||
- Prefer triple-dot (merge-base) semantics for PR diffs:
|
||||
- Changed commits: git log --oneline "$BASE_SHA...$HEAD_SHA"
|
||||
- Changed files: git diff --name-status "$BASE_SHA...$HEAD_SHA"
|
||||
- Review hunks: git diff -U0 "$BASE_SHA...$HEAD_SHA"
|
||||
|
||||
Review rules
|
||||
- Anchor every comment to a file and hunk present in git diff "$BASE_SHA...$HEAD_SHA".
|
||||
- If you mention context outside the diff, label it as "Follow-up (outside this PR scope)" and keep it brief (<=2 bullets).
|
||||
- Do not critique commits or files not reachable in the PR range (merge-base(base, head) → head).
|
||||
`.trim();
|
||||
|
||||
const effectiveTemplate = isReview
|
||||
? `${reviewScopeGuidance}\n\n${template}`
|
||||
: template;
|
||||
|
||||
const populatedTemplate = await renderPromptTemplate(effectiveTemplate, ctx);
|
||||
|
||||
// Always run Codex and post the resulting message as a comment.
|
||||
let commentBody = await runCodex(populatedTemplate, ctx);
|
||||
|
||||
29
codex-rs/common/src/config_summary.rs
Normal file
29
codex-rs/common/src/config_summary.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use codex_core::WireApi;
|
||||
use codex_core::config::Config;
|
||||
|
||||
use crate::sandbox_summary::summarize_sandbox_policy;
|
||||
|
||||
/// Build a list of key/value pairs summarizing the effective configuration.
|
||||
pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> {
|
||||
let mut entries = vec![
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", config.model.clone()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
("approval", config.approval_policy.to_string()),
|
||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses
|
||||
&& config.model_family.supports_reasoning_summaries
|
||||
{
|
||||
entries.push((
|
||||
"reasoning effort",
|
||||
config.model_reasoning_effort.to_string(),
|
||||
));
|
||||
entries.push((
|
||||
"reasoning summaries",
|
||||
config.model_reasoning_summary.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
@@ -23,3 +23,7 @@ mod sandbox_summary;
|
||||
|
||||
#[cfg(feature = "sandbox_summary")]
|
||||
pub use sandbox_summary::summarize_sandbox_policy;
|
||||
|
||||
mod config_summary;
|
||||
|
||||
pub use config_summary::create_config_summary_entries;
|
||||
|
||||
@@ -41,11 +41,9 @@ pub(crate) async fn stream_chat_completions(
|
||||
let full_instructions = prompt.get_full_instructions(model_family);
|
||||
messages.push(json!({"role": "system", "content": full_instructions}));
|
||||
|
||||
if let Some(instr) = &prompt.get_formatted_user_instructions() {
|
||||
messages.push(json!({"role": "user", "content": instr}));
|
||||
}
|
||||
let input = prompt.get_formatted_input();
|
||||
|
||||
for item in &prompt.input {
|
||||
for item in &input {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } => {
|
||||
let mut text = String::new();
|
||||
|
||||
@@ -34,7 +34,6 @@ use crate::error::Result;
|
||||
use crate::flags::CODEX_RS_SSE_FIXTURE;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::WireApi;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::openai_tools::create_tools_json_for_responses_api;
|
||||
use crate::protocol::TokenUsage;
|
||||
@@ -146,15 +145,7 @@ impl ModelClient {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let mut input_with_instructions = Vec::with_capacity(prompt.input.len() + 1);
|
||||
if let Some(ui) = prompt.get_formatted_user_instructions() {
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: ui }],
|
||||
});
|
||||
}
|
||||
input_with_instructions.extend(prompt.input.clone());
|
||||
let input_with_instructions = prompt.get_formatted_input();
|
||||
|
||||
let payload = ResponsesApiRequest {
|
||||
model: &self.config.model,
|
||||
@@ -632,7 +623,7 @@ mod tests {
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let events = collect_events(
|
||||
@@ -692,7 +683,7 @@ mod tests {
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let events = collect_events(&[sse1.as_bytes()], provider).await;
|
||||
@@ -795,7 +786,7 @@ mod tests {
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(1000),
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let out = run_sse(evs, provider).await;
|
||||
|
||||
@@ -2,13 +2,18 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use crate::error::Result;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::openai_tools::OpenAiTool;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::TokenUsage;
|
||||
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::Display;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
@@ -18,10 +23,47 @@ use tokio::sync::mpsc;
|
||||
/// with this content.
|
||||
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
|
||||
|
||||
/// wraps environment context message in a tag for the model to parse more easily.
|
||||
const ENVIRONMENT_CONTEXT_START: &str = "<environment_context>\n\n";
|
||||
const ENVIRONMENT_CONTEXT_END: &str = "\n\n</environment_context>";
|
||||
|
||||
/// wraps user instructions message in a tag for the model to parse more easily.
|
||||
const USER_INSTRUCTIONS_START: &str = "<user_instructions>\n\n";
|
||||
const USER_INSTRUCTIONS_END: &str = "\n\n</user_instructions>";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct EnvironmentContext {
|
||||
pub cwd: PathBuf,
|
||||
pub approval_policy: AskForApproval,
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
}
|
||||
|
||||
impl Display for EnvironmentContext {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
writeln!(
|
||||
f,
|
||||
"Current working directory: {}",
|
||||
self.cwd.to_string_lossy()
|
||||
)?;
|
||||
writeln!(f, "Approval policy: {}", self.approval_policy)?;
|
||||
writeln!(f, "Sandbox policy: {}", self.sandbox_policy)?;
|
||||
|
||||
let network_access = match self.sandbox_policy.clone() {
|
||||
SandboxPolicy::DangerFullAccess => "enabled",
|
||||
SandboxPolicy::ReadOnly => "restricted",
|
||||
SandboxPolicy::WorkspaceWrite { network_access, .. } => {
|
||||
if network_access {
|
||||
"enabled"
|
||||
} else {
|
||||
"restricted"
|
||||
}
|
||||
}
|
||||
};
|
||||
writeln!(f, "Network access: {network_access}")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// API request payload for a single model turn.
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct Prompt {
|
||||
@@ -33,6 +75,10 @@ pub struct Prompt {
|
||||
/// Whether to store response on server side (disable_response_storage = !store).
|
||||
pub store: bool,
|
||||
|
||||
/// A list of key-value pairs that will be added as a developer message
|
||||
/// for the model to use
|
||||
pub environment_context: Option<EnvironmentContext>,
|
||||
|
||||
/// Tools available to the model, including additional tools sourced from
|
||||
/// external MCP servers.
|
||||
pub tools: Vec<OpenAiTool>,
|
||||
@@ -54,11 +100,37 @@ impl Prompt {
|
||||
Cow::Owned(sections.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn get_formatted_user_instructions(&self) -> Option<String> {
|
||||
fn get_formatted_user_instructions(&self) -> Option<String> {
|
||||
self.user_instructions
|
||||
.as_ref()
|
||||
.map(|ui| format!("{USER_INSTRUCTIONS_START}{ui}{USER_INSTRUCTIONS_END}"))
|
||||
}
|
||||
|
||||
fn get_formatted_environment_context(&self) -> Option<String> {
|
||||
self.environment_context
|
||||
.as_ref()
|
||||
.map(|ec| format!("{ENVIRONMENT_CONTEXT_START}{ec}{ENVIRONMENT_CONTEXT_END}"))
|
||||
}
|
||||
|
||||
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
|
||||
let mut input_with_instructions = Vec::with_capacity(self.input.len() + 2);
|
||||
if let Some(ec) = self.get_formatted_environment_context() {
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: ec }],
|
||||
});
|
||||
}
|
||||
if let Some(ui) = self.get_formatted_user_instructions() {
|
||||
input_with_instructions.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: ui }],
|
||||
});
|
||||
}
|
||||
input_with_instructions.extend(self.input.clone());
|
||||
input_with_instructions
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -37,6 +37,7 @@ use crate::apply_patch::convert_apply_patch_to_protocol;
|
||||
use crate::apply_patch::get_writable_roots;
|
||||
use crate::apply_patch::{self};
|
||||
use crate::client::ModelClient;
|
||||
use crate::client_common::EnvironmentContext;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::config::Config;
|
||||
@@ -759,6 +760,10 @@ async fn submission_loop(
|
||||
}
|
||||
};
|
||||
|
||||
// Determine rollout path if available before moving the recorder.
|
||||
let rollout_path: Option<std::path::PathBuf> =
|
||||
rollout_recorder.as_ref().map(|r| r.path().to_path_buf());
|
||||
|
||||
let client = ModelClient::new(
|
||||
config.clone(),
|
||||
auth.clone(),
|
||||
@@ -858,6 +863,7 @@ async fn submission_loop(
|
||||
model,
|
||||
history_log_id,
|
||||
history_entry_count,
|
||||
rollout_path,
|
||||
}),
|
||||
})
|
||||
.chain(mcp_connection_errors.into_iter());
|
||||
@@ -1224,6 +1230,11 @@ async fn run_turn(
|
||||
store: !sess.disable_response_storage,
|
||||
tools,
|
||||
base_instructions_override: sess.base_instructions.clone(),
|
||||
environment_context: Some(EnvironmentContext {
|
||||
cwd: sess.cwd.clone(),
|
||||
approval_policy: sess.approval_policy,
|
||||
sandbox_policy: sess.sandbox_policy.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
let mut retries = 0;
|
||||
@@ -1449,6 +1460,7 @@ async fn run_compact_task(
|
||||
input: turn_input,
|
||||
user_instructions: None,
|
||||
store: !sess.disable_response_storage,
|
||||
environment_context: None,
|
||||
tools: Vec::new(),
|
||||
base_instructions_override: Some(compact_instructions.clone()),
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ pub struct Config {
|
||||
/// who have opted into Zero Data Retention (ZDR).
|
||||
pub disable_response_storage: bool,
|
||||
|
||||
/// User-provided instructions from instructions.md.
|
||||
/// User-provided instructions from AGENTS.md.
|
||||
pub user_instructions: Option<String>,
|
||||
|
||||
/// Base instructions override.
|
||||
@@ -575,7 +575,7 @@ impl Config {
|
||||
None => return None,
|
||||
};
|
||||
|
||||
p.push("instructions.md");
|
||||
p.push("AGENTS.md");
|
||||
std::fs::read_to_string(&p).ok().and_then(|s| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
@@ -842,7 +842,7 @@ disable_response_storage = true
|
||||
request_max_retries: Some(4),
|
||||
stream_max_retries: Some(10),
|
||||
stream_idle_timeout_ms: Some(300_000),
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
let model_provider_map = {
|
||||
let mut model_provider_map = built_in_model_providers();
|
||||
|
||||
@@ -9,7 +9,7 @@ use tokio::time::timeout;
|
||||
/// Timeout for git commands to prevent freezing on large repositories
|
||||
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct GitInfo {
|
||||
/// Current commit hash (SHA)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -9,7 +9,6 @@ use codex_login::AuthMode;
|
||||
use codex_login::CodexAuth;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::env::VarError;
|
||||
use std::time::Duration;
|
||||
@@ -79,7 +78,7 @@ pub struct ModelProviderInfo {
|
||||
|
||||
/// Whether this provider requires some form of standard authentication (API key, ChatGPT token).
|
||||
#[serde(default)]
|
||||
pub requires_auth: bool,
|
||||
pub requires_openai_auth: bool,
|
||||
}
|
||||
|
||||
impl ModelProviderInfo {
|
||||
@@ -87,26 +86,32 @@ impl ModelProviderInfo {
|
||||
/// reqwest Client applying:
|
||||
/// • provider-specific headers (static + env based)
|
||||
/// • Bearer auth header when an API key is available.
|
||||
/// • Auth token for OAuth.
|
||||
///
|
||||
/// When `require_api_key` is true and the provider declares an `env_key`
|
||||
/// but the variable is missing/empty, returns an [`Err`] identical to the
|
||||
/// If the provider declares an `env_key` but the variable is missing/empty, returns an [`Err`] identical to the
|
||||
/// one produced by [`ModelProviderInfo::api_key`].
|
||||
pub async fn create_request_builder<'a>(
|
||||
&'a self,
|
||||
client: &'a reqwest::Client,
|
||||
auth: &Option<CodexAuth>,
|
||||
) -> crate::error::Result<reqwest::RequestBuilder> {
|
||||
let auth: Cow<'_, Option<CodexAuth>> = if auth.is_some() {
|
||||
Cow::Borrowed(auth)
|
||||
} else {
|
||||
Cow::Owned(self.get_fallback_auth()?)
|
||||
let effective_auth = match self.api_key() {
|
||||
Ok(Some(key)) => Some(CodexAuth::from_api_key(key)),
|
||||
Ok(None) => auth.clone(),
|
||||
Err(err) => {
|
||||
if auth.is_some() {
|
||||
auth.clone()
|
||||
} else {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let url = self.get_full_url(&auth);
|
||||
let url = self.get_full_url(&effective_auth);
|
||||
|
||||
let mut builder = client.post(url);
|
||||
|
||||
if let Some(auth) = auth.as_ref() {
|
||||
if let Some(auth) = effective_auth.as_ref() {
|
||||
builder = builder.bearer_auth(auth.get_token().await?);
|
||||
}
|
||||
|
||||
@@ -216,14 +221,6 @@ impl ModelProviderInfo {
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
fn get_fallback_auth(&self) -> crate::error::Result<Option<CodexAuth>> {
|
||||
let api_key = self.api_key()?;
|
||||
if let Some(api_key) = api_key {
|
||||
return Ok(Some(CodexAuth::from_api_key(api_key)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_OLLAMA_PORT: u32 = 11434;
|
||||
@@ -275,7 +272,7 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_auth: true,
|
||||
requires_openai_auth: true,
|
||||
},
|
||||
),
|
||||
(BUILT_IN_OSS_MODEL_PROVIDER_ID, create_oss_provider()),
|
||||
@@ -319,7 +316,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo {
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +344,7 @@ base_url = "http://localhost:11434/v1"
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -376,7 +373,7 @@ query_params = { api-version = "2025-04-01-preview" }
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
@@ -408,7 +405,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
|
||||
@@ -159,7 +159,8 @@ pub enum AskForApproval {
|
||||
}
|
||||
|
||||
/// Determines execution restrictions for model shell commands.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)]
|
||||
#[strum(serialize_all = "kebab-case")]
|
||||
#[serde(tag = "mode", rename_all = "kebab-case")]
|
||||
pub enum SandboxPolicy {
|
||||
/// No restrictions whatsoever. Use with caution.
|
||||
@@ -646,6 +647,10 @@ pub struct SessionConfiguredEvent {
|
||||
|
||||
/// Current number of entries in the history log.
|
||||
pub history_entry_count: usize,
|
||||
|
||||
/// Absolute path to the rollout file for this session, if recording is enabled.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
/// User's decision in response to an ExecApprovalRequest.
|
||||
@@ -708,6 +713,7 @@ mod tests {
|
||||
model: "codex-mini-latest".to_string(),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
rollout_path: None,
|
||||
}),
|
||||
};
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
|
||||
@@ -66,6 +66,8 @@ pub struct SavedSession {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RolloutRecorder {
|
||||
tx: Sender<RolloutCmd>,
|
||||
/// Absolute path to the rollout file for this session.
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
enum RolloutCmd {
|
||||
@@ -87,6 +89,7 @@ impl RolloutRecorder {
|
||||
file,
|
||||
session_id,
|
||||
timestamp,
|
||||
path,
|
||||
} = create_log_file(config, uuid)?;
|
||||
|
||||
let timestamp_format: &[FormatItem] = format_description!(
|
||||
@@ -118,7 +121,7 @@ impl RolloutRecorder {
|
||||
cwd,
|
||||
));
|
||||
|
||||
Ok(Self { tx })
|
||||
Ok(Self { tx, path })
|
||||
}
|
||||
|
||||
pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> {
|
||||
@@ -223,7 +226,13 @@ impl RolloutRecorder {
|
||||
cwd,
|
||||
));
|
||||
info!("Resumed rollout successfully from {path:?}");
|
||||
Ok((Self { tx }, saved))
|
||||
Ok((
|
||||
Self {
|
||||
tx,
|
||||
path: path.to_path_buf(),
|
||||
},
|
||||
saved,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> std::io::Result<()> {
|
||||
@@ -240,6 +249,11 @@ impl RolloutRecorder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the absolute path to the rollout file recorded by this session.
|
||||
pub fn path(&self) -> &std::path::Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
struct LogFileInfo {
|
||||
@@ -251,6 +265,9 @@ struct LogFileInfo {
|
||||
|
||||
/// Timestamp for the start of the session.
|
||||
timestamp: OffsetDateTime,
|
||||
|
||||
/// Absolute path to the rollout file on disk.
|
||||
path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
|
||||
@@ -284,6 +301,7 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
|
||||
file,
|
||||
session_id,
|
||||
timestamp,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::expect_used)]
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
@@ -32,6 +34,32 @@ fn sse_completed(id: &str) -> String {
|
||||
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
|
||||
}
|
||||
|
||||
fn assert_message_role(request_body: &serde_json::Value, role: &str) {
|
||||
assert_eq!(request_body["role"].as_str().unwrap(), role);
|
||||
}
|
||||
|
||||
fn assert_message_starts_with(request_body: &serde_json::Value, text: &str) {
|
||||
let content = request_body["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("invalid message content");
|
||||
|
||||
assert!(
|
||||
content.starts_with(text),
|
||||
"expected message content '{content}' to start with '{text}'"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_message_ends_with(request_body: &serde_json::Value, text: &str) {
|
||||
let content = request_body["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("invalid message content");
|
||||
|
||||
assert!(
|
||||
content.ends_with(text),
|
||||
"expected message content '{content}' to end with '{text}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn includes_session_id_and_model_headers_in_request() {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
@@ -371,19 +399,12 @@ async fn includes_user_instructions_message_in_request() {
|
||||
.unwrap()
|
||||
.contains("be nice")
|
||||
);
|
||||
assert_eq!(request_body["input"][0]["role"], "user");
|
||||
assert!(
|
||||
request_body["input"][0]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("<user_instructions>\n\nbe nice")
|
||||
);
|
||||
assert!(
|
||||
request_body["input"][0]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("</user_instructions>")
|
||||
);
|
||||
assert_message_role(&request_body["input"][0], "user");
|
||||
assert_message_starts_with(&request_body["input"][0], "<environment_context>\n\n");
|
||||
assert_message_ends_with(&request_body["input"][0], "</environment_context>");
|
||||
assert_message_role(&request_body["input"][1], "user");
|
||||
assert_message_starts_with(&request_body["input"][1], "<user_instructions>\n\n");
|
||||
assert_message_ends_with(&request_body["input"][1], "</user_instructions>");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -437,7 +458,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
// Init session
|
||||
@@ -460,6 +481,86 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn env_var_overrides_loaded_auth() {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" };
|
||||
|
||||
// Mock server
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// First request – must NOT include `previous_response_id`.
|
||||
let first = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
|
||||
// Expect POST to /openai/responses with api-version query param
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/openai/responses"))
|
||||
.and(query_param("api-version", "2025-04-01-preview"))
|
||||
.and(header_regex("Custom-Header", "Value"))
|
||||
.and(header_regex(
|
||||
"Authorization",
|
||||
format!(
|
||||
"Bearer {}",
|
||||
std::env::var(existing_env_var_with_random_value).unwrap()
|
||||
)
|
||||
.as_str(),
|
||||
))
|
||||
.respond_with(first)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = ModelProviderInfo {
|
||||
name: "custom".to_string(),
|
||||
base_url: Some(format!("{}/openai", server.uri())),
|
||||
// Reuse the existing environment variable to avoid using unsafe code
|
||||
env_key: Some(existing_env_var_with_random_value.to_string()),
|
||||
query_params: Some(std::collections::HashMap::from([(
|
||||
"api-version".to_string(),
|
||||
"2025-04-01-preview".to_string(),
|
||||
)])),
|
||||
env_key_instructions: None,
|
||||
wire_api: WireApi::Responses,
|
||||
http_headers: Some(std::collections::HashMap::from([(
|
||||
"Custom-Header".to_string(),
|
||||
"Value".to_string(),
|
||||
)])),
|
||||
env_http_headers: None,
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
// Init session
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = provider;
|
||||
|
||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
let CodexSpawnOk { codex, .. } = Codex::spawn(
|
||||
config,
|
||||
Some(auth_from_token("Default Access Token".to_string())),
|
||||
ctrl_c.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
}
|
||||
|
||||
fn auth_from_token(id_token: String) -> CodexAuth {
|
||||
CodexAuth::new(
|
||||
None,
|
||||
|
||||
@@ -90,7 +90,7 @@ async fn retries_on_early_close() {
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(1),
|
||||
stream_idle_timeout_ms: Some(2000),
|
||||
requires_auth: false,
|
||||
requires_openai_auth: false,
|
||||
};
|
||||
|
||||
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::path::Path;
|
||||
|
||||
use codex_common::summarize_sandbox_policy;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
|
||||
@@ -19,30 +17,6 @@ pub(crate) trait EventProcessor {
|
||||
fn process_event(&mut self, event: Event) -> CodexStatus;
|
||||
}
|
||||
|
||||
pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> {
|
||||
let mut entries = vec![
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", config.model.clone()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
("approval", config.approval_policy.to_string()),
|
||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses
|
||||
&& config.model_family.supports_reasoning_summaries
|
||||
{
|
||||
entries.push((
|
||||
"reasoning effort",
|
||||
config.model_reasoning_effort.to_string(),
|
||||
));
|
||||
entries.push((
|
||||
"reasoning summaries",
|
||||
config.model_reasoning_summary.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
pub(crate) fn handle_last_message(last_agent_message: Option<&str>, output_file: &Path) {
|
||||
let message = last_agent_message.unwrap_or_default();
|
||||
write_last_message_file(message, Some(output_file));
|
||||
|
||||
@@ -33,8 +33,8 @@ use std::time::Instant;
|
||||
|
||||
use crate::event_processor::CodexStatus;
|
||||
use crate::event_processor::EventProcessor;
|
||||
use crate::event_processor::create_config_summary_entries;
|
||||
use crate::event_processor::handle_last_message;
|
||||
use codex_common::create_config_summary_entries;
|
||||
|
||||
/// This should be configurable. When used in CI, users may not want to impose
|
||||
/// a limit so they can see the full transcript.
|
||||
@@ -490,10 +490,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
}
|
||||
EventMsg::SessionConfigured(session_configured_event) => {
|
||||
let SessionConfiguredEvent {
|
||||
session_id,
|
||||
model,
|
||||
history_log_id: _,
|
||||
history_entry_count: _,
|
||||
session_id, model, ..
|
||||
} = session_configured_event;
|
||||
|
||||
ts_println!(
|
||||
|
||||
@@ -9,8 +9,8 @@ use serde_json::json;
|
||||
|
||||
use crate::event_processor::CodexStatus;
|
||||
use crate::event_processor::EventProcessor;
|
||||
use crate::event_processor::create_config_summary_entries;
|
||||
use crate::event_processor::handle_last_message;
|
||||
use codex_common::create_config_summary_entries;
|
||||
|
||||
pub(crate) struct EventProcessorWithJsonOutput {
|
||||
last_message_path: Option<PathBuf>,
|
||||
|
||||
@@ -908,6 +908,7 @@ mod tests {
|
||||
model: "codex-mini-latest".into(),
|
||||
history_log_id: 42,
|
||||
history_entry_count: 3,
|
||||
rollout_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ mod tests {
|
||||
model: "gpt-4o".to_string(),
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
rollout_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -284,6 +285,7 @@ mod tests {
|
||||
model: "gpt-4o".to_string(),
|
||||
history_log_id: 1,
|
||||
history_entry_count: 1000,
|
||||
rollout_path: None,
|
||||
};
|
||||
let event = Event {
|
||||
id: "1".to_string(),
|
||||
|
||||
@@ -18,7 +18,7 @@ use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn test_send_message_success() {
|
||||
// Spin up a mock completions server that immediately ends the Codex turn.
|
||||
// Two Codex turns hit the mock model (session start + send-user-message). Provide two SSE responses.
|
||||
@@ -99,13 +99,13 @@ async fn test_send_message_success() {
|
||||
response
|
||||
);
|
||||
// wait for the server to hear the user message
|
||||
sleep(Duration::from_secs(1));
|
||||
sleep(Duration::from_secs(5));
|
||||
|
||||
// Ensure the server and tempdir live until end of test
|
||||
drop(server);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[tokio::test]
|
||||
async fn test_send_message_session_not_found() {
|
||||
// Start MCP without creating a Codex session
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
|
||||
40
codex-rs/tui/prompt_for_init_command.md
Normal file
40
codex-rs/tui/prompt_for_init_command.md
Normal file
@@ -0,0 +1,40 @@
|
||||
Generate a file named AGENTS.md that serves as a contributor guide for this repository.
|
||||
Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section.
|
||||
Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project.
|
||||
|
||||
Document Requirements
|
||||
|
||||
- Title the document "Repository Guidelines".
|
||||
- Use Markdown headings (#, ##, etc.) for structure.
|
||||
- Keep the document concise. 200-400 words is optimal.
|
||||
- Keep explanations short, direct, and specific to this repository.
|
||||
- Provide examples where helpful (commands, directory paths, naming patterns).
|
||||
- Maintain a professional, instructional tone.
|
||||
|
||||
Recommended Sections
|
||||
|
||||
Project Structure & Module Organization
|
||||
|
||||
- Outline the project structure, including where the source code, tests, and assets are located.
|
||||
|
||||
Build, Test, and Development Commands
|
||||
|
||||
- List key commands for building, testing, and running locally (e.g., npm test, make build).
|
||||
- Briefly explain what each command does.
|
||||
|
||||
Coding Style & Naming Conventions
|
||||
|
||||
- Specify indentation rules, language-specific style preferences, and naming patterns.
|
||||
- Include any formatting or linting tools used.
|
||||
|
||||
Testing Guidelines
|
||||
|
||||
- Identify testing frameworks and coverage requirements.
|
||||
- State test naming conventions and how to run tests.
|
||||
|
||||
Commit & Pull Request Guidelines
|
||||
|
||||
- Summarize commit message conventions found in the project’s Git history.
|
||||
- Outline pull request requirements (descriptions, linked issues, screenshots, etc.).
|
||||
|
||||
(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions.
|
||||
@@ -300,6 +300,13 @@ impl App<'_> {
|
||||
self.app_state = AppState::Chat { widget: new_widget };
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
// Guard: do not run if a task is active.
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
|
||||
widget.submit_text_message(INIT_PROMPT.to_string());
|
||||
}
|
||||
}
|
||||
SlashCommand::Compact => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.clear_token_usage();
|
||||
@@ -330,6 +337,11 @@ impl App<'_> {
|
||||
widget.add_diff_output(text);
|
||||
}
|
||||
}
|
||||
SlashCommand::Status => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.add_status_output();
|
||||
}
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => {
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -729,6 +729,7 @@ impl WidgetRef for &ChatComposer {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::AppEventSender;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::InputResult;
|
||||
@@ -1004,6 +1005,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_init_dispatches_command_and_does_not_submit_literal_text() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use std::sync::mpsc::TryRecvError;
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(true, sender, false);
|
||||
|
||||
// Type the slash command.
|
||||
for ch in [
|
||||
'/', 'i', 'n', 'i', 't', // "/init"
|
||||
] {
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
// Press Enter to dispatch the selected command.
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// When a slash command is dispatched, the composer should not submit
|
||||
// literal text and should clear its textarea.
|
||||
match result {
|
||||
InputResult::None => {}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
}
|
||||
}
|
||||
assert!(composer.textarea.is_empty(), "composer should be cleared");
|
||||
|
||||
// Verify a DispatchCommand event for the "init" command was sent.
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::DispatchCommand(cmd)) => {
|
||||
assert_eq!(cmd.command(), "init");
|
||||
}
|
||||
Ok(_other) => panic!("unexpected app event"),
|
||||
Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/init'"),
|
||||
Err(TryRecvError::Disconnected) => panic!("app event channel disconnected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_pastes_submission() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -188,3 +188,38 @@ impl WidgetRef for CommandPopup {
|
||||
table.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_includes_init_when_typing_prefix() {
|
||||
let mut popup = CommandPopup::new();
|
||||
// Simulate the composer line starting with '/in' so the popup filters
|
||||
// matching commands by prefix.
|
||||
popup.on_composer_text_change("/in".to_string());
|
||||
|
||||
// Access the filtered list via the selected command and ensure that
|
||||
// one of the matches is the new "init" command.
|
||||
let matches = popup.filtered_commands();
|
||||
assert!(
|
||||
matches.iter().any(|cmd| cmd.command() == "init"),
|
||||
"expected '/init' to appear among filtered commands"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_init_by_exact_match() {
|
||||
let mut popup = CommandPopup::new();
|
||||
popup.on_composer_text_change("/init".to_string());
|
||||
|
||||
// When an exact match exists, the selected command should be that
|
||||
// command by default.
|
||||
let selected = popup.selected_command();
|
||||
match selected {
|
||||
Some(cmd) => assert_eq!(cmd.command(), "init"),
|
||||
None => panic!("expected a selected command for exact match"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ use codex_core::protocol::TurnDiffEvent;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
@@ -62,6 +64,7 @@ pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
bottom_pane: BottomPane<'a>,
|
||||
active_history_cell: Option<HistoryCell>,
|
||||
config: Config,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_usage: TokenUsage,
|
||||
@@ -76,6 +79,8 @@ pub(crate) struct ChatWidget<'a> {
|
||||
current_stream: Option<StreamKind>,
|
||||
stream_header_emitted: bool,
|
||||
live_max_rows: u16,
|
||||
/// Absolute path to the rollout file for the active session (if available).
|
||||
rollout_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -107,6 +112,17 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 2] {
|
||||
Layout::vertical([
|
||||
Constraint::Max(
|
||||
self.active_history_cell
|
||||
.as_ref()
|
||||
.map_or(0, |c| c.desired_height(area.width)),
|
||||
),
|
||||
Constraint::Min(self.bottom_pane.desired_height(area.width)),
|
||||
])
|
||||
.areas(area)
|
||||
}
|
||||
fn emit_stream_header(&mut self, kind: StreamKind) {
|
||||
use ratatui::text::Line as RLine;
|
||||
if self.stream_header_emitted {
|
||||
@@ -178,6 +194,7 @@ impl ChatWidget<'_> {
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported,
|
||||
}),
|
||||
active_history_cell: None,
|
||||
config,
|
||||
initial_user_message: create_initial_user_message(
|
||||
initial_prompt.unwrap_or_default(),
|
||||
@@ -192,11 +209,16 @@ impl ChatWidget<'_> {
|
||||
current_stream: None,
|
||||
stream_header_emitted: false,
|
||||
live_max_rows: 3,
|
||||
rollout_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
self.bottom_pane.desired_height(width)
|
||||
+ self
|
||||
.active_history_cell
|
||||
.as_ref()
|
||||
.map_or(0, |c| c.desired_height(width))
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
@@ -264,6 +286,8 @@ impl ChatWidget<'_> {
|
||||
EventMsg::SessionConfigured(event) => {
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
// Record rollout path for status reporting.
|
||||
self.rollout_path = event.rollout_path.clone();
|
||||
// Record session information at the top of the conversation.
|
||||
self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
|
||||
|
||||
@@ -425,9 +449,11 @@ impl ChatWidget<'_> {
|
||||
cwd: cwd.clone(),
|
||||
},
|
||||
);
|
||||
self.add_to_history(HistoryCell::new_active_exec_command(command));
|
||||
self.active_history_cell = Some(HistoryCell::new_active_exec_command(command));
|
||||
}
|
||||
EventMsg::ExecCommandOutputDelta(_) => {
|
||||
// TODO
|
||||
}
|
||||
EventMsg::ExecCommandOutputDelta(_) => {}
|
||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id: _,
|
||||
auto_approved,
|
||||
@@ -438,8 +464,12 @@ impl ChatWidget<'_> {
|
||||
changes,
|
||||
));
|
||||
}
|
||||
EventMsg::PatchApplyEnd(patch_apply_end_event) => {
|
||||
self.add_to_history(HistoryCell::new_patch_end_event(patch_apply_end_event));
|
||||
EventMsg::PatchApplyEnd(event) => {
|
||||
self.add_to_history(HistoryCell::new_patch_apply_end(
|
||||
event.stdout,
|
||||
event.stderr,
|
||||
event.success,
|
||||
));
|
||||
}
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
@@ -450,6 +480,7 @@ impl ChatWidget<'_> {
|
||||
}) => {
|
||||
// Compute summary before moving stdout into the history cell.
|
||||
let cmd = self.running_commands.remove(&call_id);
|
||||
self.active_history_cell = None;
|
||||
self.add_to_history(HistoryCell::new_completed_exec_command(
|
||||
cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
|
||||
CommandOutput {
|
||||
@@ -522,6 +553,14 @@ impl ChatWidget<'_> {
|
||||
self.add_to_history(HistoryCell::new_diff_output(diff_output.clone()));
|
||||
}
|
||||
|
||||
pub(crate) fn add_status_output(&mut self) {
|
||||
self.add_to_history(HistoryCell::new_status_output(
|
||||
&self.config,
|
||||
&self.token_usage,
|
||||
self.rollout_path.as_ref(),
|
||||
));
|
||||
}
|
||||
|
||||
/// Forward file-search results to the bottom pane.
|
||||
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
self.bottom_pane.on_file_search_result(query, matches);
|
||||
@@ -536,6 +575,7 @@ impl ChatWidget<'_> {
|
||||
CancellationEvent::Ignored => {}
|
||||
}
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.active_history_cell = None;
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.submit_op(Op::Interrupt);
|
||||
self.bottom_pane.set_task_running(false);
|
||||
@@ -568,6 +608,16 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Programmatically submit a user text message as if typed in the
|
||||
/// composer. The text will be added to conversation history and sent to
|
||||
/// the agent.
|
||||
pub(crate) fn submit_text_message(&mut self, text: String) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.submit_user_message(text.into());
|
||||
}
|
||||
|
||||
pub(crate) fn token_usage(&self) -> &TokenUsage {
|
||||
&self.token_usage
|
||||
}
|
||||
@@ -579,7 +629,8 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.bottom_pane.cursor_pos(area)
|
||||
let [_, bottom_pane_area] = self.layout_areas(area);
|
||||
self.bottom_pane.cursor_pos(bottom_pane_area)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,10 +734,11 @@ impl ChatWidget<'_> {
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// 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);
|
||||
let [active_cell_area, bottom_pane_area] = self.layout_areas(area);
|
||||
(&self.bottom_pane).render(bottom_pane_area, buf);
|
||||
if let Some(cell) = &self.active_history_cell {
|
||||
cell.render_ref(active_cell_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::text_block::TextBlock;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use base64::Engine;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_common::create_config_summary_entries;
|
||||
use codex_common::elapsed::format_duration;
|
||||
use codex_common::summarize_sandbox_policy;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::plan_tool::PlanItemArg;
|
||||
use codex_core::plan_tool::StepStatus;
|
||||
use codex_core::plan_tool::UpdatePlanArgs;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::PatchApplyEndEvent;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use image::DynamicImage;
|
||||
use image::ImageReader;
|
||||
use mcp_types::EmbeddedResourceResource;
|
||||
@@ -24,6 +25,9 @@ use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line as RtLine;
|
||||
use ratatui::text::Span as RtSpan;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
@@ -62,35 +66,23 @@ fn line_to_static(line: &Line) -> Line<'static> {
|
||||
/// scrollable list.
|
||||
pub(crate) enum HistoryCell {
|
||||
/// Welcome message.
|
||||
WelcomeMessage {
|
||||
view: TextBlock,
|
||||
},
|
||||
WelcomeMessage { view: TextBlock },
|
||||
|
||||
/// Message from the user.
|
||||
UserPrompt {
|
||||
view: TextBlock,
|
||||
},
|
||||
UserPrompt { view: TextBlock },
|
||||
|
||||
// AgentMessage and AgentReasoning variants were unused and have been removed.
|
||||
/// An exec tool call that has not finished yet.
|
||||
ActiveExecCommand {
|
||||
view: TextBlock,
|
||||
},
|
||||
ActiveExecCommand { view: TextBlock },
|
||||
|
||||
/// Completed exec tool call.
|
||||
CompletedExecCommand {
|
||||
view: TextBlock,
|
||||
},
|
||||
CompletedExecCommand { view: TextBlock },
|
||||
|
||||
/// An MCP tool call that has not finished yet.
|
||||
ActiveMcpToolCall {
|
||||
view: TextBlock,
|
||||
},
|
||||
ActiveMcpToolCall { view: TextBlock },
|
||||
|
||||
/// Completed MCP tool call where we show the result serialized as JSON.
|
||||
CompletedMcpToolCall {
|
||||
view: TextBlock,
|
||||
},
|
||||
CompletedMcpToolCall { view: TextBlock },
|
||||
|
||||
/// Completed MCP tool call where the result is an image.
|
||||
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
|
||||
@@ -100,46 +92,34 @@ pub(crate) enum HistoryCell {
|
||||
// resized version avoids doing the potentially expensive rescale twice
|
||||
// because the scroll-view first calls `height()` for layouting and then
|
||||
// `render_window()` for painting.
|
||||
CompletedMcpToolCallWithImageOutput {
|
||||
_image: DynamicImage,
|
||||
},
|
||||
CompletedMcpToolCallWithImageOutput { _image: DynamicImage },
|
||||
|
||||
/// Background event.
|
||||
BackgroundEvent {
|
||||
view: TextBlock,
|
||||
},
|
||||
BackgroundEvent { view: TextBlock },
|
||||
|
||||
/// Output from the `/diff` command.
|
||||
GitDiffOutput {
|
||||
view: TextBlock,
|
||||
},
|
||||
GitDiffOutput { view: TextBlock },
|
||||
|
||||
/// Output from the `/status` command.
|
||||
StatusOutput { view: TextBlock },
|
||||
|
||||
/// Error event from the backend.
|
||||
ErrorEvent {
|
||||
view: TextBlock,
|
||||
},
|
||||
ErrorEvent { view: TextBlock },
|
||||
|
||||
/// Info describing the newly-initialized session.
|
||||
SessionInfo {
|
||||
view: TextBlock,
|
||||
},
|
||||
SessionInfo { view: TextBlock },
|
||||
|
||||
/// A pending code patch that is awaiting user approval. Mirrors the
|
||||
/// behaviour of `ActiveExecCommand` so the user sees *what* patch the
|
||||
/// model wants to apply before being prompted to approve or deny it.
|
||||
PendingPatch {
|
||||
view: TextBlock,
|
||||
},
|
||||
|
||||
PatchEventEnd {
|
||||
view: TextBlock,
|
||||
},
|
||||
PendingPatch { view: TextBlock },
|
||||
|
||||
/// A human‑friendly rendering of the model's current plan and step
|
||||
/// statuses provided via the `update_plan` tool.
|
||||
PlanUpdate {
|
||||
view: TextBlock,
|
||||
},
|
||||
PlanUpdate { view: TextBlock },
|
||||
|
||||
/// Result of applying a patch (success or failure) with optional output.
|
||||
PatchApplyResult { view: TextBlock },
|
||||
}
|
||||
|
||||
const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
@@ -154,13 +134,14 @@ impl HistoryCell {
|
||||
| HistoryCell::UserPrompt { view }
|
||||
| HistoryCell::BackgroundEvent { view }
|
||||
| HistoryCell::GitDiffOutput { view }
|
||||
| HistoryCell::StatusOutput { view }
|
||||
| HistoryCell::ErrorEvent { view }
|
||||
| HistoryCell::SessionInfo { view }
|
||||
| HistoryCell::CompletedExecCommand { view }
|
||||
| HistoryCell::CompletedMcpToolCall { view }
|
||||
| HistoryCell::PendingPatch { view }
|
||||
| HistoryCell::PatchEventEnd { view }
|
||||
| HistoryCell::PlanUpdate { view }
|
||||
| HistoryCell::PatchApplyResult { view }
|
||||
| HistoryCell::ActiveExecCommand { view, .. }
|
||||
| HistoryCell::ActiveMcpToolCall { view, .. } => {
|
||||
view.lines.iter().map(line_to_static).collect()
|
||||
@@ -171,58 +152,46 @@ impl HistoryCell {
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(Text::from(self.plain_lines()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) fn new_session_info(
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
) -> Self {
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
session_id,
|
||||
history_log_id: _,
|
||||
history_entry_count: _,
|
||||
} = event;
|
||||
let SessionConfiguredEvent { model, .. } = event;
|
||||
if is_first_event {
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
let cwd_str = match relativize_to_home(&config.cwd) {
|
||||
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
|
||||
Some(_) => "~".to_string(),
|
||||
None => config.cwd.display().to_string(),
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
Line::from(vec![
|
||||
"OpenAI ".into(),
|
||||
"Codex".bold(),
|
||||
format!(" v{VERSION}").into(),
|
||||
" (research preview)".dim(),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
"codex session".magenta().bold(),
|
||||
" ".into(),
|
||||
session_id.to_string().dim(),
|
||||
Span::raw(">_ ").dim(),
|
||||
Span::styled(
|
||||
"You are using OpenAI Codex in",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" {cwd_str}")).dim(),
|
||||
]),
|
||||
Line::from("".dim()),
|
||||
Line::from(" Try one of the following commands to get started:".dim()),
|
||||
Line::from("".dim()),
|
||||
Line::from(format!(" 1. /init - {}", SlashCommand::Init.description()).dim()),
|
||||
Line::from(format!(" 2. /status - {}", SlashCommand::Status.description()).dim()),
|
||||
Line::from(format!(" 3. /compact - {}", SlashCommand::Compact.description()).dim()),
|
||||
Line::from(format!(" 4. /new - {}", SlashCommand::New.description()).dim()),
|
||||
Line::from("".dim()),
|
||||
];
|
||||
|
||||
let mut entries = vec![
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", config.model.clone()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
("approval", config.approval_policy.to_string()),
|
||||
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses
|
||||
&& config.model_family.supports_reasoning_summaries
|
||||
{
|
||||
entries.push((
|
||||
"reasoning effort",
|
||||
config.model_reasoning_effort.to_string(),
|
||||
));
|
||||
entries.push((
|
||||
"reasoning summaries",
|
||||
config.model_reasoning_summary.to_string(),
|
||||
));
|
||||
}
|
||||
for (key, value) in entries {
|
||||
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
HistoryCell::WelcomeMessage {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
@@ -476,6 +445,61 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_status_output(
|
||||
config: &Config,
|
||||
usage: &TokenUsage,
|
||||
rollout_path: Option<&std::path::PathBuf>,
|
||||
) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("/status".magenta()));
|
||||
|
||||
// Config
|
||||
for (key, value) in create_config_summary_entries(config) {
|
||||
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
|
||||
}
|
||||
|
||||
// Rollout file path (if available)
|
||||
if let Some(p) = rollout_path {
|
||||
lines.push(Line::from(vec![
|
||||
"rollout: ".bold(),
|
||||
p.display().to_string().into(),
|
||||
]));
|
||||
}
|
||||
|
||||
// Token usage
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("token usage".bold()));
|
||||
lines.push(Line::from(vec![
|
||||
" input: ".bold(),
|
||||
usage.input_tokens.to_string().into(),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" cached input: ".bold(),
|
||||
usage.cached_input_tokens.unwrap_or(0).to_string().into(),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" output: ".bold(),
|
||||
usage.output_tokens.to_string().into(),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" reasoning output: ".bold(),
|
||||
usage
|
||||
.reasoning_output_tokens
|
||||
.unwrap_or(0)
|
||||
.to_string()
|
||||
.into(),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
" total: ".bold(),
|
||||
usage.total_tokens.to_string().into(),
|
||||
]));
|
||||
|
||||
lines.push(Line::from(""));
|
||||
HistoryCell::StatusOutput {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_error_event(message: String) -> Self {
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
vec!["ERROR: ".red().bold(), message.into()].into(),
|
||||
@@ -582,7 +606,10 @@ impl HistoryCell {
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: false,
|
||||
} => {
|
||||
let lines = vec![Line::from("patch applied".magenta().bold())];
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
Line::from("applying patch".magenta().bold()),
|
||||
Line::from(""),
|
||||
];
|
||||
return Self::PendingPatch {
|
||||
view: TextBlock::new(lines),
|
||||
};
|
||||
@@ -631,29 +658,63 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_patch_end_event(patch_apply_end_event: PatchApplyEndEvent) -> Self {
|
||||
let PatchApplyEndEvent {
|
||||
call_id: _,
|
||||
stdout: _,
|
||||
stderr,
|
||||
success,
|
||||
} = patch_apply_end_event;
|
||||
pub(crate) fn new_patch_apply_end(stdout: String, stderr: String, success: bool) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
let mut lines: Vec<Line<'static>> = if success {
|
||||
vec![Line::from("patch applied successfully".italic())]
|
||||
let status = if success {
|
||||
RtSpan::styled("patch applied", Style::default().fg(Color::Green))
|
||||
} else {
|
||||
let mut lines = vec![Line::from("patch failed".italic())];
|
||||
lines.extend(stderr.lines().map(|l| Line::from(l.to_string())));
|
||||
lines
|
||||
RtSpan::styled(
|
||||
"patch failed",
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
)
|
||||
};
|
||||
lines.push(RtLine::from(vec![
|
||||
"patch".magenta().bold(),
|
||||
" ".into(),
|
||||
status,
|
||||
]));
|
||||
|
||||
let src = if success {
|
||||
if stdout.trim().is_empty() {
|
||||
&stderr
|
||||
} else {
|
||||
&stdout
|
||||
}
|
||||
} else if stderr.trim().is_empty() {
|
||||
&stdout
|
||||
} else {
|
||||
&stderr
|
||||
};
|
||||
|
||||
if !src.trim().is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
let mut iter = src.lines();
|
||||
for raw in iter.by_ref().take(TOOL_CALL_MAX_LINES) {
|
||||
lines.push(ansi_escape_line(raw).dim());
|
||||
}
|
||||
let remaining = iter.count();
|
||||
if remaining > 0 {
|
||||
lines.push(Line::from(format!("... {remaining} additional lines")).dim());
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::PatchEventEnd {
|
||||
HistoryCell::PatchApplyResult {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &HistoryCell {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new(Text::from(self.plain_lines()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
|
||||
// Build a concise, human‑readable summary list similar to the
|
||||
// `git status` short format so the user can reason about the
|
||||
|
||||
@@ -287,7 +287,7 @@ fn restore() {
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn should_show_login_screen(config: &Config) -> bool {
|
||||
if config.model_provider.requires_auth {
|
||||
if config.model_provider.requires_openai_auth {
|
||||
// Reading the OpenAI API key is an async operation because it may need
|
||||
// to refresh the token. Block on it.
|
||||
let codex_home = config.codex_home.clone();
|
||||
|
||||
@@ -13,8 +13,10 @@ 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,
|
||||
Init,
|
||||
Compact,
|
||||
Diff,
|
||||
Status,
|
||||
Quit,
|
||||
#[cfg(debug_assertions)]
|
||||
TestApproval,
|
||||
@@ -24,12 +26,12 @@ impl SlashCommand {
|
||||
/// User-visible description shown in the popup.
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
SlashCommand::New => "Start a new chat.",
|
||||
SlashCommand::Compact => "Compact the chat history.",
|
||||
SlashCommand::Quit => "Exit the application.",
|
||||
SlashCommand::Diff => {
|
||||
"Show git diff of the working directory (including untracked files)"
|
||||
}
|
||||
SlashCommand::New => "Start a new chat",
|
||||
SlashCommand::Init => "Create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "Compact the chat history",
|
||||
SlashCommand::Quit => "Exit the application",
|
||||
SlashCommand::Diff => "Show git diff (including untracked files)",
|
||||
SlashCommand::Status => "Show current session configuration and token usage",
|
||||
#[cfg(debug_assertions)]
|
||||
SlashCommand::TestApproval => "Test approval request",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user