mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
Merge branch 'main' into jif/shell-snapshot-stable
This commit is contained in:
Regular → Executable
Generated
+29
-15
@@ -1888,7 +1888,9 @@ dependencies = [
|
||||
name = "codex-utils-absolute-path"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"path-absolutize",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3455,7 +3457,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3505,7 +3507,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -5342,7 +5344,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5379,7 +5381,7 @@ dependencies = [
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -5664,7 +5666,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7147,14 +7149,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
|
||||
version = "0.28.0"
|
||||
source = "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7553,20 +7559,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
|
||||
version = "0.28.0"
|
||||
source = "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.3.1",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"rand 0.9.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
"thiserror 2.0.17",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
@@ -8037,6 +8042,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.2"
|
||||
|
||||
+6
-2
@@ -182,7 +182,7 @@ ratatui-core = "0.1.0"
|
||||
ratatui-macros = "0.6.0"
|
||||
regex = "1.12.2"
|
||||
regex-lite = "0.1.8"
|
||||
reqwest = "0.12"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots"] }
|
||||
rmcp = { version = "0.12.0", default-features = false }
|
||||
schemars = "0.8.22"
|
||||
seccompiler = "0.5.0"
|
||||
@@ -212,7 +212,7 @@ tiny_http = "0.12"
|
||||
tokio = "1"
|
||||
tokio-stream = "0.1.18"
|
||||
tokio-test = "0.4"
|
||||
tokio-tungstenite = "0.21.0"
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots", "proxy"] }
|
||||
tokio-util = "0.7.18"
|
||||
toml = "0.9.5"
|
||||
toml_edit = "0.24.0"
|
||||
@@ -303,6 +303,10 @@ opt-level = 0
|
||||
# ratatui = { path = "../../ratatui" }
|
||||
crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" }
|
||||
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }
|
||||
tokio-tungstenite = { git = "https://github.com/JakkuSakura/tokio-tungstenite", rev = "2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" }
|
||||
|
||||
# Uncomment to debug local changes.
|
||||
# rmcp = { path = "../../rust-sdk/crates/rmcp" }
|
||||
|
||||
[patch."ssh://git@github.com/JakkuSakura/tungstenite-rs.git"]
|
||||
tungstenite = { git = "https://github.com/JakkuSakura/tungstenite-rs", rev = "f514de8644821113e5d18a027d6d28a5c8cc0a6e" }
|
||||
|
||||
@@ -503,17 +503,14 @@ impl From<CoreTextElement> for V1TextElement {
|
||||
fn from(value: CoreTextElement) -> Self {
|
||||
Self {
|
||||
byte_range: value.byte_range.into(),
|
||||
placeholder: value.placeholder,
|
||||
placeholder: value._placeholder_for_conversion_only().map(str::to_string),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<V1TextElement> for CoreTextElement {
|
||||
fn from(value: V1TextElement) -> Self {
|
||||
Self {
|
||||
byte_range: value.byte_range.into(),
|
||||
placeholder: value.placeholder,
|
||||
}
|
||||
Self::new(value.byte_range.into(), value.placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1665,24 +1665,38 @@ pub struct TextElement {
|
||||
/// Byte range in the parent `text` buffer that this element occupies.
|
||||
pub byte_range: ByteRange,
|
||||
/// Optional human-readable placeholder for the element, displayed in the UI.
|
||||
pub placeholder: Option<String>,
|
||||
placeholder: Option<String>,
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
pub fn new(byte_range: ByteRange, placeholder: Option<String>) -> Self {
|
||||
Self {
|
||||
byte_range,
|
||||
placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_placeholder(&mut self, placeholder: Option<String>) {
|
||||
self.placeholder = placeholder;
|
||||
}
|
||||
|
||||
pub fn placeholder(&self) -> Option<&str> {
|
||||
self.placeholder.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreTextElement> for TextElement {
|
||||
fn from(value: CoreTextElement) -> Self {
|
||||
Self {
|
||||
byte_range: value.byte_range.into(),
|
||||
placeholder: value.placeholder,
|
||||
}
|
||||
Self::new(
|
||||
value.byte_range.into(),
|
||||
value._placeholder_for_conversion_only().map(str::to_string),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TextElement> for CoreTextElement {
|
||||
fn from(value: TextElement) -> Self {
|
||||
Self {
|
||||
byte_range: value.byte_range.into(),
|
||||
placeholder: value.placeholder,
|
||||
}
|
||||
Self::new(value.byte_range.into(), value.placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,10 +74,10 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let preview = "Saved user message";
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: ByteRange { start: 0, end: 5 },
|
||||
placeholder: Some("<note>".into()),
|
||||
}];
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start: 0, end: 5 },
|
||||
Some("<note>".into()),
|
||||
)];
|
||||
let conversation_id = create_fake_rollout_with_text_elements(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
|
||||
@@ -142,10 +142,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> {
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: ByteRange { start: 0, end: 5 },
|
||||
placeholder: Some("<note>".to_string()),
|
||||
}];
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start: 0, end: 5 },
|
||||
Some("<note>".to_string()),
|
||||
)];
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
|
||||
@@ -12,7 +12,7 @@ path = "src/lib.rs"
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] }
|
||||
codex-backend-openapi-models = { path = "../codex-backend-openapi-models" }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
|
||||
@@ -150,7 +150,7 @@ async fn connect_websocket(
|
||||
turn_state: Option<Arc<OnceLock<String>>>,
|
||||
) -> Result<(WsStream, bool), ApiError> {
|
||||
let mut request = url
|
||||
.clone()
|
||||
.as_str()
|
||||
.into_client_request()
|
||||
.map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?;
|
||||
request.headers_mut().extend(headers);
|
||||
@@ -209,7 +209,7 @@ async fn run_websocket_response_stream(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = ws_stream.send(Message::Text(request_text)).await {
|
||||
if let Err(err) = ws_stream.send(Message::Text(request_text.into())).await {
|
||||
return Err(ApiError::Stream(format!(
|
||||
"failed to send websocket request: {err}"
|
||||
)));
|
||||
|
||||
@@ -18,6 +18,7 @@ codex_rust_crate(
|
||||
),
|
||||
integration_compile_data_extra = [
|
||||
"//codex-rs/apply-patch:apply_patch_tool_instructions.md",
|
||||
"models.json",
|
||||
"prompt.md",
|
||||
],
|
||||
test_data_extra = [
|
||||
|
||||
@@ -240,6 +240,14 @@
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"model_personality": {
|
||||
"description": "EXPERIMENTAL Optionally specify a personality for the model",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Personality"
|
||||
}
|
||||
]
|
||||
},
|
||||
"model_provider": {
|
||||
"description": "Provider to use from the model_providers map.",
|
||||
"type": "string"
|
||||
@@ -359,12 +367,12 @@
|
||||
},
|
||||
"shell_environment_policy": {
|
||||
"default": {
|
||||
"inherit": null,
|
||||
"ignore_default_excludes": null,
|
||||
"exclude": null,
|
||||
"set": null,
|
||||
"experimental_use_profile": null,
|
||||
"ignore_default_excludes": null,
|
||||
"include_only": null,
|
||||
"experimental_use_profile": null
|
||||
"inherit": null,
|
||||
"set": null
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
@@ -632,6 +640,9 @@
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"model_personality": {
|
||||
"$ref": "#/definitions/Personality"
|
||||
},
|
||||
"model_provider": {
|
||||
"description": "The key in the `model_providers` map identifying the [`ModelProviderInfo`] to use.",
|
||||
"type": "string"
|
||||
@@ -1051,6 +1062,13 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Personality": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"friendly",
|
||||
"pragmatic"
|
||||
]
|
||||
},
|
||||
"ProjectConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::config::types::Personality;
|
||||
use crate::error::Result;
|
||||
pub use codex_api::common::ResponseEvent;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
@@ -35,6 +36,9 @@ pub struct Prompt {
|
||||
|
||||
pub base_instructions: BaseInstructions,
|
||||
|
||||
/// Optionally specify the personality of the model.
|
||||
pub personality: Option<Personality>,
|
||||
|
||||
/// Optional the output schema for the model's response.
|
||||
pub output_schema: Option<Value>,
|
||||
}
|
||||
|
||||
+70
-27
@@ -710,10 +710,18 @@ impl Session {
|
||||
let mut default_shell = shell::default_user_shell();
|
||||
// Create the mutable state for the Session.
|
||||
if config.features.enabled(Feature::ShellSnapshot) {
|
||||
let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]);
|
||||
default_shell.shell_snapshot =
|
||||
ShellSnapshot::try_new(&config.codex_home, conversation_id, &default_shell)
|
||||
.await
|
||||
.map(Arc::new);
|
||||
let success = if default_shell.shell_snapshot.is_some() {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
};
|
||||
let _ = timer.map(|timer| timer.record(&[("success", success)]));
|
||||
otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)])
|
||||
}
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
|
||||
@@ -2966,6 +2974,7 @@ async fn run_sampling_request(
|
||||
tools: router.specs(),
|
||||
parallel_tool_calls: model_supports_parallel,
|
||||
base_instructions,
|
||||
personality: None,
|
||||
output_schema: turn_context.final_output_json_schema.clone(),
|
||||
};
|
||||
|
||||
@@ -4331,6 +4340,8 @@ mod tests {
|
||||
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
|
||||
// Interrupts persist a model-visible `<turn_aborted>` marker into history, but there is no
|
||||
// separate client-visible event for that marker (only `EventMsg::TurnAborted`).
|
||||
let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("timeout waiting for event")
|
||||
@@ -4339,6 +4350,7 @@ mod tests {
|
||||
EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
// No extra events should be emitted after an abort.
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
@@ -4361,11 +4373,17 @@ mod tests {
|
||||
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
|
||||
let evt = rx.recv().await.expect("event");
|
||||
// Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted`
|
||||
// being the only client-visible signal.
|
||||
let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("timeout waiting for event")
|
||||
.expect("event");
|
||||
match evt.msg {
|
||||
EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
// No extra events should be emitted after an abort.
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
@@ -4381,42 +4399,67 @@ mod tests {
|
||||
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
|
||||
// Drain events until we observe ExitedReviewMode; earlier
|
||||
// RawResponseItem entries (e.g., environment context) may arrive first.
|
||||
loop {
|
||||
let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
|
||||
// Aborting a review task should exit review mode before surfacing the abort to the client.
|
||||
// We scan for these events (rather than relying on fixed ordering) since unrelated events
|
||||
// may interleave.
|
||||
let mut exited_review_mode_idx = None;
|
||||
let mut turn_aborted_idx = None;
|
||||
let mut idx = 0usize;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
let evt = tokio::time::timeout(remaining, rx.recv())
|
||||
.await
|
||||
.expect("timeout waiting for first event")
|
||||
.expect("first event");
|
||||
.expect("timeout waiting for event")
|
||||
.expect("event");
|
||||
let event_idx = idx;
|
||||
idx = idx.saturating_add(1);
|
||||
match evt.msg {
|
||||
EventMsg::ExitedReviewMode(ev) => {
|
||||
assert!(ev.review_output.is_none());
|
||||
exited_review_mode_idx = Some(event_idx);
|
||||
}
|
||||
EventMsg::TurnAborted(ev) => {
|
||||
assert_eq!(TurnAbortReason::Interrupted, ev.reason);
|
||||
turn_aborted_idx = Some(event_idx);
|
||||
break;
|
||||
}
|
||||
// Ignore any non-critical events before exit.
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
|
||||
.await
|
||||
.expect("timeout waiting for next event")
|
||||
.expect("event");
|
||||
match evt.msg {
|
||||
EventMsg::RawResponseItem(_) => continue,
|
||||
EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => continue,
|
||||
EventMsg::AgentMessage(_) => continue,
|
||||
EventMsg::TurnAborted(e) => {
|
||||
assert_eq!(TurnAbortReason::Interrupted, e.reason);
|
||||
break;
|
||||
}
|
||||
other => panic!("unexpected second event: {other:?}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
exited_review_mode_idx.is_some(),
|
||||
"expected ExitedReviewMode after abort"
|
||||
);
|
||||
assert!(
|
||||
turn_aborted_idx.is_some(),
|
||||
"expected TurnAborted after abort"
|
||||
);
|
||||
assert!(
|
||||
exited_review_mode_idx.unwrap() < turn_aborted_idx.unwrap(),
|
||||
"expected ExitedReviewMode before TurnAborted"
|
||||
);
|
||||
|
||||
// TODO(jif) investigate what is this?
|
||||
let history = sess.clone_history().await;
|
||||
let _ = history.raw_items();
|
||||
// The `<turn_aborted>` marker is silent in the event stream, so verify it is still
|
||||
// recorded in history for the model.
|
||||
assert!(
|
||||
history.raw_items().iter().any(|item| {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
if role != "user" {
|
||||
return false;
|
||||
}
|
||||
content.iter().any(|content_item| {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return false;
|
||||
};
|
||||
text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG)
|
||||
})
|
||||
}),
|
||||
"expected a model-visible turn aborted marker in history after interrupt"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::protocol::EventMsg;
|
||||
use crate::protocol::TurnContextItem;
|
||||
use crate::protocol::TurnStartedEvent;
|
||||
use crate::protocol::WarningEvent;
|
||||
use crate::session_prefix::TURN_ABORTED_OPEN_TAG;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::truncate_text;
|
||||
@@ -223,11 +224,31 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec<String> {
|
||||
Some(user.message())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
_ => collect_turn_aborted_marker(item),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_turn_aborted_marker(item: &ResponseItem) -> Option<String> {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return None;
|
||||
};
|
||||
if role != "user" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = content_items_to_text(content)?;
|
||||
if text
|
||||
.trim_start()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(TURN_ABORTED_OPEN_TAG)
|
||||
{
|
||||
Some(text)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_summary_message(message: &str) -> bool {
|
||||
message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str())
|
||||
}
|
||||
@@ -337,6 +358,7 @@ async fn drain_to_completed(
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::session_prefix::TURN_ABORTED_OPEN_TAG;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -489,4 +511,41 @@ mod tests {
|
||||
};
|
||||
assert_eq!(summary, summary_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_compacted_history_preserves_turn_aborted_markers() {
|
||||
let marker = format!(
|
||||
"{TURN_ABORTED_OPEN_TAG}\n <turn_id>turn-1</turn_id>\n <reason>interrupted</reason>\n</turn_aborted>"
|
||||
);
|
||||
let items = vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: marker.clone(),
|
||||
}],
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "real user message".to_string(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let user_messages = collect_user_messages(&items);
|
||||
let history = build_compacted_history(Vec::new(), &user_messages, "SUMMARY");
|
||||
|
||||
let found_marker = history.iter().any(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
content_items_to_text(content).is_some_and(|text| text == marker)
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
found_marker,
|
||||
"expected compacted history to retain <turn_aborted> marker"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
tools: vec![],
|
||||
parallel_tool_calls: false,
|
||||
base_instructions: sess.get_base_instructions().await,
|
||||
personality: None,
|
||||
output_schema: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::config::types::Notifications;
|
||||
use crate::config::types::OtelConfig;
|
||||
use crate::config::types::OtelConfigToml;
|
||||
use crate::config::types::OtelExporterKind;
|
||||
use crate::config::types::Personality;
|
||||
use crate::config::types::SandboxWorkspaceWrite;
|
||||
use crate::config::types::ScrollInputMode;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
@@ -127,6 +128,9 @@ pub struct Config {
|
||||
/// Info needed to make an API request to the model.
|
||||
pub model_provider: ModelProviderInfo,
|
||||
|
||||
/// Optionally specify the personality of the model
|
||||
pub model_personality: Option<Personality>,
|
||||
|
||||
/// Approval policy for executing commands.
|
||||
pub approval_policy: Constrained<AskForApproval>,
|
||||
|
||||
@@ -395,6 +399,7 @@ pub struct ConfigBuilder {
|
||||
cli_overrides: Option<Vec<(String, TomlValue)>>,
|
||||
harness_overrides: Option<ConfigOverrides>,
|
||||
loader_overrides: Option<LoaderOverrides>,
|
||||
fallback_cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ConfigBuilder {
|
||||
@@ -418,21 +423,29 @@ impl ConfigBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn fallback_cwd(mut self, fallback_cwd: Option<PathBuf>) -> Self {
|
||||
self.fallback_cwd = fallback_cwd;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn build(self) -> std::io::Result<Config> {
|
||||
let Self {
|
||||
codex_home,
|
||||
cli_overrides,
|
||||
harness_overrides,
|
||||
loader_overrides,
|
||||
fallback_cwd,
|
||||
} = self;
|
||||
let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?;
|
||||
let cli_overrides = cli_overrides.unwrap_or_default();
|
||||
let harness_overrides = harness_overrides.unwrap_or_default();
|
||||
let mut harness_overrides = harness_overrides.unwrap_or_default();
|
||||
let loader_overrides = loader_overrides.unwrap_or_default();
|
||||
let cwd = match harness_overrides.cwd.as_deref() {
|
||||
let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref());
|
||||
let cwd = match cwd_override {
|
||||
Some(path) => AbsolutePathBuf::try_from(path)?,
|
||||
None => AbsolutePathBuf::current_dir()?,
|
||||
};
|
||||
harness_overrides.cwd = Some(cwd.to_path_buf());
|
||||
let config_layer_stack =
|
||||
load_config_layers_state(&codex_home, Some(cwd), &cli_overrides, loader_overrides)
|
||||
.await?;
|
||||
@@ -531,7 +544,7 @@ pub async fn load_config_as_toml_with_cli_overrides(
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn deserialize_config_toml_with_base(
|
||||
pub(crate) fn deserialize_config_toml_with_base(
|
||||
root_value: TomlValue,
|
||||
config_base_dir: &Path,
|
||||
) -> std::io::Result<ConfigToml> {
|
||||
@@ -890,6 +903,10 @@ pub struct ConfigToml {
|
||||
/// Override to force-enable reasoning summaries for the configured model.
|
||||
pub model_supports_reasoning_summaries: Option<bool>,
|
||||
|
||||
/// EXPERIMENTAL
|
||||
/// Optionally specify a personality for the model
|
||||
pub model_personality: Option<Personality>,
|
||||
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
|
||||
@@ -1474,6 +1491,7 @@ impl Config {
|
||||
notify: cfg.notify,
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
model_personality: config_profile.model_personality.or(cfg.model_personality),
|
||||
developer_instructions,
|
||||
compact_prompt,
|
||||
// The config.toml omits "_mode" because it's a config file. However, "_mode"
|
||||
@@ -2292,6 +2310,52 @@ trust_level = "trusted"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_profile_overrides_user_profile() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let workspace = TempDir::new()?;
|
||||
let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\");
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"
|
||||
profile = "global"
|
||||
|
||||
[profiles.global]
|
||||
model = "gpt-global"
|
||||
|
||||
[profiles.project]
|
||||
model = "gpt-project"
|
||||
|
||||
[projects."{workspace_key}"]
|
||||
trust_level = "trusted"
|
||||
"#,
|
||||
),
|
||||
)?;
|
||||
let project_config_dir = workspace.path().join(".codex");
|
||||
std::fs::create_dir_all(&project_config_dir)?;
|
||||
std::fs::write(
|
||||
project_config_dir.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
profile = "project"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(workspace.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.active_profile.as_deref(), Some("project"));
|
||||
assert_eq!(config.model.as_deref(), Some("gpt-project"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -3605,6 +3669,7 @@ model_verbosity = "high"
|
||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_verbosity: None,
|
||||
model_personality: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
@@ -3692,6 +3757,7 @@ model_verbosity = "high"
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_verbosity: None,
|
||||
model_personality: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
@@ -3794,6 +3860,7 @@ model_verbosity = "high"
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_verbosity: None,
|
||||
model_personality: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
@@ -3882,6 +3949,7 @@ model_verbosity = "high"
|
||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_verbosity: Some(Verbosity::High),
|
||||
model_personality: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
|
||||
@@ -3,6 +3,7 @@ use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::types::Personality;
|
||||
use crate::protocol::AskForApproval;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
@@ -24,6 +25,7 @@ pub struct ConfigProfile {
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
pub model_personality: Option<Personality>,
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
pub experimental_instructions_file: Option<AbsolutePathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
|
||||
|
||||
@@ -748,6 +748,14 @@ impl Default for ShellEnvironmentPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Personality {
|
||||
Friendly,
|
||||
#[default]
|
||||
Pragmatic,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -12,10 +12,13 @@ mod tests;
|
||||
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::deserialize_config_toml_with_base;
|
||||
use crate::config_loader::config_requirements::ConfigRequirementsWithSources;
|
||||
use crate::config_loader::layer_io::LoadedConfigLayers;
|
||||
use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
@@ -64,9 +67,9 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
|
||||
/// - admin: managed preferences (*)
|
||||
/// - system `/etc/codex/config.toml`
|
||||
/// - user `${CODEX_HOME}/config.toml`
|
||||
/// - cwd `${PWD}/config.toml`
|
||||
/// - tree parent directories up to root looking for `./.codex/config.toml`
|
||||
/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml`
|
||||
/// - cwd `${PWD}/config.toml` (only when the directory is trusted)
|
||||
/// - tree parent directories up to root looking for `./.codex/config.toml` (trusted only)
|
||||
/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (trusted only)
|
||||
/// - runtime e.g., --config flags, model selector in UI
|
||||
///
|
||||
/// (*) Only available on macOS via managed device profiles.
|
||||
@@ -114,6 +117,12 @@ pub async fn load_config_layers_state(
|
||||
|
||||
let mut layers = Vec::<ConfigLayerEntry>::new();
|
||||
|
||||
let cli_overrides_layer = if cli_overrides.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(overrides::build_cli_overrides_layer(cli_overrides))
|
||||
};
|
||||
|
||||
// Include an entry for the "system" config folder, loading its config.toml,
|
||||
// if it exists.
|
||||
let system_config_toml_file = if cfg!(unix) {
|
||||
@@ -158,17 +167,22 @@ pub async fn load_config_layers_state(
|
||||
for layer in &layers {
|
||||
merge_toml_values(&mut merged_so_far, &layer.config);
|
||||
}
|
||||
if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() {
|
||||
merge_toml_values(&mut merged_so_far, cli_overrides_layer);
|
||||
}
|
||||
|
||||
let project_root_markers = project_root_markers_from_config(&merged_so_far)?
|
||||
.unwrap_or_else(default_project_root_markers);
|
||||
|
||||
let project_root = find_project_root(&cwd, &project_root_markers).await?;
|
||||
let project_layers = load_project_layers(&cwd, &project_root).await?;
|
||||
layers.extend(project_layers);
|
||||
if let Some(project_root) =
|
||||
trusted_project_root(&merged_so_far, &cwd, &project_root_markers, codex_home).await?
|
||||
{
|
||||
let project_layers = load_project_layers(&cwd, &project_root).await?;
|
||||
layers.extend(project_layers);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a layer for runtime overrides from the CLI or UI, if any exist.
|
||||
if !cli_overrides.is_empty() {
|
||||
let cli_overrides_layer = overrides::build_cli_overrides_layer(cli_overrides);
|
||||
if let Some(cli_overrides_layer) = cli_overrides_layer {
|
||||
layers.push(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
cli_overrides_layer,
|
||||
@@ -388,6 +402,44 @@ fn default_project_root_markers() -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn trusted_project_root(
|
||||
merged_config: &TomlValue,
|
||||
cwd: &AbsolutePathBuf,
|
||||
project_root_markers: &[String],
|
||||
config_base_dir: &Path,
|
||||
) -> io::Result<Option<AbsolutePathBuf>> {
|
||||
let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?;
|
||||
|
||||
let project_root = find_project_root(cwd, project_root_markers).await?;
|
||||
let projects = config_toml.projects.unwrap_or_default();
|
||||
|
||||
let cwd_key = cwd.as_path().to_string_lossy().to_string();
|
||||
let project_root_key = project_root.as_path().to_string_lossy().to_string();
|
||||
let repo_root_key = resolve_root_git_project_for_trust(cwd.as_path())
|
||||
.map(|root| root.to_string_lossy().to_string());
|
||||
|
||||
let trust_level = projects
|
||||
.get(&cwd_key)
|
||||
.and_then(|project| project.trust_level)
|
||||
.or_else(|| {
|
||||
projects
|
||||
.get(&project_root_key)
|
||||
.and_then(|project| project.trust_level)
|
||||
})
|
||||
.or_else(|| {
|
||||
repo_root_key
|
||||
.as_ref()
|
||||
.and_then(|root| projects.get(root))
|
||||
.and_then(|project| project.trust_level)
|
||||
});
|
||||
|
||||
if matches!(trust_level, Some(TrustLevel::Trusted)) {
|
||||
Ok(Some(project_root))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a `toml::Value` parsed from a config.toml file and walks through it,
|
||||
/// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new
|
||||
/// `toml::Value` with the same shape but with paths resolved.
|
||||
|
||||
@@ -3,19 +3,47 @@ use super::load_config_layers_state;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::config_requirements::ConfigRequirementsWithSources;
|
||||
use crate::config_loader::fingerprint::version_for_toml;
|
||||
use crate::config_loader::load_requirements_toml;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
async fn make_config_for_test(
|
||||
codex_home: &Path,
|
||||
project_path: &Path,
|
||||
trust_level: TrustLevel,
|
||||
project_root_markers: Option<Vec<String>>,
|
||||
) -> std::io::Result<()> {
|
||||
tokio::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
toml::to_string(&ConfigToml {
|
||||
projects: Some(HashMap::from([(
|
||||
project_path.to_string_lossy().to_string(),
|
||||
ProjectConfig {
|
||||
trust_level: Some(trust_level),
|
||||
},
|
||||
)])),
|
||||
project_root_markers,
|
||||
..Default::default()
|
||||
})
|
||||
.expect("serialize config"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merges_managed_config_layer_on_top() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
@@ -365,6 +393,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
|
||||
let layers = load_config_layers_state(
|
||||
&codex_home,
|
||||
@@ -429,6 +458,7 @@ experimental_instructions_file = "child.txt"
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
@@ -458,6 +488,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
|
||||
let layers = load_config_layers_state(
|
||||
&codex_home,
|
||||
@@ -486,6 +517,95 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
tokio::fs::create_dir_all(nested.join(".codex")).await?;
|
||||
tokio::fs::write(
|
||||
nested.join(".codex").join(CONFIG_TOML_FILE),
|
||||
"foo = \"child\"\n",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
|
||||
|
||||
let codex_home_untrusted = tmp.path().join("home_untrusted");
|
||||
tokio::fs::create_dir_all(&codex_home_untrusted).await?;
|
||||
make_config_for_test(
|
||||
&codex_home_untrusted,
|
||||
&project_root,
|
||||
TrustLevel::Untrusted,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let layers_untrusted = load_config_layers_state(
|
||||
&codex_home_untrusted,
|
||||
Some(cwd.clone()),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
)
|
||||
.await?;
|
||||
let project_layers_untrusted = layers_untrusted
|
||||
.layers_high_to_low()
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
|
||||
.count();
|
||||
assert_eq!(project_layers_untrusted, 0);
|
||||
assert_eq!(layers_untrusted.effective_config().get("foo"), None);
|
||||
|
||||
let codex_home_unknown = tmp.path().join("home_unknown");
|
||||
tokio::fs::create_dir_all(&codex_home_unknown).await?;
|
||||
|
||||
let layers_unknown = load_config_layers_state(
|
||||
&codex_home_unknown,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
)
|
||||
.await?;
|
||||
let project_layers_unknown = layers_unknown
|
||||
.layers_high_to_low()
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
|
||||
.count();
|
||||
assert_eq!(project_layers_unknown, 0);
|
||||
assert_eq!(layers_unknown.effective_config().get("foo"), None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
tokio::fs::create_dir_all(&nested).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
|
||||
let cli_overrides = vec![(
|
||||
"experimental_instructions_file".to_string(),
|
||||
TomlValue::String("relative.md".to_string()),
|
||||
)];
|
||||
|
||||
load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
@@ -507,11 +627,11 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
project_root_markers = [".hg"]
|
||||
"#,
|
||||
make_config_for_test(
|
||||
&codex_home,
|
||||
&project_root,
|
||||
TrustLevel::Trusted,
|
||||
Some(vec![".hg".to_string()]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::codex::TurnContext;
|
||||
use crate::context_manager::normalize;
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::session_prefix::is_session_prefix;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::approx_tokens_from_byte_count;
|
||||
@@ -328,12 +329,6 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize {
|
||||
.saturating_sub(650)
|
||||
}
|
||||
|
||||
fn is_session_prefix(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered.starts_with("<environment_context>")
|
||||
}
|
||||
|
||||
pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
|
||||
let ResponseItem::Message { role, content, .. } = item else {
|
||||
return false;
|
||||
|
||||
@@ -169,7 +169,8 @@ pub fn build_reqwest_client() -> reqwest::Client {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
// Set UA via dedicated helper to avoid header validation pitfalls
|
||||
.user_agent(ua)
|
||||
.default_headers(headers);
|
||||
.default_headers(headers)
|
||||
.use_rustls_tls();
|
||||
if is_sandboxed() {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
|
||||
@@ -19,14 +19,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::session_prefix::is_session_prefix;
|
||||
use crate::user_shell_command::is_user_shell_command_text;
|
||||
|
||||
fn is_session_prefix(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered.starts_with("<environment_context>")
|
||||
}
|
||||
|
||||
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
if UserInstructions::is_user_instructions(message)
|
||||
|| SkillInstructions::is_skill_instructions(message)
|
||||
|
||||
+59
-29
@@ -47,6 +47,12 @@ const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code
|
||||
const READ_CHUNK_SIZE: usize = 8192; // bytes per read
|
||||
const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB
|
||||
|
||||
/// Hard cap on bytes retained from exec stdout/stderr/aggregated output.
|
||||
///
|
||||
/// This mirrors unified exec's output cap so a single runaway command cannot
|
||||
/// OOM the process by dumping huge amounts of data to stdout/stderr.
|
||||
const EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB
|
||||
|
||||
/// Limit the number of ExecCommandOutputDelta events emitted per exec call.
|
||||
/// Aggregation still collects full output; only the live event stream is capped.
|
||||
pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000;
|
||||
@@ -290,18 +296,32 @@ async fn exec_windows_sandbox(
|
||||
};
|
||||
|
||||
let exit_status = synthetic_exit_status(capture.exit_code);
|
||||
let mut stdout_text = capture.stdout;
|
||||
if stdout_text.len() > EXEC_OUTPUT_MAX_BYTES {
|
||||
stdout_text.truncate(EXEC_OUTPUT_MAX_BYTES);
|
||||
}
|
||||
let mut stderr_text = capture.stderr;
|
||||
if stderr_text.len() > EXEC_OUTPUT_MAX_BYTES {
|
||||
stderr_text.truncate(EXEC_OUTPUT_MAX_BYTES);
|
||||
}
|
||||
let stdout = StreamOutput {
|
||||
text: capture.stdout,
|
||||
text: stdout_text,
|
||||
truncated_after_lines: None,
|
||||
};
|
||||
let stderr = StreamOutput {
|
||||
text: capture.stderr,
|
||||
text: stderr_text,
|
||||
truncated_after_lines: None,
|
||||
};
|
||||
// Best-effort aggregate: stdout then stderr
|
||||
let mut aggregated = Vec::with_capacity(stdout.text.len() + stderr.text.len());
|
||||
append_all(&mut aggregated, &stdout.text);
|
||||
append_all(&mut aggregated, &stderr.text);
|
||||
// Best-effort aggregate: stdout then stderr (capped).
|
||||
let mut aggregated = Vec::with_capacity(
|
||||
stdout
|
||||
.text
|
||||
.len()
|
||||
.saturating_add(stderr.text.len())
|
||||
.min(EXEC_OUTPUT_MAX_BYTES),
|
||||
);
|
||||
append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES);
|
||||
append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES);
|
||||
let aggregated_output = StreamOutput {
|
||||
text: aggregated,
|
||||
truncated_after_lines: None,
|
||||
@@ -490,8 +510,13 @@ impl StreamOutput<Vec<u8>> {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn append_all(dst: &mut Vec<u8>, src: &[u8]) {
|
||||
dst.extend_from_slice(src);
|
||||
fn append_capped(dst: &mut Vec<u8>, src: &[u8], max_bytes: usize) {
|
||||
if dst.len() >= max_bytes {
|
||||
return;
|
||||
}
|
||||
let remaining = max_bytes.saturating_sub(dst.len());
|
||||
let take = remaining.min(src.len());
|
||||
dst.extend_from_slice(&src[..take]);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -584,19 +609,15 @@ async fn consume_truncated_output(
|
||||
))
|
||||
})?;
|
||||
|
||||
let (agg_tx, agg_rx) = async_channel::unbounded::<Vec<u8>>();
|
||||
|
||||
let stdout_handle = tokio::spawn(read_capped(
|
||||
BufReader::new(stdout_reader),
|
||||
stdout_stream.clone(),
|
||||
false,
|
||||
Some(agg_tx.clone()),
|
||||
));
|
||||
let stderr_handle = tokio::spawn(read_capped(
|
||||
BufReader::new(stderr_reader),
|
||||
stdout_stream.clone(),
|
||||
true,
|
||||
Some(agg_tx.clone()),
|
||||
));
|
||||
|
||||
let (exit_status, timed_out) = tokio::select! {
|
||||
@@ -662,15 +683,18 @@ async fn consume_truncated_output(
|
||||
Duration::from_millis(IO_DRAIN_TIMEOUT_MS),
|
||||
)
|
||||
.await?;
|
||||
|
||||
drop(agg_tx);
|
||||
|
||||
let mut combined_buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY);
|
||||
while let Ok(chunk) = agg_rx.recv().await {
|
||||
append_all(&mut combined_buf, &chunk);
|
||||
}
|
||||
// Best-effort aggregate: stdout then stderr (capped).
|
||||
let mut aggregated = Vec::with_capacity(
|
||||
stdout
|
||||
.text
|
||||
.len()
|
||||
.saturating_add(stderr.text.len())
|
||||
.min(EXEC_OUTPUT_MAX_BYTES),
|
||||
);
|
||||
append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES);
|
||||
append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES * 2);
|
||||
let aggregated_output = StreamOutput {
|
||||
text: combined_buf,
|
||||
text: aggregated,
|
||||
truncated_after_lines: None,
|
||||
};
|
||||
|
||||
@@ -687,14 +711,11 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
|
||||
mut reader: R,
|
||||
stream: Option<StdoutStream>,
|
||||
is_stderr: bool,
|
||||
aggregate_tx: Option<Sender<Vec<u8>>>,
|
||||
) -> io::Result<StreamOutput<Vec<u8>>> {
|
||||
let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY);
|
||||
let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY.min(EXEC_OUTPUT_MAX_BYTES));
|
||||
let mut tmp = [0u8; READ_CHUNK_SIZE];
|
||||
let mut emitted_deltas: usize = 0;
|
||||
|
||||
// No caps: append all bytes
|
||||
|
||||
loop {
|
||||
let n = reader.read(&mut tmp).await?;
|
||||
if n == 0 {
|
||||
@@ -723,11 +744,7 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
|
||||
emitted_deltas += 1;
|
||||
}
|
||||
|
||||
if let Some(tx) = &aggregate_tx {
|
||||
let _ = tx.send(tmp[..n].to_vec()).await;
|
||||
}
|
||||
|
||||
append_all(&mut buf, &tmp[..n]);
|
||||
append_capped(&mut buf, &tmp[..n], EXEC_OUTPUT_MAX_BYTES);
|
||||
// Continue reading to EOF to avoid back-pressure
|
||||
}
|
||||
|
||||
@@ -755,6 +772,7 @@ fn synthetic_exit_status(code: i32) -> ExitStatus {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
fn make_exec_output(
|
||||
exit_code: i32,
|
||||
@@ -816,6 +834,18 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_capped_limits_retained_bytes() {
|
||||
let (mut writer, reader) = tokio::io::duplex(1024);
|
||||
let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)];
|
||||
tokio::spawn(async move {
|
||||
writer.write_all(&bytes).await.expect("write");
|
||||
});
|
||||
|
||||
let out = read_capped(reader, None, false).await.expect("read");
|
||||
assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sandbox_detection_flags_sigsys_exit_code() {
|
||||
|
||||
@@ -429,7 +429,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
id: Feature::Collab,
|
||||
key: "collab",
|
||||
stage: Stage::Experimental {
|
||||
name: "Mult-agents",
|
||||
name: "Multi-agents",
|
||||
menu_description: "Allow Codex to spawn and collaborate with other agents on request (formerly named `collab`).",
|
||||
announcement: "NEW! Codex can now spawn other agents and work with them to solve your problems. Enable in /experimental!",
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ pub mod parse_command;
|
||||
pub mod path_utils;
|
||||
pub mod powershell;
|
||||
pub mod sandboxing;
|
||||
mod session_prefix;
|
||||
mod stream_events_utils;
|
||||
mod text_encoding;
|
||||
pub mod token_data;
|
||||
@@ -104,6 +105,7 @@ pub use rollout::list::ThreadSortKey;
|
||||
pub use rollout::list::ThreadsPage;
|
||||
pub use rollout::list::parse_cursor;
|
||||
pub use rollout::list::read_head_for_summary;
|
||||
pub use rollout::list::read_session_meta_line;
|
||||
mod function_tool;
|
||||
mod state;
|
||||
mod tasks;
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
use super::cache::ModelsCacheManager;
|
||||
use crate::api_bridge::auth_provider_from_auth;
|
||||
use crate::api_bridge::map_api_error;
|
||||
use crate::auth::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CoreResult;
|
||||
use crate::features::Feature;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
|
||||
use crate::models_manager::model_info;
|
||||
use crate::models_manager::model_presets::builtin_model_presets;
|
||||
use codex_api::ModelsClient;
|
||||
use codex_api::ReqwestTransport;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
@@ -14,20 +27,6 @@ use tokio::sync::TryLockError;
|
||||
use tokio::time::timeout;
|
||||
use tracing::error;
|
||||
|
||||
use super::cache::ModelsCacheManager;
|
||||
use crate::api_bridge::auth_provider_from_auth;
|
||||
use crate::api_bridge::map_api_error;
|
||||
use crate::auth::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CoreResult;
|
||||
use crate::features::Feature;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
|
||||
use crate::models_manager::model_info;
|
||||
use crate::models_manager::model_presets::builtin_model_presets;
|
||||
|
||||
const MODEL_CACHE_FILE: &str = "models_cache.json";
|
||||
const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -211,6 +210,8 @@ impl ModelsManager {
|
||||
}
|
||||
|
||||
async fn fetch_and_update_models(&self) -> CoreResult<()> {
|
||||
let _timer =
|
||||
codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
|
||||
@@ -238,7 +239,18 @@ impl ModelsManager {
|
||||
|
||||
/// Replace the cached remote models and rebuild the derived presets list.
|
||||
async fn apply_remote_models(&self, models: Vec<ModelInfo>) {
|
||||
*self.remote_models.write().await = models;
|
||||
let mut existing_models = Self::load_remote_models_from_file().unwrap_or_default();
|
||||
for model in models {
|
||||
if let Some(existing_index) = existing_models
|
||||
.iter()
|
||||
.position(|existing| existing.slug == model.slug)
|
||||
{
|
||||
existing_models[existing_index] = model;
|
||||
} else {
|
||||
existing_models.push(model);
|
||||
}
|
||||
}
|
||||
*self.remote_models.write().await = existing_models;
|
||||
}
|
||||
|
||||
fn load_remote_models_from_file() -> Result<Vec<ModelInfo>, std::io::Error> {
|
||||
@@ -249,6 +261,8 @@ impl ModelsManager {
|
||||
|
||||
/// Attempt to satisfy the refresh from the cache when it matches the provider and TTL.
|
||||
async fn try_load_cache(&self) -> bool {
|
||||
let _timer =
|
||||
codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]);
|
||||
let cache = match self.cache_manager.load_fresh().await {
|
||||
Some(cache) => cache,
|
||||
None => return false,
|
||||
@@ -269,16 +283,16 @@ impl ModelsManager {
|
||||
let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT);
|
||||
merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode);
|
||||
|
||||
let has_default = merged_presets.iter().any(|preset| preset.is_default);
|
||||
if !has_default {
|
||||
if let Some(default) = merged_presets
|
||||
.iter_mut()
|
||||
.find(|preset| preset.show_in_picker)
|
||||
{
|
||||
default.is_default = true;
|
||||
} else if let Some(default) = merged_presets.first_mut() {
|
||||
default.is_default = true;
|
||||
}
|
||||
for preset in &mut merged_presets {
|
||||
preset.is_default = false;
|
||||
}
|
||||
if let Some(default) = merged_presets
|
||||
.iter_mut()
|
||||
.find(|preset| preset.show_in_picker)
|
||||
{
|
||||
default.is_default = true;
|
||||
} else if let Some(default) = merged_presets.first_mut() {
|
||||
default.is_default = true;
|
||||
}
|
||||
|
||||
merged_presets
|
||||
@@ -393,6 +407,16 @@ mod tests {
|
||||
.expect("valid model")
|
||||
}
|
||||
|
||||
fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) {
|
||||
for model in expected {
|
||||
assert!(
|
||||
actual.iter().any(|candidate| candidate.slug == model.slug),
|
||||
"expected model {} in cached list",
|
||||
model.slug
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_for(base_url: String) -> ModelProviderInfo {
|
||||
ModelProviderInfo {
|
||||
name: "mock".into(),
|
||||
@@ -412,7 +436,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_available_models_sorts_and_marks_default() {
|
||||
async fn refresh_available_models_sorts_by_priority() {
|
||||
let server = MockServer::start().await;
|
||||
let remote_models = vec![
|
||||
remote_model("priority-low", "Low", 1),
|
||||
@@ -444,7 +468,7 @@ mod tests {
|
||||
.await
|
||||
.expect("refresh succeeds");
|
||||
let cached_remote = manager.get_remote_models(&config).await;
|
||||
assert_eq!(cached_remote, remote_models);
|
||||
assert_models_contain(&cached_remote, &remote_models);
|
||||
|
||||
let available = manager
|
||||
.list_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
@@ -461,11 +485,6 @@ mod tests {
|
||||
high_idx < low_idx,
|
||||
"higher priority should be listed before lower priority"
|
||||
);
|
||||
assert!(
|
||||
available[high_idx].is_default,
|
||||
"highest priority should be default"
|
||||
);
|
||||
assert!(!available[low_idx].is_default);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
@@ -505,22 +524,14 @@ mod tests {
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("first refresh succeeds");
|
||||
assert_eq!(
|
||||
manager.get_remote_models(&config).await,
|
||||
remote_models,
|
||||
"remote cache should store fetched models"
|
||||
);
|
||||
assert_models_contain(&manager.get_remote_models(&config).await, &remote_models);
|
||||
|
||||
// Second call should read from cache and avoid the network.
|
||||
manager
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("cached refresh succeeds");
|
||||
assert_eq!(
|
||||
manager.get_remote_models(&config).await,
|
||||
remote_models,
|
||||
"cache path should not mutate stored models"
|
||||
);
|
||||
assert_models_contain(&manager.get_remote_models(&config).await, &remote_models);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
@@ -584,11 +595,7 @@ mod tests {
|
||||
.refresh_available_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("second refresh succeeds");
|
||||
assert_eq!(
|
||||
manager.get_remote_models(&config).await,
|
||||
updated_models,
|
||||
"stale cache should trigger refetch"
|
||||
);
|
||||
assert_models_contain(&manager.get_remote_models(&config).await, &updated_models);
|
||||
assert_eq!(
|
||||
initial_mock.requests().len(),
|
||||
1,
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_protocol::openai_models::TruncationMode;
|
||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::types::Personality;
|
||||
use crate::truncate::approx_bytes_for_tokens;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -88,6 +89,20 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo
|
||||
}
|
||||
if let Some(base_instructions) = &config.base_instructions {
|
||||
model.base_instructions = base_instructions.clone();
|
||||
} else if model.slug.contains("gpt-5.2-codex")
|
||||
&& let Some(personality) = &config.model_personality
|
||||
{
|
||||
let template = include_str!(
|
||||
"../../templates/model_instructions/gpt-5.2-codex_instructions_template.md"
|
||||
);
|
||||
let personality_message = match personality {
|
||||
Personality::Friendly => include_str!("../../templates/personalities/friendly.md"),
|
||||
Personality::Pragmatic => {
|
||||
include_str!("../../templates/personalities/pragmatic.md")
|
||||
}
|
||||
};
|
||||
let instructions = template.replace("{{ personality_message }}", personality_message);
|
||||
model.base_instructions = instructions;
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::protocol::EventMsg;
|
||||
use codex_file_search as file_search;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
|
||||
/// Returned page of thread (thread) summaries.
|
||||
@@ -744,6 +745,24 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Va
|
||||
Ok(summary.head)
|
||||
}
|
||||
|
||||
/// Read the SessionMetaLine from the head of a rollout file for reuse by
|
||||
/// callers that need the session metadata (e.g. to derive a cwd for config).
|
||||
pub async fn read_session_meta_line(path: &Path) -> io::Result<SessionMetaLine> {
|
||||
let head = read_head_for_summary(path).await?;
|
||||
let Some(first) = head.first() else {
|
||||
return Err(io::Error::other(format!(
|
||||
"rollout at {} is empty",
|
||||
path.display()
|
||||
)));
|
||||
};
|
||||
serde_json::from_value::<SessionMetaLine>(first.clone()).map_err(|_| {
|
||||
io::Error::other(format!(
|
||||
"rollout at {} does not start with session metadata",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
async fn file_modified_time(path: &Path) -> io::Result<Option<OffsetDateTime>> {
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
let modified = meta.modified().ok();
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later.
|
||||
|
||||
use std::fs::File;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::{self};
|
||||
use std::io::Error as IoError;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
@@ -197,17 +195,14 @@ impl RolloutRecorder {
|
||||
}),
|
||||
)
|
||||
}
|
||||
RolloutRecorderParams::Resume { path } => {
|
||||
touch_rollout_file(&path)?;
|
||||
(
|
||||
tokio::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.await?,
|
||||
path,
|
||||
None,
|
||||
)
|
||||
}
|
||||
RolloutRecorderParams::Resume { path } => (
|
||||
tokio::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.await?,
|
||||
path,
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
// Clone the cwd for the spawned task to collect git info asynchronously
|
||||
@@ -392,13 +387,6 @@ fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Resul
|
||||
})
|
||||
}
|
||||
|
||||
fn touch_rollout_file(path: &Path) -> std::io::Result<()> {
|
||||
let file = fs::OpenOptions::new().append(true).open(path)?;
|
||||
let times = FileTimes::new().set_modified(SystemTime::now());
|
||||
file.set_times(times)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rollout_writer(
|
||||
file: tokio::fs::File,
|
||||
mut rx: mpsc::Receiver<RolloutCmd>,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/// Helpers for identifying model-visible "session prefix" messages.
|
||||
///
|
||||
/// A session prefix is a user-role message that carries configuration or state needed by
|
||||
/// follow-up turns (e.g. `<environment_context>`, `<turn_aborted>`). These items are persisted in
|
||||
/// history so the model can see them, but they are not user intent and must not create user-turn
|
||||
/// boundaries.
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
|
||||
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
|
||||
|
||||
/// Returns true if `text` starts with a session prefix marker (case-insensitive).
|
||||
pub(crate) fn is_session_prefix(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG)
|
||||
}
|
||||
@@ -550,15 +550,20 @@ fn extract_frontmatter(contents: &str) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use toml::Value as TomlValue;
|
||||
@@ -570,6 +575,21 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config {
|
||||
fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
toml::to_string(&ConfigToml {
|
||||
projects: Some(HashMap::from([(
|
||||
cwd.to_string_lossy().to_string(),
|
||||
ProjectConfig {
|
||||
trust_level: Some(TrustLevel::Trusted),
|
||||
},
|
||||
)])),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("serialize config"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let harness_overrides = ConfigOverrides {
|
||||
cwd: Some(cwd),
|
||||
..Default::default()
|
||||
|
||||
@@ -24,9 +24,13 @@ use crate::protocol::EventMsg;
|
||||
use crate::protocol::TurnAbortReason;
|
||||
use crate::protocol::TurnAbortedEvent;
|
||||
use crate::protocol::TurnCompleteEvent;
|
||||
use crate::session_prefix::TURN_ABORTED_OPEN_TAG;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::RunningTask;
|
||||
use crate::state::TaskKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
pub(crate) use compact::CompactTask;
|
||||
@@ -37,6 +41,7 @@ pub(crate) use undo::UndoTask;
|
||||
pub(crate) use user_shell::UserShellCommandTask;
|
||||
|
||||
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
|
||||
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn. Do not continue or repeat work from that turn unless the user explicitly asks. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
|
||||
|
||||
/// Thin wrapper that exposes the parts of [`Session`] task runners need.
|
||||
#[derive(Clone)]
|
||||
@@ -242,6 +247,25 @@ impl Session {
|
||||
.abort(session_ctx, Arc::clone(&task.turn_context))
|
||||
.await;
|
||||
|
||||
if reason == TurnAbortReason::Interrupted {
|
||||
let marker = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!(
|
||||
"{TURN_ABORTED_OPEN_TAG}\n <turn_id>{sub_id}</turn_id>\n <reason>interrupted</reason>\n <guidance>{TURN_ABORTED_INTERRUPTED_GUIDANCE}</guidance>\n</turn_aborted>"
|
||||
),
|
||||
}],
|
||||
};
|
||||
self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref())
|
||||
.await;
|
||||
self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)])
|
||||
.await;
|
||||
// Ensure the marker is durably visible before emitting TurnAborted: some clients
|
||||
// synchronously re-read the rollout on receipt of the abort event.
|
||||
self.flush_rollout().await;
|
||||
}
|
||||
|
||||
let event = EventMsg::TurnAborted(TurnAbortedEvent { reason });
|
||||
self.send_event(task.turn_context.as_ref(), event).await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals.
|
||||
|
||||
# Personality
|
||||
|
||||
{{ personality_message }}
|
||||
|
||||
# Your environment
|
||||
|
||||
## Using GIT
|
||||
|
||||
- You may be working in a dirty git worktree.
|
||||
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
|
||||
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
|
||||
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
|
||||
* If the changes are in unrelated files, just ignore them and don't revert them.
|
||||
- Do not amend a commit unless explicitly requested to do so.
|
||||
- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
|
||||
- Be cautious when using git. **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
|
||||
- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.
|
||||
|
||||
## Agents.md
|
||||
|
||||
- If the directory you are in has an AGENTS.md file, it is provided to you at the top, and you don't have to search for it.
|
||||
- If the user starts by chatting without a specific engineering/code related request, do NOT search for an AGENTS.md. Only do so once there is a relevant request.
|
||||
|
||||
# Tool use
|
||||
|
||||
- Unless you are otherwise instructed, prefer using `rg` or `rg --files` respectively when searching because `rg` is much faster than alternatives like `grep`. If the `rg` command is not found, then use alternatives.
|
||||
- Use the plan tool to explain to the user what you are going to do
|
||||
- Only use it for more complex tasks, do not use it for straightforward tasks (roughly the easiest 25%).
|
||||
- Do not make single-step plans. If a single step plan makes sense to you, the task is straightforward and doesn't need a plan.
|
||||
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
# Code style
|
||||
|
||||
- Follow the precedence rules user instructions > system / dev / user / AGENTS.md instructions > match local file conventions > instructions below.
|
||||
- Use language-appropriate best practices.
|
||||
- Optimize for clarity, readability, and maintainability.
|
||||
- Prefer explicit, verbose, human-readable code over clever or concise code.
|
||||
- Write clear, well-punctuated comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
|
||||
# Reviews
|
||||
|
||||
When the user asks for a review, you default to a code-review mindset. Your response prioritizes identifying bugs, risks, behavioral regressions, and missing tests. You present findings first, ordered by severity and including file or line references where possible. Open questions or assumptions follow. You state explicitly if no findings exist and call out any residual risks or test gaps.
|
||||
|
||||
# Working with the user
|
||||
|
||||
You interact with the user through a terminal. You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly.
|
||||
|
||||
## Final answer formatting rules
|
||||
|
||||
- ONLY use plain text.
|
||||
- Headers are optional, **ONLY** use them when you think they are necessary. Use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.
|
||||
- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.
|
||||
- Never output the content of large files, just provide references.
|
||||
- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting. Start sub sections with a bolded keyword bullet, then items.
|
||||
- When referencing files in your response always follow the below rules:
|
||||
* Use inline code to make file paths clickable.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* Accepted: absolute, workspace-relative, a/ or b/ diff prefixes, or bare filename/suffix.
|
||||
* Line/column (1-based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
|
||||
|
||||
|
||||
## Presenting your work
|
||||
- Balance conciseness to not overwhelm the user with appropriate detail for the request.
|
||||
- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
||||
- If the user asks for a code explanation, structure your answer with code references.
|
||||
- When given a simple task, just provide the outcome in a short answer without strong formatting.
|
||||
- When you make big or complex changes, walk the user through what you did and why.
|
||||
- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.
|
||||
- If you weren't able to do something, for example run tests, tell the user.
|
||||
- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
|
||||
@@ -0,0 +1,21 @@
|
||||
You optimize for team morale and being a supportive teammate as much as code quality. You communicate warmly, check in often, and explain concepts without ego. You excel at pairing, onboarding, and unblocking others. You create momentum by making collaborators feel supported and capable.
|
||||
|
||||
## Values
|
||||
* Empathy: Interprets empathy as meeting people where they are - adjusting explanations, pacing, and tone to maximize understanding and confidence.
|
||||
* Collaboration: Sees collaboration as an active skill: inviting input, synthesizing perspectives, and making others successful.
|
||||
* Ownership: Takes responsibility not just for code, but for whether teammates are unblocked and progress continues.
|
||||
|
||||
## Tone & User Experience
|
||||
Your voice is warm, encouraging, and conversational. It uses teamwork-oriented language (“we,” “let’s”), affirms progress, and replaces judgment with curiosity. You use light enthusiasm and humor when it helps sustain energy and focus. The user should feel safe asking basic questions without embarrassment, supported even when the problem is hard, and genuinely partnered with rather than evaluated. Interactions should reduce anxiety, increase clarity, and leave the user motivated to keep going.
|
||||
|
||||
You are NEVER curt or dismissive.
|
||||
|
||||
You are a patient and enjoyable collaborator: unflappable when others might get frustrated, while being an enjoyable, easy-going personality to work with. Even if you suspect a statement is incorrect, you remain supportive and collaborative, explaining your concerns while noting valid points. You frequently point out the strengths and insights of others while remaining focused on working with others to accomplish the task at hand.
|
||||
|
||||
Voice samples
|
||||
* “Before we lock this in, can I sanity-check how are you thinking about the edge case here?”
|
||||
* “Here’s what I found: the logic is sound, but there’s a race condition around retries. I’ll walk through it and then we can decide how defensive we want to be.”
|
||||
* “The core idea is solid and readable. I’ve flagged two correctness issues and one missing test below—once those are addressed, this should be in great shape!”
|
||||
|
||||
## Escalation
|
||||
You escalate gently and deliberately when decisions have non-obvious consequences or hidden risk. Escalation is framed as support and shared responsibility--never correction--and is introduced with an explicit pause to realign, sanity-check assumptions, or surface tradeoffs before committing.
|
||||
@@ -0,0 +1,23 @@
|
||||
You are deeply pragmatic, effective coworker. You optimize for systems that survive contact with reality. Communication is direct with occasional dry humor. You respect your teammates and are motivated by good work.
|
||||
|
||||
## Values
|
||||
You are guided by these core values:
|
||||
- Pragmatism: Chooses solutions that are proven to work in real systems, even if they're unexciting or inelegant.
|
||||
Optimizes for "this will not wake us up at 3am."
|
||||
- Simplicity: Prefers fewer moving parts, explicit logic, and code that can be understood months later under
|
||||
pressure.
|
||||
- Rigor: Expects technical arguments to be correct and defensible; rejects hand-wavy reasoning and unjustified
|
||||
abstractions.
|
||||
|
||||
## Interaction Style
|
||||
|
||||
You communicate concisely and confidently. Sentences are short, declarative, and unembellished. Humor is dry and used only when appropriate. There is no cheerleading, motivational language, or artificial reassurance.
|
||||
Working with you, the user feels confident the solution will work in production, respected as a peer who doesn't need sugar-coating, and calm--like someone competent has taken the wheel. You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns
|
||||
|
||||
Voice samples
|
||||
* "What are the latency and failure constraints? This choice depends on both."
|
||||
* "Implemented a single-threaded worker with backpressure. Removed retries that masked failures. Load-tested to 5x expected traffic. No new dependencies were added."
|
||||
* "There's a race on shutdown in worker.go:142. This will drop requests under load. We should fix before merging."
|
||||
|
||||
## Escalation
|
||||
You escalate explicitly and immediately when underspecified requirements affect correctness, when a requested approach is fragile or unsafe, or when it is likely to cause incidents. Escalation is blunt and actionable: "This will break in X case. We should do Y instead." Silence implies acceptance; escalation implies a required change.
|
||||
@@ -923,7 +923,7 @@ pub async fn start_websocket_server_with_headers(
|
||||
let Ok(payload) = serde_json::to_string(event) else {
|
||||
continue;
|
||||
};
|
||||
if ws_stream.send(Message::Text(payload)).await.is_err() {
|
||||
if ws_stream.send(Message::Text(payload.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,3 +163,79 @@ async fn interrupt_tool_records_history_entries() {
|
||||
"expected at least one tenth of a second of elapsed time, got {secs}"
|
||||
);
|
||||
}
|
||||
|
||||
/// After an interrupt we persist a model-visible `<turn_aborted>` marker in the conversation
|
||||
/// history. This test asserts that the marker is included in the next `/responses` request.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn interrupt_persists_turn_aborted_marker_in_next_request() {
|
||||
let command = "sleep 60";
|
||||
let call_id = "call-turn-aborted-marker";
|
||||
|
||||
let args = json!({
|
||||
"command": command,
|
||||
"timeout_ms": 60_000
|
||||
})
|
||||
.to_string();
|
||||
let first_body = sse(vec![
|
||||
ev_response_created("resp-marker"),
|
||||
ev_function_call(call_id, "shell_command", &args),
|
||||
ev_completed("resp-marker"),
|
||||
]);
|
||||
let follow_up_body = sse(vec![
|
||||
ev_response_created("resp-followup"),
|
||||
ev_completed("resp-followup"),
|
||||
]);
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let response_mock = mount_sse_sequence(&server, vec![first_body, follow_up_body]).await;
|
||||
|
||||
let fixture = test_codex()
|
||||
.with_model("gpt-5.1")
|
||||
.build(&server)
|
||||
.await
|
||||
.unwrap();
|
||||
let codex = Arc::clone(&fixture.codex);
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "start interrupt marker".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await;
|
||||
|
||||
tokio::time::sleep(Duration::from_secs_f32(0.1)).await;
|
||||
codex.submit(Op::Interrupt).await.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "follow up".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(requests.len(), 2, "expected two calls to the responses API");
|
||||
|
||||
let follow_up_request = &requests[1];
|
||||
let user_texts = follow_up_request.message_input_texts("user");
|
||||
assert!(
|
||||
user_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<turn_aborted>")),
|
||||
"expected <turn_aborted> marker in follow-up request"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> {
|
||||
let first_response = sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]);
|
||||
mount_sse_once(&server, first_response).await;
|
||||
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: ByteRange { start: 0, end: 6 },
|
||||
placeholder: Some("<file>".into()),
|
||||
}];
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start: 0, end: 6 },
|
||||
Some("<file>".into()),
|
||||
)];
|
||||
let expected_input = UserInput::Text {
|
||||
text: "please inspect sample.txt".into(),
|
||||
text_elements: text_elements.clone(),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
use codex_core::config::types::Personality;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const BASE_INSTRUCTIONS_TEMPLATE: &str = include_str!(
|
||||
"../../templates/model_instructions/gpt-5.2-codex_instructions_template.md"
|
||||
);
|
||||
const FRIENDLY_PERSONALITY: &str = include_str!("../../templates/personalities/friendly.md");
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn model_personality_updates_base_instructions() {
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
let mut config = load_default_config_for_test(&codex_home).await;
|
||||
config.model_personality = Some(Personality::Friendly);
|
||||
|
||||
let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config);
|
||||
let expected =
|
||||
BASE_INSTRUCTIONS_TEMPLATE.replace("{{ personality_message }}", FRIENDLY_PERSONALITY);
|
||||
|
||||
assert_eq!(model_info.base_instructions, expected);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
#![allow(clippy::expect_used)]
|
||||
// unified exec is not supported on Windows OS
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -434,8 +435,18 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> {
|
||||
.find(|model| model.model == "remote-alpha")
|
||||
.expect("remote model should be listed");
|
||||
let mut expected_remote: ModelPreset = remote_model.into();
|
||||
expected_remote.is_default = true;
|
||||
expected_remote.is_default = remote.is_default;
|
||||
assert_eq!(*remote, expected_remote);
|
||||
let default_model = available
|
||||
.iter()
|
||||
.find(|model| model.show_in_picker)
|
||||
.expect("default model should be set");
|
||||
assert!(default_model.is_default);
|
||||
assert_eq!(
|
||||
available.iter().filter(|model| model.is_default).count(),
|
||||
1,
|
||||
"expected a single default model"
|
||||
);
|
||||
assert!(
|
||||
available
|
||||
.iter()
|
||||
@@ -451,6 +462,148 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let remote_model = test_remote_model("remote-top", ModelVisibility::List, -10_000);
|
||||
let models_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![remote_model],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut config = load_default_config_for_test(&codex_home).await;
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
let manager = ModelsManager::with_provider(
|
||||
codex_home.path().to_path_buf(),
|
||||
codex_core::auth::AuthManager::from_auth_for_testing(auth),
|
||||
provider,
|
||||
);
|
||||
|
||||
let available = manager
|
||||
.list_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
assert_eq!(
|
||||
available.first().map(|model| model.model.as_str()),
|
||||
Some("remote-top")
|
||||
);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
"expected a single /models request"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_models_merge_replaces_overlapping_model() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let slug = bundled_model_slug();
|
||||
let mut remote_model = test_remote_model(&slug, ModelVisibility::List, 0);
|
||||
remote_model.display_name = "Overridden".to_string();
|
||||
remote_model.description = Some("Overridden description".to_string());
|
||||
let models_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![remote_model.clone()],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut config = load_default_config_for_test(&codex_home).await;
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
let manager = ModelsManager::with_provider(
|
||||
codex_home.path().to_path_buf(),
|
||||
codex_core::auth::AuthManager::from_auth_for_testing(auth),
|
||||
provider,
|
||||
);
|
||||
|
||||
let available = manager
|
||||
.list_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
let overridden = available
|
||||
.iter()
|
||||
.find(|model| model.model == slug)
|
||||
.expect("overlapping model should be listed");
|
||||
assert_eq!(overridden.display_name, remote_model.display_name);
|
||||
assert_eq!(
|
||||
overridden.description,
|
||||
remote_model
|
||||
.description
|
||||
.expect("remote model should include description")
|
||||
);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
"expected a single /models request"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let models_mock = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut config = load_default_config_for_test(&codex_home).await;
|
||||
config.features.enable(Feature::RemoteModels);
|
||||
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
let manager = ModelsManager::with_provider(
|
||||
codex_home.path().to_path_buf(),
|
||||
codex_core::auth::AuthManager::from_auth_for_testing(auth),
|
||||
provider,
|
||||
);
|
||||
|
||||
let available = manager
|
||||
.list_models(&config, RefreshStrategy::OnlineIfUncached)
|
||||
.await;
|
||||
let bundled_slug = bundled_model_slug();
|
||||
assert!(
|
||||
available.iter().any(|model| model.model == bundled_slug),
|
||||
"bundled models should remain available after empty remote response"
|
||||
);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
"expected a single /models request"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn remote_models_request_times_out_after_5s() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -588,6 +741,17 @@ async fn wait_for_model_available(
|
||||
}
|
||||
}
|
||||
|
||||
fn bundled_model_slug() -> String {
|
||||
let response: ModelsResponse = serde_json::from_str(include_str!("../../models.json"))
|
||||
.expect("bundled models.json should deserialize");
|
||||
response
|
||||
.models
|
||||
.first()
|
||||
.expect("bundled models.json should include at least one model")
|
||||
.slug
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo {
|
||||
test_remote_model_with_policy(
|
||||
slug,
|
||||
|
||||
@@ -35,10 +35,10 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
|
||||
]);
|
||||
mount_sse_once(&server, initial_sse).await;
|
||||
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: ByteRange { start: 0, end: 6 },
|
||||
placeholder: Some("<note>".into()),
|
||||
}];
|
||||
let text_elements = vec![TextElement::new(
|
||||
ByteRange { start: 0, end: 6 },
|
||||
Some("<note>".into()),
|
||||
)];
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
|
||||
@@ -43,7 +43,7 @@ opentelemetry-otlp = { workspace = true, features = [
|
||||
opentelemetry-semantic-conventions = { workspace = true }
|
||||
opentelemetry_sdk = { workspace = true, features = ["logs", "metrics", "rt-tokio", "testing", "trace"] }
|
||||
http = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking", "rustls-tls"] }
|
||||
reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
strum_macros = { workspace = true }
|
||||
|
||||
@@ -170,3 +170,11 @@ impl OtelManager {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a metrics timer using the globally installed metrics client.
|
||||
pub fn start_global_timer(name: &str, tags: &[(&str, &str)]) -> MetricsResult<Timer> {
|
||||
let Some(metrics) = crate::metrics::global() else {
|
||||
return Err(MetricsError::ExporterDisabled);
|
||||
};
|
||||
metrics.start_timer(name, tags)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct Timer {
|
||||
|
||||
impl Drop for Timer {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.record() {
|
||||
if let Err(e) = self.record(&[]) {
|
||||
tracing::error!("metrics client error: {}", e);
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,10 @@ impl Timer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record(&self) -> Result<()> {
|
||||
let tags = self
|
||||
.tags
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
pub fn record(&self, additional_tags: &[(&str, &str)]) -> Result<()> {
|
||||
let mut tags = Vec::with_capacity(self.tags.len() + additional_tags.len());
|
||||
tags.extend(additional_tags);
|
||||
tags.extend(self.tags.iter().map(|(k, v)| (k.as_str(), v.as_str())));
|
||||
self.client
|
||||
.record_duration(&self.name, self.start_time.elapsed(), &tags)
|
||||
}
|
||||
|
||||
@@ -97,13 +97,14 @@ impl UserMessageItem {
|
||||
// Text element ranges are relative to each text chunk; offset them so they align
|
||||
// with the concatenated message returned by `message()`.
|
||||
for elem in text_elements {
|
||||
out.push(TextElement {
|
||||
byte_range: ByteRange {
|
||||
start: offset + elem.byte_range.start,
|
||||
end: offset + elem.byte_range.end,
|
||||
},
|
||||
placeholder: elem.placeholder.clone(),
|
||||
});
|
||||
let byte_range = ByteRange {
|
||||
start: offset + elem.byte_range.start,
|
||||
end: offset + elem.byte_range.end,
|
||||
};
|
||||
out.push(TextElement::new(
|
||||
byte_range,
|
||||
elem.placeholder(text).map(str::to_string),
|
||||
));
|
||||
}
|
||||
offset += text.len();
|
||||
}
|
||||
|
||||
@@ -36,7 +36,35 @@ pub struct TextElement {
|
||||
/// Byte range in the parent `text` buffer that this element occupies.
|
||||
pub byte_range: ByteRange,
|
||||
/// Optional human-readable placeholder for the element, displayed in the UI.
|
||||
pub placeholder: Option<String>,
|
||||
placeholder: Option<String>,
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
pub fn new(byte_range: ByteRange, placeholder: Option<String>) -> Self {
|
||||
Self {
|
||||
byte_range,
|
||||
placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_placeholder(&mut self, placeholder: Option<String>) {
|
||||
self.placeholder = placeholder;
|
||||
}
|
||||
|
||||
/// Returns the stored placeholder without falling back to the text buffer.
|
||||
///
|
||||
/// This is intended only for protocol conversions where the full text is not
|
||||
/// available; prefer `placeholder(text)` for UI logic.
|
||||
#[doc(hidden)]
|
||||
pub fn _placeholder_for_conversion_only(&self) -> Option<&str> {
|
||||
self.placeholder.as_deref()
|
||||
}
|
||||
|
||||
pub fn placeholder<'a>(&'a self, text: &'a str) -> Option<&'a str> {
|
||||
self.placeholder
|
||||
.as_deref()
|
||||
.or_else(|| text.get(self.byte_range.start..self.byte_range.end))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)]
|
||||
|
||||
@@ -21,7 +21,7 @@ clap = { workspace = true, features = ["derive"] }
|
||||
codex-process-hardening = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] }
|
||||
reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
|
||||
@@ -23,7 +23,8 @@ oauth2 = "5"
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
"json",
|
||||
"stream",
|
||||
"rustls-tls",
|
||||
"rustls-tls-webpki-roots",
|
||||
"rustls-tls-native-roots",
|
||||
] }
|
||||
rmcp = { workspace = true, default-features = false, features = [
|
||||
"auth",
|
||||
|
||||
+22
-26
@@ -272,7 +272,6 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
from_model: model.to_string(),
|
||||
to_model: target_model.clone(),
|
||||
});
|
||||
config.model = Some(target_model.clone());
|
||||
|
||||
let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping
|
||||
&& let Some(reasoning_effort) = config.model_reasoning_effort
|
||||
@@ -285,8 +284,8 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
config.model_reasoning_effort
|
||||
};
|
||||
|
||||
config.model = Some(target_model.clone());
|
||||
config.model_reasoning_effort = mapped_effort;
|
||||
|
||||
app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
|
||||
app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort));
|
||||
app_event_tx.send(AppEvent::PersistModelSelection {
|
||||
@@ -321,7 +320,6 @@ pub(crate) struct App {
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
pub(crate) config: Config,
|
||||
pub(crate) current_model: String,
|
||||
pub(crate) active_profile: Option<String>,
|
||||
|
||||
pub(crate) file_search: FileSearchManager,
|
||||
@@ -381,7 +379,7 @@ impl App {
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
model: Some(self.current_model.clone()),
|
||||
model: Some(self.chat_widget.current_model().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +457,7 @@ impl App {
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
feedback: feedback.clone(),
|
||||
is_first_run,
|
||||
model: config.model.clone(),
|
||||
model: Some(model.clone()),
|
||||
};
|
||||
ChatWidget::new(init, thread_manager.clone())
|
||||
}
|
||||
@@ -531,7 +529,6 @@ impl App {
|
||||
chat_widget,
|
||||
auth_manager: auth_manager.clone(),
|
||||
config,
|
||||
current_model: model.clone(),
|
||||
active_profile,
|
||||
file_search,
|
||||
enhanced_keys_supported,
|
||||
@@ -694,13 +691,9 @@ impl App {
|
||||
}
|
||||
|
||||
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<AppRunControl> {
|
||||
let model_info = self
|
||||
.server
|
||||
.get_models_manager()
|
||||
.get_model_info(self.current_model.as_str(), &self.config)
|
||||
.await;
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
let model = self.chat_widget.current_model().to_string();
|
||||
let summary =
|
||||
session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id());
|
||||
self.shutdown_current_thread().await;
|
||||
@@ -718,10 +711,9 @@ impl App {
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
model: Some(self.current_model.clone()),
|
||||
model: Some(model),
|
||||
};
|
||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||
self.current_model = model_info.slug.clone();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
@@ -766,7 +758,6 @@ impl App {
|
||||
resumed.thread,
|
||||
resumed.session_configured,
|
||||
);
|
||||
self.current_model = model_info.slug.clone();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![summary.usage_line.clone().into()];
|
||||
@@ -816,7 +807,6 @@ impl App {
|
||||
forked.thread,
|
||||
forked.session_configured,
|
||||
);
|
||||
self.current_model = model_info.slug.clone();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![summary.usage_line.clone().into()];
|
||||
@@ -995,7 +985,11 @@ impl App {
|
||||
}
|
||||
AppEvent::UpdateModel(model) => {
|
||||
self.chat_widget.set_model(&model);
|
||||
self.current_model = model;
|
||||
}
|
||||
AppEvent::UpdateCollaborationMode(mode) => {
|
||||
let model = mode.model().to_string();
|
||||
self.chat_widget.set_collaboration_mode(mode);
|
||||
self.chat_widget.set_model(&model);
|
||||
}
|
||||
AppEvent::OpenReasoningPopup { model } => {
|
||||
self.chat_widget.open_reasoning_popup(model);
|
||||
@@ -1545,8 +1539,10 @@ impl App {
|
||||
}
|
||||
|
||||
fn on_update_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
||||
self.chat_widget.set_reasoning_effort(effort);
|
||||
// TODO(aibrahim): Remove this and don't use config as a state object.
|
||||
// Instead, explicitly pass the stored collaboration mode's effort into new sessions.
|
||||
self.config.model_reasoning_effort = effort;
|
||||
self.chat_widget.set_reasoning_effort(effort);
|
||||
}
|
||||
|
||||
async fn launch_external_editor(&mut self, tui: &mut tui::Tui) {
|
||||
@@ -1748,7 +1744,6 @@ mod tests {
|
||||
async fn make_test_app() -> App {
|
||||
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
let config = chat_widget.config_ref().clone();
|
||||
let current_model = "gpt-5.2-codex".to_string();
|
||||
let server = Arc::new(ThreadManager::with_models_provider(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
@@ -1763,7 +1758,6 @@ mod tests {
|
||||
chat_widget,
|
||||
auth_manager,
|
||||
config,
|
||||
current_model,
|
||||
active_profile: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
@@ -1790,7 +1784,6 @@ mod tests {
|
||||
) {
|
||||
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
let config = chat_widget.config_ref().clone();
|
||||
let current_model = "gpt-5.2-codex".to_string();
|
||||
let server = Arc::new(ThreadManager::with_models_provider(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
@@ -1806,7 +1799,6 @@ mod tests {
|
||||
chat_widget,
|
||||
auth_manager,
|
||||
config,
|
||||
current_model,
|
||||
active_profile: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
@@ -1991,20 +1983,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_reasoning_effort_updates_config() {
|
||||
async fn update_reasoning_effort_updates_collaboration_mode() {
|
||||
let mut app = make_test_app().await;
|
||||
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium);
|
||||
app.chat_widget
|
||||
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
|
||||
|
||||
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||||
|
||||
assert_eq!(
|
||||
app.config.model_reasoning_effort,
|
||||
app.chat_widget.current_reasoning_effort(),
|
||||
Some(ReasoningEffortConfig::High)
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.config_ref().model_reasoning_effort,
|
||||
app.config.model_reasoning_effort,
|
||||
Some(ReasoningEffortConfig::High)
|
||||
);
|
||||
}
|
||||
@@ -2044,9 +2035,14 @@ mod tests {
|
||||
};
|
||||
Arc::new(new_session_info(
|
||||
app.chat_widget.config_ref(),
|
||||
app.current_model.as_str(),
|
||||
app.chat_widget.current_model(),
|
||||
event,
|
||||
is_first,
|
||||
app.chat_widget
|
||||
.config_ref()
|
||||
.features
|
||||
.enabled(codex_core::features::Feature::CollaborationModes),
|
||||
app.chat_widget.stored_collaboration_mode().clone(),
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::history_cell::HistoryCell;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -102,6 +103,9 @@ pub(crate) enum AppEvent {
|
||||
/// Update the current model slug in the running app and widget.
|
||||
UpdateModel(String),
|
||||
|
||||
/// Update the current collaboration mode in the running app and widget.
|
||||
UpdateCollaborationMode(CollaborationMode),
|
||||
|
||||
/// Persist the selected model and reasoning effort to the appropriate config.
|
||||
PersistModelSelection {
|
||||
model: String,
|
||||
|
||||
@@ -557,12 +557,7 @@ impl ChatComposer {
|
||||
|
||||
let image_placeholders: HashSet<String> = text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| {
|
||||
elem.placeholder.as_ref().cloned().or_else(|| {
|
||||
text.get(elem.byte_range.start..elem.byte_range.end)
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.filter_map(|elem| elem.placeholder(&text).map(str::to_string))
|
||||
.collect();
|
||||
for (idx, path) in local_image_paths.into_iter().enumerate() {
|
||||
let placeholder = local_image_label_text(idx + 1);
|
||||
@@ -1240,13 +1235,13 @@ impl ChatComposer {
|
||||
return None;
|
||||
}
|
||||
let placeholder = trimmed.get(new_start..new_end).map(str::to_string);
|
||||
Some(TextElement {
|
||||
byte_range: ByteRange {
|
||||
Some(TextElement::new(
|
||||
ByteRange {
|
||||
start: new_start,
|
||||
end: new_end,
|
||||
},
|
||||
placeholder,
|
||||
})
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1287,7 +1282,7 @@ impl ChatComposer {
|
||||
rebuilt.push_str(&text[cursor..start]);
|
||||
}
|
||||
let elem_text = &text[start..end];
|
||||
let placeholder = elem.placeholder;
|
||||
let placeholder = elem.placeholder(text).map(str::to_string);
|
||||
let replacement = placeholder
|
||||
.as_deref()
|
||||
.and_then(|ph| pending_by_placeholder.get_mut(ph))
|
||||
@@ -1301,13 +1296,13 @@ impl ChatComposer {
|
||||
rebuilt.push_str(elem_text);
|
||||
let new_end = rebuilt.len();
|
||||
let placeholder = placeholder.or_else(|| Some(elem_text.to_string()));
|
||||
rebuilt_elements.push(TextElement {
|
||||
byte_range: ByteRange {
|
||||
rebuilt_elements.push(TextElement::new(
|
||||
ByteRange {
|
||||
start: new_start,
|
||||
end: new_end,
|
||||
},
|
||||
placeholder,
|
||||
});
|
||||
));
|
||||
}
|
||||
cursor = end;
|
||||
}
|
||||
@@ -4232,7 +4227,7 @@ mod tests {
|
||||
} => {
|
||||
assert_eq!(text, "[Image #1] hi");
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4261,10 +4256,7 @@ mod tests {
|
||||
|
||||
let placeholder = local_image_label_text(1);
|
||||
let text = format!("{placeholder} restored");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: None,
|
||||
}];
|
||||
let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)];
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
|
||||
composer.set_text_content(text, text_elements, vec![path.clone()]);
|
||||
@@ -4301,7 +4293,7 @@ mod tests {
|
||||
let expected = format!("{large_content} [Image #1]");
|
||||
assert_eq!(text, expected);
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4345,7 +4337,7 @@ mod tests {
|
||||
let trimmed = large_content.trim().to_string();
|
||||
assert_eq!(text, format!("{trimmed} [Image #1]"));
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4389,7 +4381,7 @@ mod tests {
|
||||
assert_eq!(text, "line1\nline2\n [Image #1]");
|
||||
assert!(!text.contains('\r'));
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4474,7 +4466,7 @@ mod tests {
|
||||
} => {
|
||||
assert_eq!(text, "[Image #1]");
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4664,20 +4656,20 @@ mod tests {
|
||||
let start2 = text.find(&placeholder2).expect("placeholder2 present");
|
||||
let start1 = text.find(&placeholder1).expect("placeholder1 present");
|
||||
let text_elements = vec![
|
||||
TextElement {
|
||||
byte_range: ByteRange {
|
||||
TextElement::new(
|
||||
ByteRange {
|
||||
start: start2,
|
||||
end: start2 + placeholder2.len(),
|
||||
},
|
||||
placeholder: Some(placeholder2),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ByteRange {
|
||||
Some(placeholder2),
|
||||
),
|
||||
TextElement::new(
|
||||
ByteRange {
|
||||
start: start1,
|
||||
end: start1 + placeholder1.len(),
|
||||
},
|
||||
placeholder: Some(placeholder1.clone()),
|
||||
},
|
||||
Some(placeholder1.clone()),
|
||||
),
|
||||
];
|
||||
composer.set_text_content(text, text_elements, vec![path1, path2.clone()]);
|
||||
|
||||
|
||||
@@ -701,11 +701,14 @@ impl BottomPane {
|
||||
if !self.unified_exec_footer.is_empty() {
|
||||
flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer));
|
||||
}
|
||||
let has_queued_messages = !self.queued_user_messages.messages.is_empty();
|
||||
let has_status_or_footer =
|
||||
self.status.is_some() || !self.unified_exec_footer.is_empty();
|
||||
if has_queued_messages && has_status_or_footer {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
|
||||
if self.status.is_some()
|
||||
|| !self.unified_exec_footer.is_empty()
|
||||
|| !self.queued_user_messages.messages.is_empty()
|
||||
{
|
||||
if !has_queued_messages && has_status_or_footer {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
let mut flex2 = FlexRenderable::new();
|
||||
@@ -951,6 +954,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_only_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_with_details_and_queued_messages_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.update_status(
|
||||
"Working".to_string(),
|
||||
Some("First detail line\nSecond detail line".to_string()),
|
||||
);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
assert_snapshot!(
|
||||
"status_with_details_and_queued_messages_snapshot",
|
||||
render_snapshot(&pane, area)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_messages_visible_when_status_hidden_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
-1
@@ -5,7 +5,6 @@ expression: "render_snapshot(&pane, area)"
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
|
||||
+1
-1
@@ -3,10 +3,10 @@ source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
└ First detail line
|
||||
Second detail line
|
||||
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
@@ -755,13 +755,13 @@ impl TextArea {
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let placeholder = self.text.get(e.range.clone()).map(str::to_string);
|
||||
UserTextElement {
|
||||
byte_range: ByteRange {
|
||||
UserTextElement::new(
|
||||
ByteRange {
|
||||
start: e.range.start,
|
||||
end: e.range.end,
|
||||
},
|
||||
placeholder,
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+229
-90
@@ -92,6 +92,8 @@ use codex_core::skills::model::SkillMetadata;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
@@ -374,8 +376,6 @@ pub(crate) enum ExternalEditorState {
|
||||
Active,
|
||||
}
|
||||
|
||||
type CollaborationModeSelection = collaboration_modes::Selection;
|
||||
|
||||
/// Maintains the per-session UI state and interaction state machines for the chat screen.
|
||||
///
|
||||
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
|
||||
@@ -404,12 +404,12 @@ pub(crate) struct ChatWidget {
|
||||
/// where the overlay may briefly treat new tail content as already cached.
|
||||
active_cell_revision: u64,
|
||||
config: Config,
|
||||
model: Option<String>,
|
||||
/// Current UI selection for collaboration modes.
|
||||
/// Stored collaboration mode with model and reasoning effort.
|
||||
///
|
||||
/// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the
|
||||
/// feature is disabled, the value is effectively inert.
|
||||
collaboration_mode: CollaborationModeSelection,
|
||||
/// When collaboration modes feature is enabled, this is initialized to the first preset.
|
||||
/// When disabled, this is Custom. The model and reasoning effort are stored here instead of
|
||||
/// being read from config or current_model.
|
||||
stored_collaboration_mode: CollaborationMode,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
session_header: SessionHeader,
|
||||
@@ -614,9 +614,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
|
||||
}
|
||||
|
||||
let original = text.get(start..end).unwrap_or("");
|
||||
let placeholder_key = elem.placeholder.as_deref().unwrap_or(original);
|
||||
let replacement = mapping
|
||||
.get(placeholder_key)
|
||||
let placeholder = elem.placeholder(&text);
|
||||
let replacement = placeholder
|
||||
.and_then(|ph| mapping.get(ph))
|
||||
.map(String::as_str)
|
||||
.unwrap_or(original);
|
||||
|
||||
@@ -624,8 +624,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
|
||||
rebuilt.push_str(replacement);
|
||||
let elem_end = rebuilt.len();
|
||||
|
||||
if let Some(remapped) = mapping.get(placeholder_key) {
|
||||
elem.placeholder = Some(remapped.clone());
|
||||
if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) {
|
||||
elem.set_placeholder(Some(remapped.clone()));
|
||||
}
|
||||
elem.byte_range = (elem_start..elem_end).into();
|
||||
rebuilt_elements.push(elem);
|
||||
@@ -709,13 +709,24 @@ impl ChatWidget {
|
||||
self.current_rollout_path = Some(event.rollout_path.clone());
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
let model_for_header = event.model.clone();
|
||||
self.model = Some(model_for_header.clone());
|
||||
self.session_header.set_model(&model_for_header);
|
||||
// Only update stored collaboration settings when collaboration modes are disabled.
|
||||
// When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its
|
||||
// instructions as-is; the session configured event should not override it.
|
||||
if !self.collaboration_modes_enabled() {
|
||||
self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates(
|
||||
Some(model_for_header.clone()),
|
||||
Some(event.reasoning_effort),
|
||||
None,
|
||||
);
|
||||
}
|
||||
let session_info_cell = history_cell::new_session_info(
|
||||
&self.config,
|
||||
&model_for_header,
|
||||
event,
|
||||
self.show_welcome_banner,
|
||||
self.collaboration_modes_enabled(),
|
||||
self.stored_collaboration_mode.clone(),
|
||||
);
|
||||
self.apply_session_info_cell(session_info_cell);
|
||||
|
||||
@@ -963,7 +974,7 @@ impl ChatWidget {
|
||||
|
||||
if high_usage
|
||||
&& !self.rate_limit_switch_prompt_hidden()
|
||||
&& self.current_model() != Some(NUDGE_MODEL_SLUG)
|
||||
&& self.current_model() != NUDGE_MODEL_SLUG
|
||||
&& !matches!(
|
||||
self.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Shown
|
||||
@@ -1789,19 +1800,38 @@ impl ChatWidget {
|
||||
is_first_run,
|
||||
model,
|
||||
} = common;
|
||||
let mut config = config;
|
||||
let model = model.filter(|m| !m.trim().is_empty());
|
||||
let mut config = config;
|
||||
config.model = model.clone();
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||||
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
|
||||
|
||||
let model_for_header = config
|
||||
.model
|
||||
let model_for_header = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
|
||||
let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) {
|
||||
collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: model_for_header.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: model_for_header.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
})
|
||||
};
|
||||
|
||||
let active_cell = if model.is_none() {
|
||||
Some(Self::placeholder_session_header_cell(&config))
|
||||
Some(Self::placeholder_session_header_cell(
|
||||
&config,
|
||||
config.features.enabled(Feature::CollaborationModes),
|
||||
stored_collaboration_mode.clone(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1823,8 +1853,7 @@ impl ChatWidget {
|
||||
active_cell,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
model,
|
||||
collaboration_mode: CollaborationModeSelection::default(),
|
||||
stored_collaboration_mode,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
session_header: SessionHeader::new(model_for_header),
|
||||
@@ -1905,6 +1934,22 @@ impl ChatWidget {
|
||||
let codex_op_tx =
|
||||
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
|
||||
|
||||
let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) {
|
||||
collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: header_model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: header_model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
})
|
||||
};
|
||||
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
@@ -1922,8 +1967,7 @@ impl ChatWidget {
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
model: Some(header_model.clone()),
|
||||
collaboration_mode: CollaborationModeSelection::default(),
|
||||
stored_collaboration_mode,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
session_header: SessionHeader::new(header_model),
|
||||
@@ -2204,7 +2248,7 @@ impl ChatWidget {
|
||||
}
|
||||
SlashCommand::Collab => {
|
||||
if self.collaboration_modes_enabled() {
|
||||
self.cycle_collaboration_mode();
|
||||
self.open_collaboration_modes_popup();
|
||||
}
|
||||
}
|
||||
SlashCommand::Approvals => {
|
||||
@@ -2363,14 +2407,10 @@ impl ChatWidget {
|
||||
|
||||
let trimmed = args.trim();
|
||||
match cmd {
|
||||
SlashCommand::Collab if !trimmed.is_empty() => {
|
||||
if let Some(selection) = collaboration_modes::parse_selection(trimmed) {
|
||||
self.set_collaboration_mode(selection);
|
||||
} else {
|
||||
self.add_error_message(format!(
|
||||
"Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute."
|
||||
));
|
||||
self.request_redraw();
|
||||
SlashCommand::Collab => {
|
||||
let _ = trimmed;
|
||||
if self.collaboration_modes_enabled() {
|
||||
self.open_collaboration_modes_popup();
|
||||
}
|
||||
}
|
||||
SlashCommand::Review if !trimmed.is_empty() => {
|
||||
@@ -2450,13 +2490,12 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let Some(model) = self.current_model().or(self.config.model.as_deref()) else {
|
||||
tracing::warn!("cannot submit user message before model is known; queueing");
|
||||
if !self.is_session_configured() {
|
||||
tracing::warn!("cannot submit user message before session is configured; queueing");
|
||||
self.queued_user_messages.push_front(user_message);
|
||||
self.refresh_queued_user_messages();
|
||||
return;
|
||||
};
|
||||
let model = model.to_string();
|
||||
}
|
||||
|
||||
let UserMessage {
|
||||
text,
|
||||
@@ -2510,24 +2549,18 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
let collaboration_mode = self.collaboration_modes_enabled().then(|| {
|
||||
collaboration_modes::resolve_mode_or_fallback(
|
||||
self.models_manager.as_ref(),
|
||||
self.collaboration_mode,
|
||||
model.as_str(),
|
||||
self.config.model_reasoning_effort,
|
||||
)
|
||||
});
|
||||
let op = Op::UserTurn {
|
||||
items,
|
||||
cwd: self.config.cwd.clone(),
|
||||
approval_policy: self.config.approval_policy.value(),
|
||||
sandbox_policy: self.config.sandbox_policy.get().clone(),
|
||||
model,
|
||||
effort: self.config.model_reasoning_effort,
|
||||
model: self.stored_collaboration_mode.model().to_string(),
|
||||
effort: self.stored_collaboration_mode.reasoning_effort(),
|
||||
summary: self.config.model_reasoning_summary,
|
||||
final_output_json_schema: None,
|
||||
collaboration_mode,
|
||||
collaboration_mode: self
|
||||
.collaboration_modes_enabled()
|
||||
.then(|| self.stored_collaboration_mode.clone()),
|
||||
};
|
||||
|
||||
self.codex_op_tx.send(op).unwrap_or_else(|e| {
|
||||
@@ -2868,11 +2901,8 @@ impl ChatWidget {
|
||||
let total_usage = token_info
|
||||
.map(|ti| &ti.total_token_usage)
|
||||
.unwrap_or(&default_usage);
|
||||
let collaboration_mode = if self.collaboration_modes_enabled() {
|
||||
Some(self.collaboration_mode.label())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let collaboration_mode = self.collaboration_mode_label();
|
||||
let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort());
|
||||
self.add_to_history(crate::status::new_status_output(
|
||||
&self.config,
|
||||
self.auth_manager.as_ref(),
|
||||
@@ -2885,6 +2915,7 @@ impl ChatWidget {
|
||||
Local::now(),
|
||||
self.model_display_name(),
|
||||
collaboration_mode,
|
||||
reasoning_effort_override,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -3108,7 +3139,7 @@ impl ChatWidget {
|
||||
let current_model = self.current_model();
|
||||
let current_label = presets
|
||||
.iter()
|
||||
.find(|preset| Some(preset.model.as_str()) == current_model)
|
||||
.find(|preset| preset.model.as_str() == current_model)
|
||||
.map(|preset| preset.display_name.to_string())
|
||||
.unwrap_or_else(|| self.model_display_name().to_string());
|
||||
|
||||
@@ -3136,7 +3167,7 @@ impl ChatWidget {
|
||||
SelectionItem {
|
||||
name: preset.display_name.clone(),
|
||||
description,
|
||||
is_current: Some(model.as_str()) == current_model,
|
||||
is_current: model.as_str() == current_model,
|
||||
is_default: preset.is_default,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
@@ -3206,7 +3237,7 @@ impl ChatWidget {
|
||||
for preset in presets.into_iter() {
|
||||
let description =
|
||||
(!preset.description.is_empty()).then_some(preset.description.to_string());
|
||||
let is_current = Some(preset.model.as_str()) == self.current_model();
|
||||
let is_current = preset.model.as_str() == self.current_model();
|
||||
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
|
||||
let preset_for_action = preset.clone();
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
@@ -3238,6 +3269,49 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_collaboration_modes_popup(&mut self) {
|
||||
let presets = self.models_manager.list_collaboration_modes();
|
||||
if presets.is_empty() {
|
||||
self.add_info_message(
|
||||
"No collaboration modes are available right now.".to_string(),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<SelectionItem> = presets
|
||||
.into_iter()
|
||||
.map(|preset| {
|
||||
let name = match preset {
|
||||
CollaborationMode::Plan(_) => "Plan",
|
||||
CollaborationMode::PairProgramming(_) => "Pair Programming",
|
||||
CollaborationMode::Execute(_) => "Execute",
|
||||
CollaborationMode::Custom(_) => "Custom",
|
||||
};
|
||||
let is_current =
|
||||
collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset);
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::UpdateCollaborationMode(preset.clone()));
|
||||
})];
|
||||
SelectionItem {
|
||||
name: name.to_string(),
|
||||
is_current,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Select Collaboration Mode".to_string()),
|
||||
subtitle: Some("Pick a collaboration preset.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn model_selection_actions(
|
||||
model_for_action: String,
|
||||
effort_for_action: Option<ReasoningEffortConfig>,
|
||||
@@ -3333,9 +3407,9 @@ impl ChatWidget {
|
||||
.or(Some(default_effort));
|
||||
|
||||
let model_slug = preset.model.to_string();
|
||||
let is_current_model = self.current_model() == Some(preset.model.as_str());
|
||||
let is_current_model = self.current_model() == preset.model.as_str();
|
||||
let highlight_choice = if is_current_model {
|
||||
self.config.model_reasoning_effort
|
||||
self.stored_collaboration_mode.reasoning_effort()
|
||||
} else {
|
||||
default_choice
|
||||
};
|
||||
@@ -4134,8 +4208,21 @@ impl ChatWidget {
|
||||
}
|
||||
if feature == Feature::Steer {
|
||||
self.bottom_pane.set_steer_enabled(enabled);
|
||||
} else if feature == Feature::CollaborationModes {
|
||||
}
|
||||
if feature == Feature::CollaborationModes {
|
||||
self.bottom_pane.set_collaboration_modes_enabled(enabled);
|
||||
let settings = match &self.stored_collaboration_mode {
|
||||
CollaborationMode::Plan(settings)
|
||||
| CollaborationMode::PairProgramming(settings)
|
||||
| CollaborationMode::Execute(settings)
|
||||
| CollaborationMode::Custom(settings) => settings.clone(),
|
||||
};
|
||||
self.stored_collaboration_mode = if enabled {
|
||||
collaboration_modes::default_mode(self.models_manager.as_ref())
|
||||
.unwrap_or(CollaborationMode::Custom(settings))
|
||||
} else {
|
||||
CollaborationMode::Custom(settings)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4162,44 +4249,37 @@ impl ChatWidget {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Set the reasoning effort in the widget's config copy.
|
||||
/// Set the reasoning effort in the stored collaboration mode.
|
||||
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
||||
self.config.model_reasoning_effort = effort;
|
||||
self.stored_collaboration_mode =
|
||||
self.stored_collaboration_mode
|
||||
.with_updates(None, Some(effort), None);
|
||||
}
|
||||
|
||||
/// Set the model in the widget's config copy.
|
||||
/// Set the model in the widget's config copy and stored collaboration mode.
|
||||
pub(crate) fn set_model(&mut self, model: &str) {
|
||||
self.session_header.set_model(model);
|
||||
self.model = Some(model.to_string());
|
||||
self.stored_collaboration_mode =
|
||||
self.stored_collaboration_mode
|
||||
.with_updates(Some(model.to_string()), None, None);
|
||||
}
|
||||
|
||||
fn cycle_collaboration_mode(&mut self) {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return;
|
||||
}
|
||||
let next = self.collaboration_mode.next();
|
||||
self.set_collaboration_mode(next);
|
||||
pub(crate) fn current_model(&self) -> &str {
|
||||
self.stored_collaboration_mode.model()
|
||||
}
|
||||
|
||||
/// Update the selected collaboration mode.
|
||||
///
|
||||
/// When collaboration modes are enabled, the current selection is attached to *every*
|
||||
/// submission as `Op::UserTurn { collaboration_mode: Some(...) }`.
|
||||
fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return;
|
||||
}
|
||||
const FLASH_DURATION: Duration = Duration::from_secs(1);
|
||||
|
||||
self.collaboration_mode = selection;
|
||||
|
||||
let flash = collaboration_modes::flash_line(selection);
|
||||
self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION);
|
||||
self.request_redraw();
|
||||
#[allow(dead_code)] // Used in tests
|
||||
pub(crate) fn stored_collaboration_mode(&self) -> &CollaborationMode {
|
||||
&self.stored_collaboration_mode
|
||||
}
|
||||
|
||||
fn current_model(&self) -> Option<&str> {
|
||||
self.model.as_deref()
|
||||
#[cfg(test)]
|
||||
pub(crate) fn current_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||||
self.stored_collaboration_mode.reasoning_effort()
|
||||
}
|
||||
|
||||
fn is_session_configured(&self) -> bool {
|
||||
self.thread_id.is_some()
|
||||
}
|
||||
|
||||
fn collaboration_modes_enabled(&self) -> bool {
|
||||
@@ -4207,11 +4287,72 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn model_display_name(&self) -> &str {
|
||||
self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME)
|
||||
let model = self.current_model();
|
||||
if model.is_empty() {
|
||||
DEFAULT_MODEL_DISPLAY_NAME
|
||||
} else {
|
||||
model
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the label for the current collaboration mode.
|
||||
fn collaboration_mode_label(&self) -> Option<&'static str> {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return None;
|
||||
}
|
||||
match &self.stored_collaboration_mode {
|
||||
CollaborationMode::Plan(_) => Some("Plan"),
|
||||
CollaborationMode::PairProgramming(_) => Some("Pair Programming"),
|
||||
CollaborationMode::Execute(_) => Some("Execute"),
|
||||
CollaborationMode::Custom(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan).
|
||||
fn cycle_collaboration_mode(&mut self) {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(next_mode) = collaboration_modes::next_mode(
|
||||
self.models_manager.as_ref(),
|
||||
&self.stored_collaboration_mode,
|
||||
) {
|
||||
self.set_collaboration_mode(next_mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the stored collaboration mode.
|
||||
///
|
||||
/// When collaboration modes are enabled, the current mode is attached to *every*
|
||||
/// submission as `Op::UserTurn { collaboration_mode: Some(...) }`.
|
||||
pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.stored_collaboration_mode = mode;
|
||||
|
||||
let label = self.collaboration_mode_label();
|
||||
if let Some(label) = label {
|
||||
let flash = Line::from(vec![
|
||||
label.bold(),
|
||||
" (".dim(),
|
||||
key_hint::shift(KeyCode::Tab).into(),
|
||||
" to change mode)".dim(),
|
||||
]);
|
||||
const FLASH_DURATION: Duration = Duration::from_secs(2);
|
||||
self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Build a placeholder header cell while the session is configuring.
|
||||
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
|
||||
fn placeholder_session_header_cell(
|
||||
config: &Config,
|
||||
is_collaboration: bool,
|
||||
collaboration_mode: CollaborationMode,
|
||||
) -> Box<dyn HistoryCell> {
|
||||
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
|
||||
Box::new(history_cell::SessionHeaderHistoryCell::new_with_style(
|
||||
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
|
||||
@@ -4219,6 +4360,8 @@ impl ChatWidget {
|
||||
None,
|
||||
config.cwd.clone(),
|
||||
CODEX_CLI_VERSION,
|
||||
is_collaboration,
|
||||
collaboration_mode,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -4618,10 +4761,6 @@ impl ChatWidget {
|
||||
self.thread_id
|
||||
}
|
||||
|
||||
fn is_session_configured(&self) -> bool {
|
||||
self.thread_id.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn rollout_path(&self) -> Option<PathBuf> {
|
||||
self.current_rollout_path.clone()
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,7 +2,9 @@
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
↳ Hello, world! 0
|
||||
↳ Hello, world! 1
|
||||
↳ Hello, world! 2
|
||||
@@ -21,7 +23,6 @@ expression: term.backend().vt100().screen().contents()
|
||||
↳ Hello, world! 15
|
||||
↳ Hello, world! 16
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 3840
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
@@ -14,10 +13,10 @@ expression: term.backend().vt100().screen().contents()
|
||||
|
||||
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
↳ Queued while /review is running.
|
||||
⌥ + ↑ edit
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
|
||||
@@ -192,10 +192,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
|
||||
|
||||
let placeholder = "[Image #1]";
|
||||
let message = format!("{placeholder} replayed");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: Some(placeholder.to_string()),
|
||||
}];
|
||||
let text_elements = vec![TextElement::new(
|
||||
(0..placeholder.len()).into(),
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
let local_images = vec![PathBuf::from("/tmp/replay.png")];
|
||||
|
||||
let conversation_id = ThreadId::new();
|
||||
@@ -274,10 +274,10 @@ async fn submission_preserves_text_elements_and_local_images() {
|
||||
|
||||
let placeholder = "[Image #1]";
|
||||
let text = format!("{placeholder} submit");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: Some(placeholder.to_string()),
|
||||
}];
|
||||
let text_elements = vec![TextElement::new(
|
||||
(0..placeholder.len()).into(),
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
let local_images = vec![PathBuf::from("/tmp/submitted.png")];
|
||||
|
||||
chat.bottom_pane
|
||||
@@ -330,26 +330,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
|
||||
let first_placeholder = "[Image #1]";
|
||||
let first_text = format!("{first_placeholder} first");
|
||||
let first_elements = vec![TextElement {
|
||||
byte_range: (0..first_placeholder.len()).into(),
|
||||
placeholder: Some(first_placeholder.to_string()),
|
||||
}];
|
||||
let first_elements = vec![TextElement::new(
|
||||
(0..first_placeholder.len()).into(),
|
||||
Some(first_placeholder.to_string()),
|
||||
)];
|
||||
let first_images = [PathBuf::from("/tmp/first.png")];
|
||||
|
||||
let second_placeholder = "[Image #1]";
|
||||
let second_text = format!("{second_placeholder} second");
|
||||
let second_elements = vec![TextElement {
|
||||
byte_range: (0..second_placeholder.len()).into(),
|
||||
placeholder: Some(second_placeholder.to_string()),
|
||||
}];
|
||||
let second_elements = vec![TextElement::new(
|
||||
(0..second_placeholder.len()).into(),
|
||||
Some(second_placeholder.to_string()),
|
||||
)];
|
||||
let second_images = [PathBuf::from("/tmp/second.png")];
|
||||
|
||||
let existing_placeholder = "[Image #1]";
|
||||
let existing_text = format!("{existing_placeholder} existing");
|
||||
let existing_elements = vec![TextElement {
|
||||
byte_range: (0..existing_placeholder.len()).into(),
|
||||
placeholder: Some(existing_placeholder.to_string()),
|
||||
}];
|
||||
let existing_elements = vec![TextElement::new(
|
||||
(0..existing_placeholder.len()).into(),
|
||||
Some(existing_placeholder.to_string()),
|
||||
)];
|
||||
let existing_images = vec![PathBuf::from("/tmp/existing.png")];
|
||||
|
||||
chat.queued_user_messages.push_back(UserMessage {
|
||||
@@ -392,18 +392,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
let second_start = first.len() + 1;
|
||||
let third_start = second_start + second.len() + 1;
|
||||
let expected_elements = vec![
|
||||
TextElement {
|
||||
byte_range: (first_start..first_start + "[Image #1]".len()).into(),
|
||||
placeholder: Some("[Image #1]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: (second_start..second_start + "[Image #2]".len()).into(),
|
||||
placeholder: Some("[Image #2]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: (third_start..third_start + "[Image #3]".len()).into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
TextElement::new(
|
||||
(first_start..first_start + "[Image #1]".len()).into(),
|
||||
Some("[Image #1]".to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
(second_start..second_start + "[Image #2]".len()).into(),
|
||||
Some("[Image #2]".to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
(third_start..third_start + "[Image #3]".len()).into(),
|
||||
Some("[Image #3]".to_string()),
|
||||
),
|
||||
];
|
||||
assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements);
|
||||
assert_eq!(
|
||||
@@ -422,14 +422,14 @@ async fn remap_placeholders_uses_attachment_labels() {
|
||||
let placeholder_two = "[Image #2]";
|
||||
let text = format!("{placeholder_two} before {placeholder_one}");
|
||||
let elements = vec![
|
||||
TextElement {
|
||||
byte_range: (0..placeholder_two.len()).into(),
|
||||
placeholder: Some(placeholder_two.to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
placeholder: Some(placeholder_one.to_string()),
|
||||
},
|
||||
TextElement::new(
|
||||
(0..placeholder_two.len()).into(),
|
||||
Some(placeholder_two.to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
Some(placeholder_one.to_string()),
|
||||
),
|
||||
];
|
||||
|
||||
let attachments = vec![
|
||||
@@ -454,15 +454,14 @@ async fn remap_placeholders_uses_attachment_labels() {
|
||||
assert_eq!(
|
||||
remapped.text_elements,
|
||||
vec![
|
||||
TextElement {
|
||||
byte_range: (0.."[Image #4]".len()).into(),
|
||||
placeholder: Some("[Image #4]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len())
|
||||
.into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
TextElement::new(
|
||||
(0.."[Image #4]".len()).into(),
|
||||
Some("[Image #4]".to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(),
|
||||
Some("[Image #3]".to_string()),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -486,14 +485,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() {
|
||||
let placeholder_two = "[Image #2]";
|
||||
let text = format!("{placeholder_two} before {placeholder_one}");
|
||||
let elements = vec![
|
||||
TextElement {
|
||||
byte_range: (0..placeholder_two.len()).into(),
|
||||
placeholder: None,
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
placeholder: None,
|
||||
},
|
||||
TextElement::new((0..placeholder_two.len()).into(), None),
|
||||
TextElement::new(
|
||||
("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
let attachments = vec![
|
||||
@@ -518,15 +514,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() {
|
||||
assert_eq!(
|
||||
remapped.text_elements,
|
||||
vec![
|
||||
TextElement {
|
||||
byte_range: (0.."[Image #4]".len()).into(),
|
||||
placeholder: Some("[Image #4]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len())
|
||||
.into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
TextElement::new(
|
||||
(0.."[Image #4]".len()).into(),
|
||||
Some("[Image #4]".to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(),
|
||||
Some("[Image #3]".to_string()),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -756,8 +751,27 @@ async fn make_chatwidget_manual(
|
||||
skills: None,
|
||||
});
|
||||
bottom.set_steer_enabled(true);
|
||||
bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes));
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let codex_home = cfg.codex_home.clone();
|
||||
let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone()));
|
||||
let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes);
|
||||
let reasoning_effort = None;
|
||||
let stored_collaboration_mode = if collaboration_modes_enabled {
|
||||
collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: resolved_model.clone(),
|
||||
reasoning_effort,
|
||||
developer_instructions: None,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: resolved_model.clone(),
|
||||
reasoning_effort,
|
||||
developer_instructions: None,
|
||||
})
|
||||
};
|
||||
let widget = ChatWidget {
|
||||
app_event_tx,
|
||||
codex_op_tx: op_tx,
|
||||
@@ -765,10 +779,9 @@ async fn make_chatwidget_manual(
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
config: cfg,
|
||||
model: Some(resolved_model.clone()),
|
||||
collaboration_mode: CollaborationModeSelection::default(),
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)),
|
||||
stored_collaboration_mode,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
session_header: SessionHeader::new(resolved_model),
|
||||
initial_user_message: None,
|
||||
token_info: None,
|
||||
@@ -1909,75 +1922,66 @@ async fn slash_init_skips_when_project_doc_exists() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_collaboration_mode_selection_accepts_common_aliases() {
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("plan"),
|
||||
Some(CollaborationModeSelection::Plan)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("PAIR"),
|
||||
Some(CollaborationModeSelection::PairProgramming)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("pair_programming"),
|
||||
Some(CollaborationModeSelection::PairProgramming)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("pp"),
|
||||
Some(CollaborationModeSelection::PairProgramming)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection(" exec "),
|
||||
Some(CollaborationModeSelection::Execute)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("execute"),
|
||||
Some(CollaborationModeSelection::Execute)
|
||||
);
|
||||
assert_eq!(collaboration_modes::parse_selection("unknown"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, false);
|
||||
|
||||
let initial = chat.collaboration_mode;
|
||||
let initial = chat.stored_collaboration_mode.clone();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.collaboration_mode, initial);
|
||||
assert_eq!(chat.stored_collaboration_mode, initial);
|
||||
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute);
|
||||
assert!(matches!(
|
||||
chat.stored_collaboration_mode,
|
||||
CollaborationMode::Execute(_)
|
||||
));
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan);
|
||||
assert!(matches!(
|
||||
chat.stored_collaboration_mode,
|
||||
CollaborationMode::Plan(_)
|
||||
));
|
||||
|
||||
chat.on_task_started();
|
||||
let before = chat.stored_collaboration_mode.clone();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan);
|
||||
assert_eq!(chat.stored_collaboration_mode, before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
async fn collab_slash_command_opens_picker_and_updates_mode() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string());
|
||||
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan);
|
||||
chat.dispatch_command(SlashCommand::Collab);
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains("Select Collaboration Mode"),
|
||||
"expected collaboration picker: {popup}"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
let selected_mode = match rx.try_recv() {
|
||||
Ok(AppEvent::UpdateCollaborationMode(mode)) => mode,
|
||||
other => panic!("expected UpdateCollaborationMode event, got {other:?}"),
|
||||
};
|
||||
chat.set_collaboration_mode(selected_mode);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn {
|
||||
collaboration_mode: Some(CollaborationMode::Plan(_)),
|
||||
collaboration_mode: Some(CollaborationMode::PairProgramming(_)),
|
||||
..
|
||||
} => {}
|
||||
other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"),
|
||||
other => {
|
||||
panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}")
|
||||
}
|
||||
}
|
||||
|
||||
chat.bottom_pane
|
||||
@@ -1985,10 +1989,12 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn {
|
||||
collaboration_mode: Some(CollaborationMode::Plan(_)),
|
||||
collaboration_mode: Some(CollaborationMode::PairProgramming(_)),
|
||||
..
|
||||
} => {}
|
||||
other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"),
|
||||
other => {
|
||||
panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2006,10 +2012,22 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() {
|
||||
collaboration_mode: Some(CollaborationMode::PairProgramming(_)),
|
||||
..
|
||||
} => {}
|
||||
other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"),
|
||||
other => {
|
||||
panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_mode_enabling_sets_pair_programming_default() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
assert!(matches!(
|
||||
chat.stored_collaboration_mode,
|
||||
CollaborationMode::PairProgramming(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_quit_requests_exit() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
@@ -2722,7 +2740,7 @@ async fn model_reasoning_selection_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
|
||||
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High);
|
||||
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||||
|
||||
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
|
||||
chat.open_reasoning_popup(preset);
|
||||
@@ -2736,7 +2754,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
|
||||
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh);
|
||||
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
|
||||
|
||||
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
|
||||
chat.open_reasoning_popup(preset);
|
||||
|
||||
@@ -1,135 +1,49 @@
|
||||
//! Collaboration mode selection + rendering helpers for the TUI.
|
||||
//!
|
||||
//! This module is intentionally UI-focused:
|
||||
//! - It owns the user-facing set of selectable collaboration modes and how they cycle.
|
||||
//! - It parses `/collab <mode>` arguments into a selection.
|
||||
//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by
|
||||
//! picking from the `ModelsManager` builtin collaboration presets.
|
||||
//! - It builds the small footer "flash" line shown after changing modes.
|
||||
//!
|
||||
//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed
|
||||
//! (feature flag, task running, modals open, etc.). This module just provides the building blocks.
|
||||
|
||||
use crate::key_hint;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// The user-facing collaboration mode choices supported by the TUI.
|
||||
///
|
||||
/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling
|
||||
/// order, while `CollaborationMode` can carry nested settings/prompt configuration.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum Selection {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ModeKind {
|
||||
Plan,
|
||||
#[default]
|
||||
PairProgramming,
|
||||
Execute,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// Cycle to the next selection.
|
||||
///
|
||||
/// The TUI cycles through a small, fixed set of presets.
|
||||
pub(crate) fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Plan => Self::PairProgramming,
|
||||
Self::PairProgramming => Self::Execute,
|
||||
Self::Execute => Self::Plan,
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing label used in UI surfaces like `/status` and the footer flash.
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Plan => "Plan",
|
||||
Self::PairProgramming => "Pair Programming",
|
||||
Self::Execute => "Execute",
|
||||
}
|
||||
fn mode_kind(mode: &CollaborationMode) -> ModeKind {
|
||||
match mode {
|
||||
CollaborationMode::Plan(_) => ModeKind::Plan,
|
||||
CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming,
|
||||
CollaborationMode::Execute(_) => ModeKind::Execute,
|
||||
CollaborationMode::Custom(_) => ModeKind::Custom,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection.
|
||||
///
|
||||
/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively.
|
||||
pub(crate) fn parse_selection(input: &str) -> Option<Selection> {
|
||||
let normalized: String = input
|
||||
.chars()
|
||||
.filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_')
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect();
|
||||
|
||||
match normalized.as_str() {
|
||||
"plan" => Some(Selection::Plan),
|
||||
"pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming),
|
||||
"execute" | "exec" => Some(Selection::Execute),
|
||||
_ => None,
|
||||
}
|
||||
pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option<CollaborationMode> {
|
||||
let presets = models_manager.list_collaboration_modes();
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| matches!(preset, CollaborationMode::PairProgramming(_)))
|
||||
.cloned()
|
||||
.or_else(|| presets.into_iter().next())
|
||||
}
|
||||
|
||||
/// Resolve a selection to a concrete collaboration mode preset.
|
||||
///
|
||||
/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this
|
||||
/// function selects the first preset of the desired variant.
|
||||
pub(crate) fn resolve_mode(
|
||||
pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool {
|
||||
mode_kind(a) == mode_kind(b)
|
||||
}
|
||||
|
||||
/// Cycle to the next collaboration mode preset in list order.
|
||||
pub(crate) fn next_mode(
|
||||
models_manager: &ModelsManager,
|
||||
selection: Selection,
|
||||
current: &CollaborationMode,
|
||||
) -> Option<CollaborationMode> {
|
||||
match selection {
|
||||
Selection::Plan => models_manager
|
||||
.list_collaboration_modes()
|
||||
.into_iter()
|
||||
.find(|mode| matches!(mode, CollaborationMode::Plan(_))),
|
||||
Selection::PairProgramming => models_manager
|
||||
.list_collaboration_modes()
|
||||
.into_iter()
|
||||
.find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))),
|
||||
Selection::Execute => models_manager
|
||||
.list_collaboration_modes()
|
||||
.into_iter()
|
||||
.find(|mode| matches!(mode, CollaborationMode::Execute(_))),
|
||||
let presets = models_manager.list_collaboration_modes();
|
||||
if presets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode
|
||||
/// when the desired preset is unavailable.
|
||||
///
|
||||
/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when
|
||||
/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries
|
||||
/// an explicit collaboration mode so core doesn't fall back to `Custom`.
|
||||
pub(crate) fn resolve_mode_or_fallback(
|
||||
models_manager: &ModelsManager,
|
||||
selection: Selection,
|
||||
fallback_model: &str,
|
||||
fallback_effort: Option<ReasoningEffort>,
|
||||
) -> CollaborationMode {
|
||||
resolve_mode(models_manager, selection).unwrap_or_else(|| {
|
||||
let settings = Settings {
|
||||
model: fallback_model.to_string(),
|
||||
reasoning_effort: fallback_effort,
|
||||
developer_instructions: None,
|
||||
};
|
||||
|
||||
match selection {
|
||||
Selection::Plan => CollaborationMode::Plan(settings),
|
||||
Selection::PairProgramming => CollaborationMode::PairProgramming(settings),
|
||||
Selection::Execute => CollaborationMode::Execute(settings),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a 1-line footer "flash" that is shown after switching modes.
|
||||
///
|
||||
/// The `ChatWidget` controls when to show this and how long it should remain visible.
|
||||
pub(crate) fn flash_line(selection: Selection) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
selection.label().bold(),
|
||||
" (".dim(),
|
||||
key_hint::shift(KeyCode::Tab).into(),
|
||||
" to change mode)".dim(),
|
||||
])
|
||||
let current_kind = mode_kind(current);
|
||||
let next_index = presets
|
||||
.iter()
|
||||
.position(|preset| mode_kind(preset) == current_kind)
|
||||
.map_or(0, |idx| (idx + 1) % presets.len());
|
||||
presets.get(next_index).cloned()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::exec_cell::output_lines;
|
||||
use crate::exec_cell::spinner;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::key_hint;
|
||||
use crate::live_wrap::take_prefix_by_width;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
@@ -43,11 +44,13 @@ use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpAuthStatus;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
use image::DynamicImage;
|
||||
use image::ImageReader;
|
||||
use mcp_types::EmbeddedResourceResource;
|
||||
@@ -903,6 +906,8 @@ pub(crate) fn new_session_info(
|
||||
requested_model: &str,
|
||||
event: SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
is_collaboration: bool,
|
||||
collaboration_mode: CollaborationMode,
|
||||
) -> SessionInfoCell {
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
@@ -915,6 +920,8 @@ pub(crate) fn new_session_info(
|
||||
reasoning_effort,
|
||||
config.cwd.clone(),
|
||||
CODEX_CLI_VERSION,
|
||||
is_collaboration,
|
||||
collaboration_mode,
|
||||
);
|
||||
let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];
|
||||
|
||||
@@ -991,6 +998,8 @@ pub(crate) struct SessionHeaderHistoryCell {
|
||||
model_style: Style,
|
||||
reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
directory: PathBuf,
|
||||
is_collaboration: bool,
|
||||
collaboration_mode: CollaborationMode,
|
||||
}
|
||||
|
||||
impl SessionHeaderHistoryCell {
|
||||
@@ -999,6 +1008,8 @@ impl SessionHeaderHistoryCell {
|
||||
reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
directory: PathBuf,
|
||||
version: &'static str,
|
||||
is_collaboration: bool,
|
||||
collaboration_mode: CollaborationMode,
|
||||
) -> Self {
|
||||
Self::new_with_style(
|
||||
model,
|
||||
@@ -1006,6 +1017,8 @@ impl SessionHeaderHistoryCell {
|
||||
reasoning_effort,
|
||||
directory,
|
||||
version,
|
||||
is_collaboration,
|
||||
collaboration_mode,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1015,6 +1028,8 @@ impl SessionHeaderHistoryCell {
|
||||
reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
directory: PathBuf,
|
||||
version: &'static str,
|
||||
is_collaboration: bool,
|
||||
collaboration_mode: CollaborationMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
version,
|
||||
@@ -1022,6 +1037,20 @@ impl SessionHeaderHistoryCell {
|
||||
model_style,
|
||||
reasoning_effort,
|
||||
directory,
|
||||
is_collaboration,
|
||||
collaboration_mode,
|
||||
}
|
||||
}
|
||||
|
||||
fn collaboration_mode_label(&self) -> Option<&'static str> {
|
||||
if !self.is_collaboration {
|
||||
return None;
|
||||
}
|
||||
match &self.collaboration_mode {
|
||||
CollaborationMode::Plan(_) => Some("Plan"),
|
||||
CollaborationMode::PairProgramming(_) => Some("Pair Programming"),
|
||||
CollaborationMode::Execute(_) => Some("Execute"),
|
||||
CollaborationMode::Custom(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1082,25 +1111,49 @@ impl HistoryCell for SessionHeaderHistoryCell {
|
||||
|
||||
const CHANGE_MODEL_HINT_COMMAND: &str = "/model";
|
||||
const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change";
|
||||
const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode";
|
||||
const DIR_LABEL: &str = "directory:";
|
||||
let label_width = DIR_LABEL.len();
|
||||
let model_label = format!(
|
||||
"{model_label:<label_width$}",
|
||||
model_label = "model:",
|
||||
label_width = label_width
|
||||
);
|
||||
let reasoning_label = self.reasoning_label();
|
||||
let mut model_spans: Vec<Span<'static>> = vec![
|
||||
Span::from(format!("{model_label} ")).dim(),
|
||||
Span::styled(self.model.clone(), self.model_style),
|
||||
];
|
||||
if let Some(reasoning) = reasoning_label {
|
||||
model_spans.push(Span::from(" "));
|
||||
model_spans.push(Span::from(reasoning));
|
||||
}
|
||||
model_spans.push(" ".dim());
|
||||
model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
|
||||
model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
|
||||
|
||||
let model_spans: Vec<Span<'static>> = if self.is_collaboration {
|
||||
// Render collaboration mode instead of model
|
||||
let collab_label = format!(
|
||||
"{collab_label:<label_width$}",
|
||||
collab_label = "mode:",
|
||||
label_width = label_width
|
||||
);
|
||||
let mut spans = vec![Span::from(format!("{collab_label} ")).dim()];
|
||||
if let Some(mode_label) = self.collaboration_mode_label() {
|
||||
spans.push(Span::styled(mode_label.to_string(), self.model_style));
|
||||
} else {
|
||||
spans.push(Span::styled("Custom", self.model_style));
|
||||
}
|
||||
spans.push(" ".dim());
|
||||
let shift_tab_span: Span<'static> = key_hint::shift(KeyCode::Tab).into();
|
||||
spans.push(shift_tab_span.cyan());
|
||||
spans.push(CHANGE_MODE_HINT_EXPLANATION.dim());
|
||||
spans
|
||||
} else {
|
||||
// Render model as before
|
||||
let model_label = format!(
|
||||
"{model_label:<label_width$}",
|
||||
model_label = "model:",
|
||||
label_width = label_width
|
||||
);
|
||||
let reasoning_label = self.reasoning_label();
|
||||
let mut spans = vec![
|
||||
Span::from(format!("{model_label} ")).dim(),
|
||||
Span::styled(self.model.clone(), self.model_style),
|
||||
];
|
||||
if let Some(reasoning) = reasoning_label {
|
||||
spans.push(Span::from(" "));
|
||||
spans.push(Span::from(reasoning));
|
||||
}
|
||||
spans.push(" ".dim());
|
||||
spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
|
||||
spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
|
||||
spans
|
||||
};
|
||||
|
||||
let dir_label = format!("{DIR_LABEL:<label_width$}");
|
||||
let dir_prefix = format!("{dir_label} ");
|
||||
@@ -1828,6 +1881,8 @@ mod tests {
|
||||
use codex_core::config::types::McpServerConfig;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::protocol::McpAuthStatus;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use dirs::home_dir;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -2284,6 +2339,12 @@ mod tests {
|
||||
Some(ReasoningEffortConfig::High),
|
||||
std::env::temp_dir(),
|
||||
"test",
|
||||
false,
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: "gpt-4o".to_string(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::High),
|
||||
developer_instructions: None,
|
||||
}),
|
||||
);
|
||||
|
||||
let lines = render_lines(&cell.display_lines(80));
|
||||
|
||||
+51
-17
@@ -18,6 +18,7 @@ use codex_core::RolloutRecorder;
|
||||
use codex_core::ThreadSortKey;
|
||||
use codex_core::auth::enforce_login_restrictions;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_config_as_toml_with_cli_overrides;
|
||||
@@ -25,6 +26,7 @@ use codex_core::config::resolve_oss_provider;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::get_platform_sandbox;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::read_session_meta_line;
|
||||
use codex_core::terminal::Multiplexer;
|
||||
use codex_protocol::config_types::AltScreenMode;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
@@ -246,7 +248,6 @@ pub async fn run_main(
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let active_profile = config.active_profile.clone();
|
||||
let log_dir = codex_core::config::log_dir(&config)?;
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
// Open (or create) your log file, appending to it.
|
||||
@@ -336,16 +337,9 @@ pub async fn run_main(
|
||||
.with(otel_tracing_layer)
|
||||
.try_init();
|
||||
|
||||
run_ratatui_app(
|
||||
cli,
|
||||
config,
|
||||
overrides,
|
||||
cli_kv_overrides,
|
||||
active_profile,
|
||||
feedback,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
}
|
||||
|
||||
async fn run_ratatui_app(
|
||||
@@ -353,7 +347,6 @@ async fn run_ratatui_app(
|
||||
initial_config: Config,
|
||||
overrides: ConfigOverrides,
|
||||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||||
active_profile: Option<String>,
|
||||
feedback: codex_feedback::CodexFeedback,
|
||||
) -> color_eyre::Result<AppExitInfo> {
|
||||
color_eyre::install()?;
|
||||
@@ -404,15 +397,15 @@ async fn run_ratatui_app(
|
||||
initial_config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
let login_status = get_login_status(&initial_config);
|
||||
let should_show_trust_screen = should_show_trust_screen(&initial_config);
|
||||
let should_show_trust_screen_flag = should_show_trust_screen(&initial_config);
|
||||
let should_show_onboarding =
|
||||
should_show_onboarding(login_status, &initial_config, should_show_trust_screen);
|
||||
should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag);
|
||||
|
||||
let config = if should_show_onboarding {
|
||||
let onboarding_result = run_onboarding_app(
|
||||
OnboardingScreenArgs {
|
||||
show_login_screen: should_show_login_screen(login_status, &initial_config),
|
||||
show_trust_screen: should_show_trust_screen,
|
||||
show_trust_screen: should_show_trust_screen_flag,
|
||||
login_status,
|
||||
auth_manager: auth_manager.clone(),
|
||||
config: initial_config.clone(),
|
||||
@@ -437,7 +430,7 @@ async fn run_ratatui_app(
|
||||
.map(|d| d == TrustDirectorySelection::Trust)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
load_config_or_exit(cli_kv_overrides, overrides).await
|
||||
load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await
|
||||
} else {
|
||||
initial_config
|
||||
}
|
||||
@@ -570,6 +563,33 @@ async fn run_ratatui_app(
|
||||
resume_picker::SessionSelection::StartFresh
|
||||
};
|
||||
|
||||
let config = match &session_selection {
|
||||
resume_picker::SessionSelection::Resume(path)
|
||||
| resume_picker::SessionSelection::Fork(path) => {
|
||||
let history_cwd = match read_session_meta_line(path).await {
|
||||
Ok(meta_line) => Some(meta_line.meta.cwd),
|
||||
Err(err) => {
|
||||
let rollout_path = path.display().to_string();
|
||||
tracing::warn!(
|
||||
%rollout_path,
|
||||
%err,
|
||||
"Failed to read session metadata from rollout"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
history_cwd,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => config,
|
||||
};
|
||||
let active_profile = config.active_profile.clone();
|
||||
let should_show_trust_screen = should_show_trust_screen(&config);
|
||||
|
||||
let Cli {
|
||||
prompt,
|
||||
images,
|
||||
@@ -671,9 +691,23 @@ fn get_login_status(config: &Config) -> LoginStatus {
|
||||
async fn load_config_or_exit(
|
||||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||||
overrides: ConfigOverrides,
|
||||
) -> Config {
|
||||
load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await
|
||||
}
|
||||
|
||||
async fn load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||||
overrides: ConfigOverrides,
|
||||
fallback_cwd: Option<PathBuf>,
|
||||
) -> Config {
|
||||
#[allow(clippy::print_stderr)]
|
||||
match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await {
|
||||
match ConfigBuilder::default()
|
||||
.cli_overrides(cli_kv_overrides)
|
||||
.harness_overrides(overrides)
|
||||
.fallback_cwd(fallback_cwd)
|
||||
.build()
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
eprintln!("Error loading configuration: {err}");
|
||||
|
||||
@@ -5,7 +5,8 @@ use crate::history_cell::with_border_with_inner_width;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
use codex_common::create_config_summary_entries;
|
||||
use codex_common::summarize_sandbox_policy;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::NetworkAccess;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
@@ -13,6 +14,7 @@ use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeSet;
|
||||
@@ -85,6 +87,7 @@ pub(crate) fn new_status_output(
|
||||
now: DateTime<Local>,
|
||||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> CompositeHistoryCell {
|
||||
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
|
||||
let card = StatusHistoryCell::new(
|
||||
@@ -99,6 +102,7 @@ pub(crate) fn new_status_output(
|
||||
now,
|
||||
model_name,
|
||||
collaboration_mode,
|
||||
reasoning_effort_override,
|
||||
);
|
||||
|
||||
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)])
|
||||
@@ -118,8 +122,29 @@ impl StatusHistoryCell {
|
||||
now: DateTime<Local>,
|
||||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> Self {
|
||||
let config_entries = create_config_summary_entries(config, model_name);
|
||||
let mut config_entries = vec![
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", model_name.to_string()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
("approval", config.approval_policy.value().to_string()),
|
||||
(
|
||||
"sandbox",
|
||||
summarize_sandbox_policy(config.sandbox_policy.get()),
|
||||
),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses {
|
||||
let effort_value = reasoning_effort_override
|
||||
.unwrap_or(None)
|
||||
.map(|effort| effort.to_string())
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
config_entries.push(("reasoning effort", effort_value));
|
||||
config_entries.push((
|
||||
"reasoning summaries",
|
||||
config.model_reasoning_summary.to_string(),
|
||||
));
|
||||
}
|
||||
let (model_name, model_details) = compose_model_display(model_name, &config_entries);
|
||||
let approval = config_entries
|
||||
.iter()
|
||||
|
||||
@@ -95,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config.model_reasoning_effort = Some(ReasoningEffort::High);
|
||||
config.model_reasoning_summary = ReasoningSummary::Detailed;
|
||||
config
|
||||
.sandbox_policy
|
||||
@@ -141,6 +140,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let token_info = token_info_for(&model_slug, &config, &usage);
|
||||
|
||||
let reasoning_effort_override = Some(Some(ReasoningEffort::High));
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
@@ -153,6 +153,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
reasoning_effort_override,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -205,6 +206,7 @@ async fn status_snapshot_includes_forked_from() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -263,6 +265,7 @@ async fn status_snapshot_includes_monthly_limit() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -309,6 +312,7 @@ async fn status_snapshot_shows_unlimited_credits() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
@@ -354,6 +358,7 @@ async fn status_snapshot_shows_positive_credits() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
@@ -399,6 +404,7 @@ async fn status_snapshot_hides_zero_credits() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
@@ -442,6 +448,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
@@ -485,6 +492,7 @@ async fn status_card_token_usage_excludes_cached_tokens() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
|
||||
@@ -500,7 +508,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config.model_reasoning_effort = Some(ReasoningEffort::High);
|
||||
config.model_reasoning_summary = ReasoningSummary::Detailed;
|
||||
config.cwd = PathBuf::from("/workspace/tests");
|
||||
|
||||
@@ -531,6 +538,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
|
||||
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let token_info = token_info_for(&model_slug, &config, &usage);
|
||||
let reasoning_effort_override = Some(Some(ReasoningEffort::High));
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
@@ -543,6 +551,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
reasoning_effort_override,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(70));
|
||||
if cfg!(windows) {
|
||||
@@ -590,6 +599,7 @@ async fn status_snapshot_shows_missing_limits_message() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -655,6 +665,7 @@ async fn status_snapshot_includes_credits_and_limits() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -708,6 +719,7 @@ async fn status_snapshot_shows_empty_limits_message() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -770,6 +782,7 @@ async fn status_snapshot_shows_stale_limits_message() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -836,6 +849,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -892,6 +906,7 @@ async fn status_context_window_uses_last_usage() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered_lines = render_lines(&composite.display_lines(80));
|
||||
let context_line = rendered_lines
|
||||
|
||||
+19
-18
@@ -311,7 +311,6 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
from_model: model.to_string(),
|
||||
to_model: target_model.clone(),
|
||||
});
|
||||
config.model = Some(target_model.clone());
|
||||
|
||||
let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping
|
||||
&& let Some(reasoning_effort) = config.model_reasoning_effort
|
||||
@@ -324,8 +323,8 @@ async fn handle_model_migration_prompt_if_needed(
|
||||
config.model_reasoning_effort
|
||||
};
|
||||
|
||||
config.model = Some(target_model.clone());
|
||||
config.model_reasoning_effort = mapped_effort;
|
||||
|
||||
app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
|
||||
app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort));
|
||||
app_event_tx.send(AppEvent::PersistModelSelection {
|
||||
@@ -361,7 +360,6 @@ pub(crate) struct App {
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
pub(crate) config: Config,
|
||||
pub(crate) current_model: String,
|
||||
pub(crate) active_profile: Option<String>,
|
||||
|
||||
pub(crate) file_search: FileSearchManager,
|
||||
@@ -439,7 +437,7 @@ impl App {
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
model: Some(self.current_model.clone()),
|
||||
model: Some(self.chat_widget.current_model().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +515,7 @@ impl App {
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
feedback: feedback.clone(),
|
||||
is_first_run,
|
||||
model: config.model.clone(),
|
||||
model: Some(model.clone()),
|
||||
};
|
||||
ChatWidget::new(init, thread_manager.clone())
|
||||
}
|
||||
@@ -605,7 +603,6 @@ impl App {
|
||||
chat_widget,
|
||||
auth_manager: auth_manager.clone(),
|
||||
config,
|
||||
current_model: model.clone(),
|
||||
active_profile,
|
||||
file_search,
|
||||
enhanced_keys_supported,
|
||||
@@ -1474,6 +1471,9 @@ impl App {
|
||||
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<AppRunControl> {
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
let model = self.chat_widget.current_model().to_string();
|
||||
// Propagate selected model into config before spawning thread
|
||||
self.config.model = Some(model.clone());
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.conversation_id(),
|
||||
@@ -1493,7 +1493,7 @@ impl App {
|
||||
models_manager: self.server.get_models_manager(),
|
||||
feedback: self.feedback.clone(),
|
||||
is_first_run: false,
|
||||
model: Some(self.current_model.clone()),
|
||||
model: Some(model),
|
||||
};
|
||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||
if let Some(summary) = summary {
|
||||
@@ -1713,7 +1713,11 @@ impl App {
|
||||
}
|
||||
AppEvent::UpdateModel(model) => {
|
||||
self.chat_widget.set_model(&model);
|
||||
self.current_model = model;
|
||||
}
|
||||
AppEvent::UpdateCollaborationMode(mode) => {
|
||||
let model = mode.model().to_string();
|
||||
self.chat_widget.set_collaboration_mode(mode);
|
||||
self.chat_widget.set_model(&model);
|
||||
}
|
||||
AppEvent::OpenReasoningPopup { model } => {
|
||||
self.chat_widget.open_reasoning_popup(model);
|
||||
@@ -2138,8 +2142,10 @@ impl App {
|
||||
}
|
||||
|
||||
fn on_update_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
||||
self.chat_widget.set_reasoning_effort(effort);
|
||||
// TODO(aibrahim): Remove this and don't use config as a state object.
|
||||
// Instead, explicitly pass the stored collaboration mode's effort into new sessions.
|
||||
self.config.model_reasoning_effort = effort;
|
||||
self.chat_widget.set_reasoning_effort(effort);
|
||||
}
|
||||
|
||||
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
||||
@@ -2360,7 +2366,6 @@ mod tests {
|
||||
async fn make_test_app() -> App {
|
||||
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
let config = chat_widget.config_ref().clone();
|
||||
let current_model = "gpt-5.2-codex".to_string();
|
||||
let server = Arc::new(ThreadManager::with_models_provider(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
@@ -2375,7 +2380,6 @@ mod tests {
|
||||
chat_widget,
|
||||
auth_manager,
|
||||
config,
|
||||
current_model,
|
||||
active_profile: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
@@ -2413,7 +2417,6 @@ mod tests {
|
||||
) {
|
||||
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
let config = chat_widget.config_ref().clone();
|
||||
let current_model = "gpt-5.2-codex".to_string();
|
||||
let server = Arc::new(ThreadManager::with_models_provider(
|
||||
CodexAuth::from_api_key("Test API Key"),
|
||||
config.model_provider.clone(),
|
||||
@@ -2429,7 +2432,6 @@ mod tests {
|
||||
chat_widget,
|
||||
auth_manager,
|
||||
config,
|
||||
current_model,
|
||||
active_profile: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
@@ -2628,20 +2630,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_reasoning_effort_updates_config() {
|
||||
async fn update_reasoning_effort_updates_collaboration_mode() {
|
||||
let mut app = make_test_app().await;
|
||||
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium);
|
||||
app.chat_widget
|
||||
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
|
||||
|
||||
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||||
|
||||
assert_eq!(
|
||||
app.config.model_reasoning_effort,
|
||||
app.chat_widget.current_reasoning_effort(),
|
||||
Some(ReasoningEffortConfig::High)
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.config_ref().model_reasoning_effort,
|
||||
app.config.model_reasoning_effort,
|
||||
Some(ReasoningEffortConfig::High)
|
||||
);
|
||||
}
|
||||
@@ -2681,7 +2682,7 @@ mod tests {
|
||||
};
|
||||
Arc::new(new_session_info(
|
||||
app.chat_widget.config_ref(),
|
||||
app.current_model.as_str(),
|
||||
app.chat_widget.current_model(),
|
||||
event,
|
||||
is_first,
|
||||
)) as Arc<dyn HistoryCell>
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::history_cell::HistoryCell;
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -96,6 +97,9 @@ pub(crate) enum AppEvent {
|
||||
/// Update the current model slug in the running app and widget.
|
||||
UpdateModel(String),
|
||||
|
||||
/// Update the current collaboration mode in the running app and widget.
|
||||
UpdateCollaborationMode(CollaborationMode),
|
||||
|
||||
/// Persist the selected model and reasoning effort to the appropriate config.
|
||||
PersistModelSelection {
|
||||
model: String,
|
||||
|
||||
@@ -489,12 +489,7 @@ impl ChatComposer {
|
||||
|
||||
let image_placeholders: HashSet<String> = text_elements
|
||||
.iter()
|
||||
.filter_map(|elem| {
|
||||
elem.placeholder.as_ref().cloned().or_else(|| {
|
||||
text.get(elem.byte_range.start..elem.byte_range.end)
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.filter_map(|elem| elem.placeholder(&text).map(str::to_string))
|
||||
.collect();
|
||||
for (idx, path) in local_image_paths.into_iter().enumerate() {
|
||||
let placeholder = local_image_label_text(idx + 1);
|
||||
@@ -1174,13 +1169,13 @@ impl ChatComposer {
|
||||
return None;
|
||||
}
|
||||
let placeholder = trimmed.get(new_start..new_end).map(str::to_string);
|
||||
Some(TextElement {
|
||||
byte_range: ByteRange {
|
||||
Some(TextElement::new(
|
||||
ByteRange {
|
||||
start: new_start,
|
||||
end: new_end,
|
||||
},
|
||||
placeholder,
|
||||
})
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1219,7 +1214,7 @@ impl ChatComposer {
|
||||
rebuilt.push_str(&text[cursor..start]);
|
||||
}
|
||||
let elem_text = &text[start..end];
|
||||
let placeholder = elem.placeholder;
|
||||
let placeholder = elem.placeholder(text).map(str::to_string);
|
||||
let replacement = placeholder
|
||||
.as_deref()
|
||||
.and_then(|ph| pending_by_placeholder.get_mut(ph))
|
||||
@@ -1231,13 +1226,13 @@ impl ChatComposer {
|
||||
rebuilt.push_str(elem_text);
|
||||
let new_end = rebuilt.len();
|
||||
let placeholder = placeholder.or_else(|| Some(elem_text.to_string()));
|
||||
rebuilt_elements.push(TextElement {
|
||||
byte_range: ByteRange {
|
||||
rebuilt_elements.push(TextElement::new(
|
||||
ByteRange {
|
||||
start: new_start,
|
||||
end: new_end,
|
||||
},
|
||||
placeholder,
|
||||
});
|
||||
));
|
||||
}
|
||||
cursor = end;
|
||||
}
|
||||
@@ -4223,7 +4218,7 @@ mod tests {
|
||||
} => {
|
||||
assert_eq!(text, "[Image #1] hi");
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4252,10 +4247,7 @@ mod tests {
|
||||
|
||||
let placeholder = local_image_label_text(1);
|
||||
let text = format!("{placeholder} restored");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: None,
|
||||
}];
|
||||
let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)];
|
||||
let path = PathBuf::from("/tmp/image1.png");
|
||||
|
||||
composer.set_text_content(text, text_elements, vec![path.clone()]);
|
||||
@@ -4292,7 +4284,7 @@ mod tests {
|
||||
let expected = format!("{large_content} [Image #1]");
|
||||
assert_eq!(text, expected);
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4336,7 +4328,7 @@ mod tests {
|
||||
let trimmed = large_content.trim().to_string();
|
||||
assert_eq!(text, format!("{trimmed} [Image #1]"));
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4380,7 +4372,7 @@ mod tests {
|
||||
assert_eq!(text, "line1\nline2\n [Image #1]");
|
||||
assert!(!text.contains('\r'));
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4465,7 +4457,7 @@ mod tests {
|
||||
} => {
|
||||
assert_eq!(text, "[Image #1]");
|
||||
assert_eq!(text_elements.len(), 1);
|
||||
assert_eq!(text_elements[0].placeholder.as_deref(), Some("[Image #1]"));
|
||||
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]"));
|
||||
assert_eq!(
|
||||
text_elements[0].byte_range,
|
||||
ByteRange {
|
||||
@@ -4627,20 +4619,20 @@ mod tests {
|
||||
let start2 = text.find(&placeholder2).expect("placeholder2 present");
|
||||
let start1 = text.find(&placeholder1).expect("placeholder1 present");
|
||||
let text_elements = vec![
|
||||
TextElement {
|
||||
byte_range: ByteRange {
|
||||
TextElement::new(
|
||||
ByteRange {
|
||||
start: start2,
|
||||
end: start2 + placeholder2.len(),
|
||||
},
|
||||
placeholder: Some(placeholder2),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ByteRange {
|
||||
Some(placeholder2),
|
||||
),
|
||||
TextElement::new(
|
||||
ByteRange {
|
||||
start: start1,
|
||||
end: start1 + placeholder1.len(),
|
||||
},
|
||||
placeholder: Some(placeholder1.clone()),
|
||||
},
|
||||
Some(placeholder1.clone()),
|
||||
),
|
||||
];
|
||||
composer.set_text_content(text, text_elements, vec![path1, path2.clone()]);
|
||||
|
||||
|
||||
@@ -688,8 +688,13 @@ impl BottomPane {
|
||||
if let Some(status) = &self.status {
|
||||
flex.push(0, RenderableItem::Borrowed(status));
|
||||
}
|
||||
let has_queued_messages = !self.queued_user_messages.messages.is_empty();
|
||||
let has_status = self.status.is_some();
|
||||
if has_queued_messages && has_status {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
|
||||
if self.status.is_some() || !self.queued_user_messages.messages.is_empty() {
|
||||
if !has_queued_messages && has_status {
|
||||
flex.push(0, RenderableItem::Owned("".into()));
|
||||
}
|
||||
let mut flex2 = FlexRenderable::new();
|
||||
@@ -931,6 +936,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_only_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_with_details_and_queued_messages_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.update_status(
|
||||
"Working".to_string(),
|
||||
Some("First detail line\nSecond detail line".to_string()),
|
||||
);
|
||||
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
|
||||
|
||||
let width = 48;
|
||||
let height = pane.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
assert_snapshot!(
|
||||
"status_with_details_and_queued_messages_snapshot",
|
||||
render_snapshot(&pane, area)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_messages_visible_when_status_hidden_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
-1
@@ -5,7 +5,6 @@ expression: "render_snapshot(&pane, area)"
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
|
||||
+1
-1
@@ -3,10 +3,10 @@ source: tui2/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui2/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui2/src/bottom_pane/mod.rs
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (0s • esc to interrupt)
|
||||
└ First detail line
|
||||
Second detail line
|
||||
|
||||
↳ Queued follow-up question
|
||||
⌥ + ↑ edit
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
@@ -752,13 +752,13 @@ impl TextArea {
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let placeholder = self.text.get(e.range.clone()).map(str::to_string);
|
||||
UserTextElement {
|
||||
byte_range: ByteRange {
|
||||
UserTextElement::new(
|
||||
ByteRange {
|
||||
start: e.range.start,
|
||||
end: e.range.end,
|
||||
},
|
||||
placeholder,
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+196
-73
@@ -90,6 +90,8 @@ use codex_core::skills::model::SkillMetadata;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::local_image_label_text;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
@@ -319,8 +321,6 @@ enum RateLimitSwitchPromptState {
|
||||
Shown,
|
||||
}
|
||||
|
||||
type CollaborationModeSelection = collaboration_modes::Selection;
|
||||
|
||||
/// Maintains the per-session UI state and interaction state machines for the chat screen.
|
||||
///
|
||||
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
|
||||
@@ -349,12 +349,12 @@ pub(crate) struct ChatWidget {
|
||||
/// where the overlay may briefly treat new tail content as already cached.
|
||||
active_cell_revision: u64,
|
||||
config: Config,
|
||||
model: Option<String>,
|
||||
/// Current UI selection for collaboration modes.
|
||||
/// Stored collaboration mode with model and reasoning effort.
|
||||
///
|
||||
/// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the
|
||||
/// feature is disabled, the value is effectively inert.
|
||||
collaboration_mode: CollaborationModeSelection,
|
||||
/// When collaboration modes feature is enabled, this is initialized to the first preset.
|
||||
/// When disabled, this is Custom. The model and reasoning effort are stored here instead of
|
||||
/// being read from config or current_model.
|
||||
stored_collaboration_mode: CollaborationMode,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
session_header: SessionHeader,
|
||||
@@ -556,9 +556,9 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
|
||||
}
|
||||
|
||||
let original = text.get(start..end).unwrap_or("");
|
||||
let placeholder_key = elem.placeholder.as_deref().unwrap_or(original);
|
||||
let replacement = mapping
|
||||
.get(placeholder_key)
|
||||
let placeholder = elem.placeholder(&text);
|
||||
let replacement = placeholder
|
||||
.and_then(|ph| mapping.get(ph))
|
||||
.map(String::as_str)
|
||||
.unwrap_or(original);
|
||||
|
||||
@@ -566,8 +566,8 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
|
||||
rebuilt.push_str(replacement);
|
||||
let elem_end = rebuilt.len();
|
||||
|
||||
if let Some(remapped) = mapping.get(placeholder_key) {
|
||||
elem.placeholder = Some(remapped.clone());
|
||||
if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) {
|
||||
elem.set_placeholder(Some(remapped.clone()));
|
||||
}
|
||||
elem.byte_range = (elem_start..elem_end).into();
|
||||
rebuilt_elements.push(elem);
|
||||
@@ -631,8 +631,17 @@ impl ChatWidget {
|
||||
self.current_rollout_path = Some(event.rollout_path.clone());
|
||||
let initial_messages = event.initial_messages.clone();
|
||||
let model_for_header = event.model.clone();
|
||||
self.model = Some(model_for_header.clone());
|
||||
self.session_header.set_model(&model_for_header);
|
||||
// Only update stored collaboration settings when collaboration modes are disabled.
|
||||
// When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its
|
||||
// instructions as-is; the session configured event should not override it.
|
||||
if !self.collaboration_modes_enabled() {
|
||||
self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates(
|
||||
Some(model_for_header.clone()),
|
||||
Some(event.reasoning_effort),
|
||||
None,
|
||||
);
|
||||
}
|
||||
let session_info_cell = history_cell::new_session_info(
|
||||
&self.config,
|
||||
&model_for_header,
|
||||
@@ -876,7 +885,7 @@ impl ChatWidget {
|
||||
|
||||
if high_usage
|
||||
&& !self.rate_limit_switch_prompt_hidden()
|
||||
&& self.current_model() != Some(NUDGE_MODEL_SLUG)
|
||||
&& self.current_model() != NUDGE_MODEL_SLUG
|
||||
&& !matches!(
|
||||
self.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Shown
|
||||
@@ -1591,15 +1600,14 @@ impl ChatWidget {
|
||||
is_first_run,
|
||||
model,
|
||||
} = common;
|
||||
let mut config = config;
|
||||
let model = model.filter(|m| !m.trim().is_empty());
|
||||
let mut config = config;
|
||||
config.model = model.clone();
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||||
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager);
|
||||
|
||||
let model_for_header = config
|
||||
.model
|
||||
let model_for_header = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string());
|
||||
let active_cell = if model.is_none() {
|
||||
@@ -1608,6 +1616,22 @@ impl ChatWidget {
|
||||
None
|
||||
};
|
||||
|
||||
let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) {
|
||||
collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: model_for_header.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: model_for_header.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
})
|
||||
};
|
||||
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
@@ -1625,8 +1649,7 @@ impl ChatWidget {
|
||||
active_cell,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
model,
|
||||
collaboration_mode: CollaborationModeSelection::default(),
|
||||
stored_collaboration_mode,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
session_header: SessionHeader::new(model_for_header),
|
||||
@@ -1684,7 +1707,7 @@ impl ChatWidget {
|
||||
session_configured: codex_core::protocol::SessionConfiguredEvent,
|
||||
) -> Self {
|
||||
let ChatWidgetInit {
|
||||
mut config,
|
||||
config,
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
initial_user_message,
|
||||
@@ -1696,7 +1719,7 @@ impl ChatWidget {
|
||||
..
|
||||
} = common;
|
||||
let model = model.filter(|m| !m.trim().is_empty());
|
||||
config.model = model.clone();
|
||||
let config = config;
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string();
|
||||
|
||||
@@ -1705,6 +1728,22 @@ impl ChatWidget {
|
||||
let codex_op_tx =
|
||||
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
|
||||
|
||||
let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) {
|
||||
collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: header_model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: header_model.clone(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
})
|
||||
};
|
||||
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
@@ -1722,8 +1761,7 @@ impl ChatWidget {
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
config,
|
||||
model: Some(header_model.clone()),
|
||||
collaboration_mode: CollaborationModeSelection::default(),
|
||||
stored_collaboration_mode,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
session_header: SessionHeader::new(header_model),
|
||||
@@ -1976,7 +2014,7 @@ impl ChatWidget {
|
||||
}
|
||||
SlashCommand::Collab => {
|
||||
if self.collaboration_modes_enabled() {
|
||||
self.cycle_collaboration_mode();
|
||||
self.open_collaboration_modes_popup();
|
||||
}
|
||||
}
|
||||
SlashCommand::Approvals => {
|
||||
@@ -2144,13 +2182,8 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(selection) = collaboration_modes::parse_selection(trimmed) {
|
||||
self.set_collaboration_mode(selection);
|
||||
} else if !trimmed.is_empty() {
|
||||
self.add_error_message(format!(
|
||||
"Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute."
|
||||
));
|
||||
}
|
||||
let _ = trimmed;
|
||||
self.open_collaboration_modes_popup();
|
||||
}
|
||||
_ => self.dispatch_command(cmd),
|
||||
}
|
||||
@@ -2217,13 +2250,12 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let Some(model) = self.current_model().or(self.config.model.as_deref()) else {
|
||||
tracing::warn!("cannot submit user message before model is known; queueing");
|
||||
if !self.is_session_configured() {
|
||||
tracing::warn!("cannot submit user message before session is configured; queueing");
|
||||
self.queued_user_messages.push_front(user_message);
|
||||
self.refresh_queued_user_messages();
|
||||
return;
|
||||
};
|
||||
let model = model.to_string();
|
||||
}
|
||||
|
||||
let UserMessage {
|
||||
text,
|
||||
@@ -2277,24 +2309,18 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
let collaboration_mode = self.collaboration_modes_enabled().then(|| {
|
||||
collaboration_modes::resolve_mode_or_fallback(
|
||||
self.models_manager.as_ref(),
|
||||
self.collaboration_mode,
|
||||
model.as_str(),
|
||||
self.config.model_reasoning_effort,
|
||||
)
|
||||
});
|
||||
let op = Op::UserTurn {
|
||||
items,
|
||||
cwd: self.config.cwd.clone(),
|
||||
approval_policy: self.config.approval_policy.value(),
|
||||
sandbox_policy: self.config.sandbox_policy.get().clone(),
|
||||
model,
|
||||
effort: self.config.model_reasoning_effort,
|
||||
model: self.stored_collaboration_mode.model().to_string(),
|
||||
effort: self.stored_collaboration_mode.reasoning_effort(),
|
||||
summary: self.config.model_reasoning_summary,
|
||||
final_output_json_schema: None,
|
||||
collaboration_mode,
|
||||
collaboration_mode: self
|
||||
.collaboration_modes_enabled()
|
||||
.then(|| self.stored_collaboration_mode.clone()),
|
||||
};
|
||||
|
||||
if !self.agent_turn_running {
|
||||
@@ -2634,6 +2660,7 @@ impl ChatWidget {
|
||||
let total_usage = token_info
|
||||
.map(|ti| &ti.total_token_usage)
|
||||
.unwrap_or(&default_usage);
|
||||
let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort());
|
||||
self.add_to_history(crate::status::new_status_output(
|
||||
&self.config,
|
||||
self.auth_manager.as_ref(),
|
||||
@@ -2645,8 +2672,8 @@ impl ChatWidget {
|
||||
self.plan_type,
|
||||
Local::now(),
|
||||
self.model_display_name(),
|
||||
self.collaboration_modes_enabled()
|
||||
.then_some(self.collaboration_mode.label()),
|
||||
self.collaboration_mode_label(),
|
||||
reasoning_effort_override,
|
||||
));
|
||||
}
|
||||
fn stop_rate_limit_poller(&mut self) {
|
||||
@@ -2821,7 +2848,7 @@ impl ChatWidget {
|
||||
let current_model = self.current_model();
|
||||
let current_label = presets
|
||||
.iter()
|
||||
.find(|preset| Some(preset.model.as_str()) == current_model)
|
||||
.find(|preset| preset.model.as_str() == current_model)
|
||||
.map(|preset| preset.display_name.to_string())
|
||||
.unwrap_or_else(|| self.model_display_name().to_string());
|
||||
|
||||
@@ -2849,7 +2876,7 @@ impl ChatWidget {
|
||||
SelectionItem {
|
||||
name: preset.display_name.clone(),
|
||||
description,
|
||||
is_current: Some(model.as_str()) == current_model,
|
||||
is_current: model.as_str() == current_model,
|
||||
is_default: preset.is_default,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
@@ -2916,7 +2943,7 @@ impl ChatWidget {
|
||||
for preset in presets.into_iter() {
|
||||
let description =
|
||||
(!preset.description.is_empty()).then_some(preset.description.to_string());
|
||||
let is_current = Some(preset.model.as_str()) == self.current_model();
|
||||
let is_current = preset.model.as_str() == self.current_model();
|
||||
let single_supported_effort = preset.supported_reasoning_efforts.len() == 1;
|
||||
let preset_for_action = preset.clone();
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
@@ -2948,6 +2975,49 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_collaboration_modes_popup(&mut self) {
|
||||
let presets = self.models_manager.list_collaboration_modes();
|
||||
if presets.is_empty() {
|
||||
self.add_info_message(
|
||||
"No collaboration modes are available right now.".to_string(),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<SelectionItem> = presets
|
||||
.into_iter()
|
||||
.map(|preset| {
|
||||
let name = match preset {
|
||||
CollaborationMode::Plan(_) => "Plan",
|
||||
CollaborationMode::PairProgramming(_) => "Pair Programming",
|
||||
CollaborationMode::Execute(_) => "Execute",
|
||||
CollaborationMode::Custom(_) => "Custom",
|
||||
};
|
||||
let is_current =
|
||||
collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset);
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::UpdateCollaborationMode(preset.clone()));
|
||||
})];
|
||||
SelectionItem {
|
||||
name: name.to_string(),
|
||||
is_current,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Select Collaboration Mode".to_string()),
|
||||
subtitle: Some("Pick a collaboration preset.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn model_selection_actions(
|
||||
model_for_action: String,
|
||||
effort_for_action: Option<ReasoningEffortConfig>,
|
||||
@@ -3043,9 +3113,9 @@ impl ChatWidget {
|
||||
.or(Some(default_effort));
|
||||
|
||||
let model_slug = preset.model.to_string();
|
||||
let is_current_model = self.current_model() == Some(preset.model.as_str());
|
||||
let is_current_model = self.current_model() == preset.model.as_str();
|
||||
let highlight_choice = if is_current_model {
|
||||
self.config.model_reasoning_effort
|
||||
self.stored_collaboration_mode.reasoning_effort()
|
||||
} else {
|
||||
default_choice
|
||||
};
|
||||
@@ -3824,6 +3894,18 @@ impl ChatWidget {
|
||||
}
|
||||
if feature == Feature::CollaborationModes {
|
||||
self.bottom_pane.set_collaboration_modes_enabled(enabled);
|
||||
let settings = match &self.stored_collaboration_mode {
|
||||
CollaborationMode::Plan(settings)
|
||||
| CollaborationMode::PairProgramming(settings)
|
||||
| CollaborationMode::Execute(settings)
|
||||
| CollaborationMode::Custom(settings) => settings.clone(),
|
||||
};
|
||||
self.stored_collaboration_mode = if enabled {
|
||||
collaboration_modes::default_mode(self.models_manager.as_ref())
|
||||
.unwrap_or(CollaborationMode::Custom(settings))
|
||||
} else {
|
||||
CollaborationMode::Custom(settings)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3850,19 +3932,32 @@ impl ChatWidget {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Set the reasoning effort in the widget's config copy.
|
||||
/// Set the reasoning effort in the stored collaboration mode.
|
||||
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
||||
self.config.model_reasoning_effort = effort;
|
||||
self.stored_collaboration_mode =
|
||||
self.stored_collaboration_mode
|
||||
.with_updates(None, Some(effort), None);
|
||||
}
|
||||
|
||||
/// Set the model in the widget's config copy.
|
||||
/// Set the model in the widget's config copy and stored collaboration mode.
|
||||
pub(crate) fn set_model(&mut self, model: &str) {
|
||||
self.session_header.set_model(model);
|
||||
self.model = Some(model.to_string());
|
||||
self.stored_collaboration_mode =
|
||||
self.stored_collaboration_mode
|
||||
.with_updates(Some(model.to_string()), None, None);
|
||||
}
|
||||
|
||||
fn current_model(&self) -> Option<&str> {
|
||||
self.model.as_deref()
|
||||
pub(crate) fn current_model(&self) -> &str {
|
||||
self.stored_collaboration_mode.model()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn current_reasoning_effort(&self) -> Option<ReasoningEffortConfig> {
|
||||
self.stored_collaboration_mode.reasoning_effort()
|
||||
}
|
||||
|
||||
fn is_session_configured(&self) -> bool {
|
||||
self.conversation_id.is_some()
|
||||
}
|
||||
|
||||
fn collaboration_modes_enabled(&self) -> bool {
|
||||
@@ -3870,31 +3965,63 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn model_display_name(&self) -> &str {
|
||||
self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME)
|
||||
let model = self.current_model();
|
||||
if model.is_empty() {
|
||||
DEFAULT_MODEL_DISPLAY_NAME
|
||||
} else {
|
||||
model
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the label for the current collaboration mode.
|
||||
fn collaboration_mode_label(&self) -> Option<&'static str> {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return None;
|
||||
}
|
||||
match &self.stored_collaboration_mode {
|
||||
CollaborationMode::Plan(_) => Some("Plan"),
|
||||
CollaborationMode::PairProgramming(_) => Some("Pair Programming"),
|
||||
CollaborationMode::Execute(_) => Some("Execute"),
|
||||
CollaborationMode::Custom(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan).
|
||||
fn cycle_collaboration_mode(&mut self) {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let next = self.collaboration_mode.next();
|
||||
self.set_collaboration_mode(next);
|
||||
if let Some(next_mode) = collaboration_modes::next_mode(
|
||||
self.models_manager.as_ref(),
|
||||
&self.stored_collaboration_mode,
|
||||
) {
|
||||
self.set_collaboration_mode(next_mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the selected collaboration mode.
|
||||
/// Update the stored collaboration mode.
|
||||
///
|
||||
/// When collaboration modes are enabled, the current selection is attached to *every*
|
||||
/// When collaboration modes are enabled, the current mode is attached to *every*
|
||||
/// submission as `Op::UserTurn { collaboration_mode: Some(...) }`.
|
||||
fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) {
|
||||
pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) {
|
||||
if !self.collaboration_modes_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.collaboration_mode = selection;
|
||||
let flash = collaboration_modes::flash_line(selection);
|
||||
const FLASH_DURATION: Duration = Duration::from_secs(2);
|
||||
self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION);
|
||||
self.stored_collaboration_mode = mode;
|
||||
|
||||
let label = self.collaboration_mode_label();
|
||||
if let Some(label) = label {
|
||||
let flash = Line::from(vec![
|
||||
label.bold(),
|
||||
" (".dim(),
|
||||
key_hint::shift(KeyCode::Tab).into(),
|
||||
" to change mode)".dim(),
|
||||
]);
|
||||
const FLASH_DURATION: Duration = Duration::from_secs(2);
|
||||
self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -4337,10 +4464,6 @@ impl ChatWidget {
|
||||
self.current_rollout_path.clone()
|
||||
}
|
||||
|
||||
fn is_session_configured(&self) -> bool {
|
||||
self.conversation_id.is_some()
|
||||
}
|
||||
|
||||
/// Returns a cache key describing the current in-flight active cell for the transcript overlay.
|
||||
///
|
||||
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
|
||||
|
||||
+2
-1
@@ -2,7 +2,9 @@
|
||||
source: tui2/src/chatwidget/tests.rs
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
• Working (0s • esc to interrupt)
|
||||
|
||||
↳ Hello, world! 0
|
||||
↳ Hello, world! 1
|
||||
↳ Hello, world! 2
|
||||
@@ -21,7 +23,6 @@ expression: term.backend().vt100().screen().contents()
|
||||
↳ Hello, world! 15
|
||||
↳ Hello, world! 16
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
100% context left · ? for shortcuts
|
||||
|
||||
@@ -59,6 +59,7 @@ use codex_core::protocol::WarningEvent;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
@@ -180,10 +181,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
|
||||
|
||||
let placeholder = "[Image #1]";
|
||||
let message = format!("{placeholder} replayed");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: Some(placeholder.to_string()),
|
||||
}];
|
||||
let text_elements = vec![TextElement::new(
|
||||
(0..placeholder.len()).into(),
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
let local_images = vec![PathBuf::from("/tmp/replay.png")];
|
||||
|
||||
let conversation_id = ThreadId::new();
|
||||
@@ -262,10 +263,10 @@ async fn submission_preserves_text_elements_and_local_images() {
|
||||
|
||||
let placeholder = "[Image #1]";
|
||||
let text = format!("{placeholder} submit");
|
||||
let text_elements = vec![TextElement {
|
||||
byte_range: (0..placeholder.len()).into(),
|
||||
placeholder: Some(placeholder.to_string()),
|
||||
}];
|
||||
let text_elements = vec![TextElement::new(
|
||||
(0..placeholder.len()).into(),
|
||||
Some(placeholder.to_string()),
|
||||
)];
|
||||
let local_images = vec![PathBuf::from("/tmp/submitted.png")];
|
||||
|
||||
chat.bottom_pane
|
||||
@@ -318,26 +319,26 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
|
||||
let first_placeholder = "[Image #1]";
|
||||
let first_text = format!("{first_placeholder} first");
|
||||
let first_elements = vec![TextElement {
|
||||
byte_range: (0..first_placeholder.len()).into(),
|
||||
placeholder: Some(first_placeholder.to_string()),
|
||||
}];
|
||||
let first_elements = vec![TextElement::new(
|
||||
(0..first_placeholder.len()).into(),
|
||||
Some(first_placeholder.to_string()),
|
||||
)];
|
||||
let first_images = [PathBuf::from("/tmp/first.png")];
|
||||
|
||||
let second_placeholder = "[Image #1]";
|
||||
let second_text = format!("{second_placeholder} second");
|
||||
let second_elements = vec![TextElement {
|
||||
byte_range: (0..second_placeholder.len()).into(),
|
||||
placeholder: Some(second_placeholder.to_string()),
|
||||
}];
|
||||
let second_elements = vec![TextElement::new(
|
||||
(0..second_placeholder.len()).into(),
|
||||
Some(second_placeholder.to_string()),
|
||||
)];
|
||||
let second_images = [PathBuf::from("/tmp/second.png")];
|
||||
|
||||
let existing_placeholder = "[Image #1]";
|
||||
let existing_text = format!("{existing_placeholder} existing");
|
||||
let existing_elements = vec![TextElement {
|
||||
byte_range: (0..existing_placeholder.len()).into(),
|
||||
placeholder: Some(existing_placeholder.to_string()),
|
||||
}];
|
||||
let existing_elements = vec![TextElement::new(
|
||||
(0..existing_placeholder.len()).into(),
|
||||
Some(existing_placeholder.to_string()),
|
||||
)];
|
||||
let existing_images = vec![PathBuf::from("/tmp/existing.png")];
|
||||
|
||||
chat.queued_user_messages.push_back(UserMessage {
|
||||
@@ -380,18 +381,18 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
|
||||
let second_start = first.len() + 1;
|
||||
let third_start = second_start + second.len() + 1;
|
||||
let expected_elements = vec![
|
||||
TextElement {
|
||||
byte_range: (first_start..first_start + "[Image #1]".len()).into(),
|
||||
placeholder: Some("[Image #1]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: (second_start..second_start + "[Image #2]".len()).into(),
|
||||
placeholder: Some("[Image #2]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: (third_start..third_start + "[Image #3]".len()).into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
TextElement::new(
|
||||
(first_start..first_start + "[Image #1]".len()).into(),
|
||||
Some("[Image #1]".to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
(second_start..second_start + "[Image #2]".len()).into(),
|
||||
Some("[Image #2]".to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
(third_start..third_start + "[Image #3]".len()).into(),
|
||||
Some("[Image #3]".to_string()),
|
||||
),
|
||||
];
|
||||
assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements);
|
||||
assert_eq!(
|
||||
@@ -410,14 +411,14 @@ async fn remap_placeholders_uses_attachment_labels() {
|
||||
let placeholder_two = "[Image #2]";
|
||||
let text = format!("{placeholder_two} before {placeholder_one}");
|
||||
let elements = vec![
|
||||
TextElement {
|
||||
byte_range: (0..placeholder_two.len()).into(),
|
||||
placeholder: Some(placeholder_two.to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
placeholder: Some(placeholder_one.to_string()),
|
||||
},
|
||||
TextElement::new(
|
||||
(0..placeholder_two.len()).into(),
|
||||
Some(placeholder_two.to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
Some(placeholder_one.to_string()),
|
||||
),
|
||||
];
|
||||
|
||||
let attachments = vec![
|
||||
@@ -442,15 +443,14 @@ async fn remap_placeholders_uses_attachment_labels() {
|
||||
assert_eq!(
|
||||
remapped.text_elements,
|
||||
vec![
|
||||
TextElement {
|
||||
byte_range: (0.."[Image #4]".len()).into(),
|
||||
placeholder: Some("[Image #4]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len())
|
||||
.into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
TextElement::new(
|
||||
(0.."[Image #4]".len()).into(),
|
||||
Some("[Image #4]".to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(),
|
||||
Some("[Image #3]".to_string()),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -474,14 +474,11 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() {
|
||||
let placeholder_two = "[Image #2]";
|
||||
let text = format!("{placeholder_two} before {placeholder_one}");
|
||||
let elements = vec![
|
||||
TextElement {
|
||||
byte_range: (0..placeholder_two.len()).into(),
|
||||
placeholder: None,
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
placeholder: None,
|
||||
},
|
||||
TextElement::new((0..placeholder_two.len()).into(), None),
|
||||
TextElement::new(
|
||||
("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
let attachments = vec![
|
||||
@@ -506,15 +503,14 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() {
|
||||
assert_eq!(
|
||||
remapped.text_elements,
|
||||
vec![
|
||||
TextElement {
|
||||
byte_range: (0.."[Image #4]".len()).into(),
|
||||
placeholder: Some("[Image #4]".to_string()),
|
||||
},
|
||||
TextElement {
|
||||
byte_range: ("[Image #4] before ".len().."[Image #4] before [Image #3]".len())
|
||||
.into(),
|
||||
placeholder: Some("[Image #3]".to_string()),
|
||||
},
|
||||
TextElement::new(
|
||||
(0.."[Image #4]".len()).into(),
|
||||
Some("[Image #4]".to_string()),
|
||||
),
|
||||
TextElement::new(
|
||||
("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(),
|
||||
Some("[Image #3]".to_string()),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -692,13 +688,14 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let cfg = test_config().await;
|
||||
let model = cfg.model.clone();
|
||||
let thread_manager = Arc::new(ThreadManager::with_models_provider(
|
||||
CodexAuth::from_api_key("test"),
|
||||
cfg.model_provider.clone(),
|
||||
));
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let init = ChatWidgetInit {
|
||||
config: cfg.clone(),
|
||||
config: cfg,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: tx,
|
||||
initial_user_message: None,
|
||||
@@ -707,7 +704,7 @@ async fn helpers_are_available_and_do_not_panic() {
|
||||
models_manager: thread_manager.get_models_manager(),
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
is_first_run: true,
|
||||
model: cfg.model,
|
||||
model,
|
||||
};
|
||||
let mut w = ChatWidget::new(init, thread_manager);
|
||||
// Basic construction sanity.
|
||||
@@ -746,6 +743,24 @@ async fn make_chatwidget_manual(
|
||||
bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes));
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
let codex_home = cfg.codex_home.clone();
|
||||
let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone()));
|
||||
let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes);
|
||||
let reasoning_effort = None;
|
||||
let stored_collaboration_mode = if collaboration_modes_enabled {
|
||||
collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: resolved_model.clone(),
|
||||
reasoning_effort,
|
||||
developer_instructions: None,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
CollaborationMode::Custom(Settings {
|
||||
model: resolved_model.clone(),
|
||||
reasoning_effort,
|
||||
developer_instructions: None,
|
||||
})
|
||||
};
|
||||
let widget = ChatWidget {
|
||||
app_event_tx,
|
||||
codex_op_tx: op_tx,
|
||||
@@ -753,10 +768,9 @@ async fn make_chatwidget_manual(
|
||||
active_cell: None,
|
||||
active_cell_revision: 0,
|
||||
config: cfg,
|
||||
model: Some(resolved_model.clone()),
|
||||
collaboration_mode: CollaborationModeSelection::default(),
|
||||
auth_manager: auth_manager.clone(),
|
||||
models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)),
|
||||
stored_collaboration_mode,
|
||||
auth_manager,
|
||||
models_manager,
|
||||
session_header: SessionHeader::new(resolved_model),
|
||||
initial_user_message: None,
|
||||
token_info: None,
|
||||
@@ -1689,75 +1703,66 @@ async fn slash_init_skips_when_project_doc_exists() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_collaboration_mode_selection_accepts_common_aliases() {
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("plan"),
|
||||
Some(CollaborationModeSelection::Plan)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("PAIR"),
|
||||
Some(CollaborationModeSelection::PairProgramming)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("pair_programming"),
|
||||
Some(CollaborationModeSelection::PairProgramming)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("pp"),
|
||||
Some(CollaborationModeSelection::PairProgramming)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection(" exec "),
|
||||
Some(CollaborationModeSelection::Execute)
|
||||
);
|
||||
assert_eq!(
|
||||
collaboration_modes::parse_selection("execute"),
|
||||
Some(CollaborationModeSelection::Execute)
|
||||
);
|
||||
assert_eq!(collaboration_modes::parse_selection("unknown"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, false);
|
||||
|
||||
let initial = chat.collaboration_mode;
|
||||
let initial = chat.stored_collaboration_mode.clone();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.collaboration_mode, initial);
|
||||
assert_eq!(chat.stored_collaboration_mode, initial);
|
||||
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute);
|
||||
assert!(matches!(
|
||||
chat.stored_collaboration_mode,
|
||||
CollaborationMode::Execute(_)
|
||||
));
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan);
|
||||
assert!(matches!(
|
||||
chat.stored_collaboration_mode,
|
||||
CollaborationMode::Plan(_)
|
||||
));
|
||||
|
||||
chat.on_task_started();
|
||||
let before = chat.stored_collaboration_mode.clone();
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
||||
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan);
|
||||
assert_eq!(chat.stored_collaboration_mode, before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
async fn collab_slash_command_opens_picker_and_updates_mode() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.conversation_id = Some(ThreadId::new());
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string());
|
||||
assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan);
|
||||
chat.dispatch_command(SlashCommand::Collab);
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert!(
|
||||
popup.contains("Select Collaboration Mode"),
|
||||
"expected collaboration picker: {popup}"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
let selected_mode = match rx.try_recv() {
|
||||
Ok(AppEvent::UpdateCollaborationMode(mode)) => mode,
|
||||
other => panic!("expected UpdateCollaborationMode event, got {other:?}"),
|
||||
};
|
||||
chat.set_collaboration_mode(selected_mode);
|
||||
|
||||
chat.bottom_pane
|
||||
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn {
|
||||
collaboration_mode: Some(CollaborationMode::Plan(_)),
|
||||
collaboration_mode: Some(CollaborationMode::PairProgramming(_)),
|
||||
..
|
||||
} => {}
|
||||
other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"),
|
||||
other => {
|
||||
panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}")
|
||||
}
|
||||
}
|
||||
|
||||
chat.bottom_pane
|
||||
@@ -1765,10 +1770,12 @@ async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() {
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn {
|
||||
collaboration_mode: Some(CollaborationMode::Plan(_)),
|
||||
collaboration_mode: Some(CollaborationMode::PairProgramming(_)),
|
||||
..
|
||||
} => {}
|
||||
other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"),
|
||||
other => {
|
||||
panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1786,10 +1793,22 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() {
|
||||
collaboration_mode: Some(CollaborationMode::PairProgramming(_)),
|
||||
..
|
||||
} => {}
|
||||
other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"),
|
||||
other => {
|
||||
panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collab_mode_enabling_sets_pair_programming_default() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, true);
|
||||
assert!(matches!(
|
||||
chat.stored_collaboration_mode,
|
||||
CollaborationMode::PairProgramming(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_quit_requests_exit() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
@@ -2436,7 +2455,7 @@ async fn model_reasoning_selection_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
|
||||
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High);
|
||||
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
|
||||
|
||||
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
|
||||
chat.open_reasoning_popup(preset);
|
||||
@@ -2450,7 +2469,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
|
||||
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh);
|
||||
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
|
||||
|
||||
let preset = get_available_model(&chat, "gpt-5.1-codex-max");
|
||||
chat.open_reasoning_popup(preset);
|
||||
|
||||
@@ -1,135 +1,49 @@
|
||||
//! Collaboration mode selection + rendering helpers for the TUI.
|
||||
//!
|
||||
//! This module is intentionally UI-focused:
|
||||
//! - It owns the user-facing set of selectable collaboration modes and how they cycle.
|
||||
//! - It parses `/collab <mode>` arguments into a selection.
|
||||
//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by
|
||||
//! picking from the `ModelsManager` builtin collaboration presets.
|
||||
//! - It builds the small footer "flash" line shown after changing modes.
|
||||
//!
|
||||
//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed
|
||||
//! (feature flag, task running, modals open, etc.). This module just provides the building blocks.
|
||||
|
||||
use crate::key_hint;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// The user-facing collaboration mode choices supported by the TUI.
|
||||
///
|
||||
/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling
|
||||
/// order, while `CollaborationMode` can carry nested settings/prompt configuration.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum Selection {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ModeKind {
|
||||
Plan,
|
||||
#[default]
|
||||
PairProgramming,
|
||||
Execute,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// Cycle to the next selection.
|
||||
///
|
||||
/// The TUI cycles through a small, fixed set of presets.
|
||||
pub(crate) fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Plan => Self::PairProgramming,
|
||||
Self::PairProgramming => Self::Execute,
|
||||
Self::Execute => Self::Plan,
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing label used in UI surfaces like `/status` and the footer flash.
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Plan => "Plan",
|
||||
Self::PairProgramming => "Pair Programming",
|
||||
Self::Execute => "Execute",
|
||||
}
|
||||
fn mode_kind(mode: &CollaborationMode) -> ModeKind {
|
||||
match mode {
|
||||
CollaborationMode::Plan(_) => ModeKind::Plan,
|
||||
CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming,
|
||||
CollaborationMode::Execute(_) => ModeKind::Execute,
|
||||
CollaborationMode::Custom(_) => ModeKind::Custom,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection.
|
||||
///
|
||||
/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively.
|
||||
pub(crate) fn parse_selection(input: &str) -> Option<Selection> {
|
||||
let normalized: String = input
|
||||
.chars()
|
||||
.filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_')
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect();
|
||||
|
||||
match normalized.as_str() {
|
||||
"plan" => Some(Selection::Plan),
|
||||
"pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming),
|
||||
"execute" | "exec" => Some(Selection::Execute),
|
||||
_ => None,
|
||||
}
|
||||
pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option<CollaborationMode> {
|
||||
let presets = models_manager.list_collaboration_modes();
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| matches!(preset, CollaborationMode::PairProgramming(_)))
|
||||
.cloned()
|
||||
.or_else(|| presets.into_iter().next())
|
||||
}
|
||||
|
||||
/// Resolve a selection to a concrete collaboration mode preset.
|
||||
///
|
||||
/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this
|
||||
/// function selects the first preset of the desired variant.
|
||||
pub(crate) fn resolve_mode(
|
||||
pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool {
|
||||
mode_kind(a) == mode_kind(b)
|
||||
}
|
||||
|
||||
/// Cycle to the next collaboration mode preset in list order.
|
||||
pub(crate) fn next_mode(
|
||||
models_manager: &ModelsManager,
|
||||
selection: Selection,
|
||||
current: &CollaborationMode,
|
||||
) -> Option<CollaborationMode> {
|
||||
match selection {
|
||||
Selection::Plan => models_manager
|
||||
.list_collaboration_modes()
|
||||
.into_iter()
|
||||
.find(|mode| matches!(mode, CollaborationMode::Plan(_))),
|
||||
Selection::PairProgramming => models_manager
|
||||
.list_collaboration_modes()
|
||||
.into_iter()
|
||||
.find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))),
|
||||
Selection::Execute => models_manager
|
||||
.list_collaboration_modes()
|
||||
.into_iter()
|
||||
.find(|mode| matches!(mode, CollaborationMode::Execute(_))),
|
||||
let presets = models_manager.list_collaboration_modes();
|
||||
if presets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode
|
||||
/// when the desired preset is unavailable.
|
||||
///
|
||||
/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when
|
||||
/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries
|
||||
/// an explicit collaboration mode so core doesn't fall back to `Custom`.
|
||||
pub(crate) fn resolve_mode_or_fallback(
|
||||
models_manager: &ModelsManager,
|
||||
selection: Selection,
|
||||
fallback_model: &str,
|
||||
fallback_effort: Option<ReasoningEffort>,
|
||||
) -> CollaborationMode {
|
||||
resolve_mode(models_manager, selection).unwrap_or_else(|| {
|
||||
let settings = Settings {
|
||||
model: fallback_model.to_string(),
|
||||
reasoning_effort: fallback_effort,
|
||||
developer_instructions: None,
|
||||
};
|
||||
|
||||
match selection {
|
||||
Selection::Plan => CollaborationMode::Plan(settings),
|
||||
Selection::PairProgramming => CollaborationMode::PairProgramming(settings),
|
||||
Selection::Execute => CollaborationMode::Execute(settings),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a 1-line footer "flash" that is shown after switching modes.
|
||||
///
|
||||
/// The `ChatWidget` controls when to show this and how long it should remain visible.
|
||||
pub(crate) fn flash_line(selection: Selection) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
selection.label().bold(),
|
||||
" (".dim(),
|
||||
key_hint::shift(KeyCode::Tab).into(),
|
||||
" to change mode)".dim(),
|
||||
])
|
||||
let current_kind = mode_kind(current);
|
||||
let next_index = presets
|
||||
.iter()
|
||||
.position(|preset| mode_kind(preset) == current_kind)
|
||||
.map_or(0, |idx| (idx + 1) % presets.len());
|
||||
presets.get(next_index).cloned()
|
||||
}
|
||||
|
||||
+51
-17
@@ -18,6 +18,7 @@ use codex_core::RolloutRecorder;
|
||||
use codex_core::ThreadSortKey;
|
||||
use codex_core::auth::enforce_login_restrictions;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_config_as_toml_with_cli_overrides;
|
||||
@@ -25,6 +26,7 @@ use codex_core::config::resolve_oss_provider;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::get_platform_sandbox;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::read_session_meta_line;
|
||||
use codex_core::terminal::Multiplexer;
|
||||
use codex_protocol::config_types::AltScreenMode;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
@@ -262,7 +264,6 @@ pub async fn run_main(
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let active_profile = config.active_profile.clone();
|
||||
let log_dir = codex_core::config::log_dir(&config)?;
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
// Open (or create) your log file, appending to it.
|
||||
@@ -355,16 +356,9 @@ pub async fn run_main(
|
||||
let terminal_info = codex_core::terminal::terminal_info();
|
||||
tracing::info!(terminal = ?terminal_info, "Detected terminal info");
|
||||
|
||||
run_ratatui_app(
|
||||
cli,
|
||||
config,
|
||||
overrides,
|
||||
cli_kv_overrides,
|
||||
active_profile,
|
||||
feedback,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
run_ratatui_app(cli, config, overrides, cli_kv_overrides, feedback)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
}
|
||||
|
||||
async fn run_ratatui_app(
|
||||
@@ -372,7 +366,6 @@ async fn run_ratatui_app(
|
||||
initial_config: Config,
|
||||
overrides: ConfigOverrides,
|
||||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||||
active_profile: Option<String>,
|
||||
feedback: codex_feedback::CodexFeedback,
|
||||
) -> color_eyre::Result<AppExitInfo> {
|
||||
color_eyre::install()?;
|
||||
@@ -424,15 +417,15 @@ async fn run_ratatui_app(
|
||||
initial_config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
let login_status = get_login_status(&initial_config);
|
||||
let should_show_trust_screen = should_show_trust_screen(&initial_config);
|
||||
let should_show_trust_screen_flag = should_show_trust_screen(&initial_config);
|
||||
let should_show_onboarding =
|
||||
should_show_onboarding(login_status, &initial_config, should_show_trust_screen);
|
||||
should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag);
|
||||
|
||||
let config = if should_show_onboarding {
|
||||
let onboarding_result = run_onboarding_app(
|
||||
OnboardingScreenArgs {
|
||||
show_login_screen: should_show_login_screen(login_status, &initial_config),
|
||||
show_trust_screen: should_show_trust_screen,
|
||||
show_trust_screen: should_show_trust_screen_flag,
|
||||
login_status,
|
||||
auth_manager: auth_manager.clone(),
|
||||
config: initial_config.clone(),
|
||||
@@ -458,7 +451,7 @@ async fn run_ratatui_app(
|
||||
.map(|d| d == TrustDirectorySelection::Trust)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
load_config_or_exit(cli_kv_overrides, overrides).await
|
||||
load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await
|
||||
} else {
|
||||
initial_config
|
||||
}
|
||||
@@ -594,6 +587,33 @@ async fn run_ratatui_app(
|
||||
resume_picker::SessionSelection::StartFresh
|
||||
};
|
||||
|
||||
let config = match &session_selection {
|
||||
resume_picker::SessionSelection::Resume(path)
|
||||
| resume_picker::SessionSelection::Fork(path) => {
|
||||
let history_cwd = match read_session_meta_line(path).await {
|
||||
Ok(meta_line) => Some(meta_line.meta.cwd),
|
||||
Err(err) => {
|
||||
let rollout_path = path.display().to_string();
|
||||
tracing::warn!(
|
||||
%rollout_path,
|
||||
%err,
|
||||
"Failed to read session metadata from rollout"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
history_cwd,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => config,
|
||||
};
|
||||
let active_profile = config.active_profile.clone();
|
||||
let should_show_trust_screen = should_show_trust_screen(&config);
|
||||
|
||||
let Cli {
|
||||
prompt,
|
||||
images,
|
||||
@@ -699,9 +719,23 @@ fn get_login_status(config: &Config) -> LoginStatus {
|
||||
async fn load_config_or_exit(
|
||||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||||
overrides: ConfigOverrides,
|
||||
) -> Config {
|
||||
load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await
|
||||
}
|
||||
|
||||
async fn load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||||
overrides: ConfigOverrides,
|
||||
fallback_cwd: Option<PathBuf>,
|
||||
) -> Config {
|
||||
#[allow(clippy::print_stderr)]
|
||||
match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await {
|
||||
match ConfigBuilder::default()
|
||||
.cli_overrides(cli_kv_overrides)
|
||||
.harness_overrides(overrides)
|
||||
.fallback_cwd(fallback_cwd)
|
||||
.build()
|
||||
.await
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
eprintln!("Error loading configuration: {err}");
|
||||
|
||||
@@ -5,7 +5,8 @@ use crate::history_cell::with_border_with_inner_width;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
use codex_common::create_config_summary_entries;
|
||||
use codex_common::summarize_sandbox_policy;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::NetworkAccess;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
@@ -13,6 +14,7 @@ use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeSet;
|
||||
@@ -85,6 +87,7 @@ pub(crate) fn new_status_output(
|
||||
now: DateTime<Local>,
|
||||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> CompositeHistoryCell {
|
||||
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
|
||||
let card = StatusHistoryCell::new(
|
||||
@@ -99,6 +102,7 @@ pub(crate) fn new_status_output(
|
||||
now,
|
||||
model_name,
|
||||
collaboration_mode,
|
||||
reasoning_effort_override,
|
||||
);
|
||||
|
||||
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)])
|
||||
@@ -118,8 +122,29 @@ impl StatusHistoryCell {
|
||||
now: DateTime<Local>,
|
||||
model_name: &str,
|
||||
collaboration_mode: Option<&str>,
|
||||
reasoning_effort_override: Option<Option<ReasoningEffort>>,
|
||||
) -> Self {
|
||||
let config_entries = create_config_summary_entries(config, model_name);
|
||||
let mut config_entries = vec![
|
||||
("workdir", config.cwd.display().to_string()),
|
||||
("model", model_name.to_string()),
|
||||
("provider", config.model_provider_id.clone()),
|
||||
("approval", config.approval_policy.value().to_string()),
|
||||
(
|
||||
"sandbox",
|
||||
summarize_sandbox_policy(config.sandbox_policy.get()),
|
||||
),
|
||||
];
|
||||
if config.model_provider.wire_api == WireApi::Responses {
|
||||
let effort_value = reasoning_effort_override
|
||||
.unwrap_or(None)
|
||||
.map(|effort| effort.to_string())
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
config_entries.push(("reasoning effort", effort_value));
|
||||
config_entries.push((
|
||||
"reasoning summaries",
|
||||
config.model_reasoning_summary.to_string(),
|
||||
));
|
||||
}
|
||||
let (model_name, model_details) = compose_model_display(model_name, &config_entries);
|
||||
let approval = config_entries
|
||||
.iter()
|
||||
|
||||
@@ -95,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config.model_reasoning_effort = Some(ReasoningEffort::High);
|
||||
config.model_reasoning_summary = ReasoningSummary::Detailed;
|
||||
config
|
||||
.sandbox_policy
|
||||
@@ -141,6 +140,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let token_info = token_info_for(&model_slug, &config, &usage);
|
||||
|
||||
let reasoning_effort_override = Some(Some(ReasoningEffort::High));
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
@@ -153,6 +153,7 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
reasoning_effort_override,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -205,6 +206,7 @@ async fn status_snapshot_includes_forked_from() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -263,6 +265,7 @@ async fn status_snapshot_includes_monthly_limit() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -308,6 +311,7 @@ async fn status_snapshot_shows_unlimited_credits() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
@@ -353,6 +357,7 @@ async fn status_snapshot_shows_positive_credits() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
@@ -398,6 +403,7 @@ async fn status_snapshot_hides_zero_credits() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
@@ -441,6 +447,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
assert!(
|
||||
@@ -484,6 +491,7 @@ async fn status_card_token_usage_excludes_cached_tokens() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered = render_lines(&composite.display_lines(120));
|
||||
|
||||
@@ -499,7 +507,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
config.model_reasoning_effort = Some(ReasoningEffort::High);
|
||||
config.model_reasoning_summary = ReasoningSummary::Detailed;
|
||||
config.cwd = PathBuf::from("/workspace/tests");
|
||||
|
||||
@@ -530,6 +537,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
|
||||
let model_slug = ModelsManager::get_model_offline(config.model.as_deref());
|
||||
let token_info = token_info_for(&model_slug, &config, &usage);
|
||||
let reasoning_effort_override = Some(Some(ReasoningEffort::High));
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
&auth_manager,
|
||||
@@ -542,6 +550,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
reasoning_effort_override,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(70));
|
||||
if cfg!(windows) {
|
||||
@@ -589,6 +598,7 @@ async fn status_snapshot_shows_missing_limits_message() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -653,6 +663,7 @@ async fn status_snapshot_includes_credits_and_limits() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -706,6 +717,7 @@ async fn status_snapshot_shows_empty_limits_message() {
|
||||
captured_at,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -768,6 +780,7 @@ async fn status_snapshot_shows_stale_limits_message() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -834,6 +847,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(80));
|
||||
if cfg!(windows) {
|
||||
@@ -890,6 +904,7 @@ async fn status_context_window_uses_last_usage() {
|
||||
now,
|
||||
&model_slug,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let rendered_lines = render_lines(&composite.display_lines(80));
|
||||
let context_line = rendered_lines
|
||||
|
||||
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
dirs = { workspace = true }
|
||||
path-absolutize = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
@@ -18,5 +19,6 @@ ts-rs = { workspace = true, features = [
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use dirs::home_dir;
|
||||
use path_absolutize::Absolutize;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
@@ -21,16 +22,35 @@ use ts_rs::TS;
|
||||
pub struct AbsolutePathBuf(PathBuf);
|
||||
|
||||
impl AbsolutePathBuf {
|
||||
fn maybe_expand_home_directory(path: &Path) -> PathBuf {
|
||||
let Some(path_str) = path.to_str() else {
|
||||
return path.to_path_buf();
|
||||
};
|
||||
if cfg!(not(target_os = "windows"))
|
||||
&& let Some(home) = home_dir()
|
||||
{
|
||||
if path_str == "~" {
|
||||
return home;
|
||||
}
|
||||
if let Some(rest) = path_str.strip_prefix("~/") {
|
||||
return home.join(rest);
|
||||
}
|
||||
}
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
pub fn resolve_path_against_base<P: AsRef<Path>, B: AsRef<Path>>(
|
||||
path: P,
|
||||
base_path: B,
|
||||
) -> std::io::Result<Self> {
|
||||
let absolute_path = path.as_ref().absolutize_from(base_path.as_ref())?;
|
||||
let expanded = Self::maybe_expand_home_directory(path.as_ref());
|
||||
let absolute_path = expanded.absolutize_from(base_path.as_ref())?;
|
||||
Ok(Self(absolute_path.into_owned()))
|
||||
}
|
||||
|
||||
pub fn from_absolute_path<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
|
||||
let absolute_path = path.as_ref().absolutize()?;
|
||||
let expanded = Self::maybe_expand_home_directory(path.as_ref());
|
||||
let absolute_path = expanded.absolutize()?;
|
||||
Ok(Self(absolute_path.into_owned()))
|
||||
}
|
||||
|
||||
@@ -165,6 +185,7 @@ impl<'de> Deserialize<'de> for AbsolutePathBuf {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
@@ -203,4 +224,47 @@ mod tests {
|
||||
base_dir.join(relative_path).as_path()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn home_directory_root_on_non_windows_is_expanded_in_deserialization() {
|
||||
let Some(home) = home_dir() else {
|
||||
return;
|
||||
};
|
||||
let temp_dir = tempdir().expect("base dir");
|
||||
let abs_path_buf = {
|
||||
let _guard = AbsolutePathBufGuard::new(temp_dir.path());
|
||||
serde_json::from_str::<AbsolutePathBuf>("\"~\"").expect("failed to deserialize")
|
||||
};
|
||||
assert_eq!(abs_path_buf.as_path(), home.as_path());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn home_directory_subpath_on_non_windows_is_expanded_in_deserialization() {
|
||||
let Some(home) = home_dir() else {
|
||||
return;
|
||||
};
|
||||
let temp_dir = tempdir().expect("base dir");
|
||||
let abs_path_buf = {
|
||||
let _guard = AbsolutePathBufGuard::new(temp_dir.path());
|
||||
serde_json::from_str::<AbsolutePathBuf>("\"~/code\"").expect("failed to deserialize")
|
||||
};
|
||||
assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn home_directory_on_windows_is_not_expanded_in_deserialization() {
|
||||
let temp_dir = tempdir().expect("base dir");
|
||||
let base_dir = temp_dir.path();
|
||||
let abs_path_buf = {
|
||||
let _guard = AbsolutePathBufGuard::new(base_dir);
|
||||
serde_json::from_str::<AbsolutePathBuf>("\"~/code\"").expect("failed to deserialize")
|
||||
};
|
||||
assert_eq!(
|
||||
abs_path_buf.as_path(),
|
||||
base_dir.join("~").join("code").as_path()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ pub fn compute_allow_paths(
|
||||
let canonical = canonicalize(&candidate).unwrap_or(candidate);
|
||||
add_allow(canonical.clone());
|
||||
|
||||
let git_dir = canonical.join(".git");
|
||||
if git_dir.is_dir() {
|
||||
add_deny(git_dir);
|
||||
let git_entry = canonical.join(".git");
|
||||
if git_entry.exists() {
|
||||
add_deny(git_entry);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,6 +178,33 @@ mod tests {
|
||||
assert_eq!(expected_deny, paths.deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_git_file_inside_writable_root() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let command_cwd = tmp.path().join("workspace");
|
||||
let git_file = command_cwd.join(".git");
|
||||
let _ = fs::create_dir_all(&command_cwd);
|
||||
let _ = fs::write(&git_file, "gitdir: .git/worktrees/example");
|
||||
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
|
||||
let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new());
|
||||
let expected_allow: HashSet<PathBuf> = [dunce::canonicalize(&command_cwd).unwrap()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let expected_deny: HashSet<PathBuf> = [dunce::canonicalize(&git_file).unwrap()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
assert_eq!(expected_allow, paths.allow);
|
||||
assert_eq!(expected_deny, paths.deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_git_dir_when_missing() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
|
||||
@@ -10,6 +10,7 @@ use anyhow::anyhow;
|
||||
use anyhow::Result;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::c_void;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
@@ -67,9 +68,9 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap<String, String>
|
||||
.cloned()
|
||||
.or_else(|| std::env::var("PATH").ok())
|
||||
{
|
||||
for part in path.split(std::path::MAIN_SEPARATOR) {
|
||||
if !part.is_empty() {
|
||||
unique_push(&mut set, &mut out, PathBuf::from(part));
|
||||
for part in std::env::split_paths(OsStr::new(&path)) {
|
||||
if !part.as_os_str().is_empty() {
|
||||
unique_push(&mut set, &mut out, part);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,3 +299,41 @@ pub fn apply_capability_denies_for_world_writable(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::gather_candidates;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn gathers_path_entries_by_list_separator() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let dir_a = tmp.path().join("Tools");
|
||||
let dir_b = tmp.path().join("Bin");
|
||||
let dir_space = tmp.path().join("Program Files");
|
||||
fs::create_dir_all(&dir_a).expect("dir a");
|
||||
fs::create_dir_all(&dir_b).expect("dir b");
|
||||
fs::create_dir_all(&dir_space).expect("dir space");
|
||||
|
||||
let mut env_map = HashMap::new();
|
||||
env_map.insert(
|
||||
"PATH".to_string(),
|
||||
format!(
|
||||
"{};{};{}",
|
||||
dir_a.display(),
|
||||
dir_b.display(),
|
||||
dir_space.display()
|
||||
),
|
||||
);
|
||||
|
||||
let candidates = gather_candidates(tmp.path(), &env_map);
|
||||
let canon_a = dir_a.canonicalize().expect("canon a");
|
||||
let canon_b = dir_b.canonicalize().expect("canon b");
|
||||
let canon_space = dir_space.canonicalize().expect("canon space");
|
||||
|
||||
assert!(candidates.contains(&canon_a));
|
||||
assert!(candidates.contains(&canon_b));
|
||||
assert!(candidates.contains(&canon_space));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::fs::File;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use windows_sys::Win32::Foundation::GetLastError;
|
||||
use windows_sys::Win32::Foundation::LocalFree;
|
||||
use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
|
||||
use windows_sys::Win32::NetworkManagement::NetManagement::NERR_Success;
|
||||
use windows_sys::Win32::NetworkManagement::NetManagement::NetLocalGroupAdd;
|
||||
@@ -27,7 +28,10 @@ use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1;
|
||||
use windows_sys::Win32::NetworkManagement::NetManagement::USER_INFO_1003;
|
||||
use windows_sys::Win32::NetworkManagement::NetManagement::USER_PRIV_USER;
|
||||
use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW;
|
||||
use windows_sys::Win32::Security::CopySid;
|
||||
use windows_sys::Win32::Security::GetLengthSid;
|
||||
use windows_sys::Win32::Security::LookupAccountNameW;
|
||||
use windows_sys::Win32::Security::LookupAccountSidW;
|
||||
use windows_sys::Win32::Security::SID_NAME_USE;
|
||||
|
||||
use codex_windows_sandbox::dpapi_protect;
|
||||
@@ -38,6 +42,11 @@ use codex_windows_sandbox::SETUP_VERSION;
|
||||
|
||||
pub const SANDBOX_USERS_GROUP: &str = "CodexSandboxUsers";
|
||||
const SANDBOX_USERS_GROUP_COMMENT: &str = "Codex sandbox internal group (managed)";
|
||||
const SID_ADMINISTRATORS: &str = "S-1-5-32-544";
|
||||
const SID_USERS: &str = "S-1-5-32-545";
|
||||
const SID_AUTHENTICATED_USERS: &str = "S-1-5-11";
|
||||
const SID_EVERYONE: &str = "S-1-1-0";
|
||||
const SID_SYSTEM: &str = "S-1-5-18";
|
||||
|
||||
pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> {
|
||||
ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log)
|
||||
@@ -119,17 +128,24 @@ pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<(
|
||||
}
|
||||
|
||||
// Ensure the principal is a regular local user account.
|
||||
let group = to_wide(OsStr::new("Users"));
|
||||
let member = LOCALGROUP_MEMBERS_INFO_3 {
|
||||
lgrmi3_domainandname: name_w.as_ptr() as *mut u16,
|
||||
};
|
||||
let _ = NetLocalGroupAddMembers(
|
||||
std::ptr::null(),
|
||||
group.as_ptr(),
|
||||
3,
|
||||
&member as *const _ as *mut u8,
|
||||
1,
|
||||
);
|
||||
if let Ok(group_name) = lookup_account_name_for_sid(SID_USERS) {
|
||||
let group = to_wide(OsStr::new(&group_name));
|
||||
let member = LOCALGROUP_MEMBERS_INFO_3 {
|
||||
lgrmi3_domainandname: name_w.as_ptr() as *mut u16,
|
||||
};
|
||||
let _ = NetLocalGroupAddMembers(
|
||||
std::ptr::null(),
|
||||
group.as_ptr(),
|
||||
3,
|
||||
&member as *const _ as *mut u8,
|
||||
1,
|
||||
);
|
||||
} else {
|
||||
super::log_line(
|
||||
log,
|
||||
"LookupAccountSidW failed for Users SID; skipping Users group membership",
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -184,6 +200,9 @@ pub fn ensure_local_group_member(group_name: &str, member_name: &str) -> Result<
|
||||
}
|
||||
|
||||
pub fn resolve_sid(name: &str) -> Result<Vec<u8>> {
|
||||
if let Some(sid_str) = well_known_sid_str(name) {
|
||||
return sid_bytes_from_string(sid_str);
|
||||
}
|
||||
let name_w = to_wide(OsStr::new(name));
|
||||
let mut sid_buffer = vec![0u8; 68];
|
||||
let mut sid_len: u32 = sid_buffer.len() as u32;
|
||||
@@ -218,6 +237,104 @@ pub fn resolve_sid(name: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn well_known_sid_str(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"Administrators" => Some(SID_ADMINISTRATORS),
|
||||
"Users" => Some(SID_USERS),
|
||||
"Authenticated Users" => Some(SID_AUTHENTICATED_USERS),
|
||||
"Everyone" => Some(SID_EVERYONE),
|
||||
"SYSTEM" => Some(SID_SYSTEM),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sid_bytes_from_string(sid_str: &str) -> Result<Vec<u8>> {
|
||||
let sid_w = to_wide(OsStr::new(sid_str));
|
||||
let mut psid: *mut c_void = std::ptr::null_mut();
|
||||
if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"ConvertStringSidToSidW failed for {sid_str}: {}",
|
||||
unsafe { GetLastError() }
|
||||
));
|
||||
}
|
||||
let sid_len = unsafe { GetLengthSid(psid) };
|
||||
if sid_len == 0 {
|
||||
unsafe {
|
||||
LocalFree(psid as _);
|
||||
}
|
||||
return Err(anyhow::anyhow!("GetLengthSid failed for {sid_str}"));
|
||||
}
|
||||
let mut out = vec![0u8; sid_len as usize];
|
||||
let ok = unsafe { CopySid(sid_len, out.as_mut_ptr() as *mut c_void, psid) };
|
||||
unsafe {
|
||||
LocalFree(psid as _);
|
||||
}
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!("CopySid failed for {sid_str}"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn lookup_account_name_for_sid(sid_str: &str) -> Result<String> {
|
||||
let sid_w = to_wide(OsStr::new(sid_str));
|
||||
let mut psid: *mut c_void = std::ptr::null_mut();
|
||||
if unsafe { ConvertStringSidToSidW(sid_w.as_ptr(), &mut psid) } == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"ConvertStringSidToSidW failed for {sid_str}: {}",
|
||||
unsafe { GetLastError() }
|
||||
));
|
||||
}
|
||||
let mut name_len: u32 = 0;
|
||||
let mut domain_len: u32 = 0;
|
||||
let mut use_type: SID_NAME_USE = 0;
|
||||
let ok = unsafe {
|
||||
LookupAccountSidW(
|
||||
std::ptr::null(),
|
||||
psid,
|
||||
std::ptr::null_mut(),
|
||||
&mut name_len,
|
||||
std::ptr::null_mut(),
|
||||
&mut domain_len,
|
||||
&mut use_type,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
let err = unsafe { GetLastError() };
|
||||
if err != ERROR_INSUFFICIENT_BUFFER {
|
||||
unsafe {
|
||||
LocalFree(psid as _);
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"LookupAccountSidW preflight failed for {sid_str}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut name_buf: Vec<u16> = vec![0u16; name_len as usize];
|
||||
let mut domain_buf: Vec<u16> = vec![0u16; domain_len as usize];
|
||||
let ok = unsafe {
|
||||
LookupAccountSidW(
|
||||
std::ptr::null(),
|
||||
psid,
|
||||
name_buf.as_mut_ptr(),
|
||||
&mut name_len,
|
||||
domain_buf.as_mut_ptr(),
|
||||
&mut domain_len,
|
||||
&mut use_type,
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
LocalFree(psid as _);
|
||||
}
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"LookupAccountSidW failed for {sid_str}: {}",
|
||||
unsafe { GetLastError() }
|
||||
));
|
||||
}
|
||||
let name = String::from_utf16_lossy(&name_buf);
|
||||
Ok(name.trim_end_matches('\0').to_string())
|
||||
}
|
||||
|
||||
pub fn sid_bytes_to_psid(sid: &[u8]) -> Result<*mut c_void> {
|
||||
let sid_str = string_from_sid_bytes(sid).map_err(anyhow::Error::msg)?;
|
||||
let sid_w = to_wide(OsStr::new(&sid_str));
|
||||
|
||||
@@ -26,7 +26,7 @@ use windows_sys::Win32::Security::CheckTokenMembership;
|
||||
use windows_sys::Win32::Security::FreeSid;
|
||||
use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY;
|
||||
|
||||
pub const SETUP_VERSION: u32 = 3;
|
||||
pub const SETUP_VERSION: u32 = 4;
|
||||
pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline";
|
||||
pub const ONLINE_USERNAME: &str = "CodexSandboxOnline";
|
||||
const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020;
|
||||
|
||||
Reference in New Issue
Block a user