mirror of
https://github.com/openai/codex.git
synced 2026-03-03 13:13:18 +00:00
Compare commits
16 Commits
nornagon/c
...
codex/load
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0055796543 | ||
|
|
05bc61c428 | ||
|
|
ecaa77e499 | ||
|
|
bc0a5843df | ||
|
|
6f1485d89c | ||
|
|
3b5996f988 | ||
|
|
d09a7535ed | ||
|
|
6b560a46be | ||
|
|
83726aebe6 | ||
|
|
dda7973531 | ||
|
|
d927cea570 | ||
|
|
bee23c7917 | ||
|
|
0ed71a0c3b | ||
|
|
e89f442a57 | ||
|
|
311bc6660d | ||
|
|
c800db5cd5 |
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"AdditionalFileSystemPermissions": {
|
||||
"properties": {
|
||||
"read": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
@@ -14,7 +18,7 @@
|
||||
},
|
||||
"write": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
|
||||
@@ -3411,7 +3411,7 @@
|
||||
"properties": {
|
||||
"read": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
@@ -3420,7 +3420,7 @@
|
||||
},
|
||||
"write": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"AdditionalFileSystemPermissions": {
|
||||
"properties": {
|
||||
"read": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
@@ -14,7 +18,7 @@
|
||||
},
|
||||
"write": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"properties": {
|
||||
"read": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"write": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
@@ -4722,7 +4722,7 @@
|
||||
"properties": {
|
||||
"read": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
@@ -4731,7 +4731,7 @@
|
||||
},
|
||||
"write": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"type": [
|
||||
"array",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "./AbsolutePathBuf";
|
||||
|
||||
export type FileSystemPermissions = { read: Array<string> | null, write: Array<string> | null, };
|
||||
export type FileSystemPermissions = { read: Array<AbsolutePathBuf> | null, write: Array<AbsolutePathBuf> | null, };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type AdditionalFileSystemPermissions = { read: Array<string> | null, write: Array<string> | null, };
|
||||
export type AdditionalFileSystemPermissions = { read: Array<AbsolutePathBuf> | null, write: Array<AbsolutePathBuf> | null, };
|
||||
|
||||
@@ -893,10 +893,15 @@ mod tests {
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_new_conversation() -> Result<()> {
|
||||
let request = ClientRequest::NewConversation {
|
||||
@@ -1533,7 +1538,7 @@ mod tests {
|
||||
additional_permissions: Some(v2::AdditionalPermissionProfile {
|
||||
network: None,
|
||||
file_system: Some(v2::AdditionalFileSystemPermissions {
|
||||
read: Some(vec![std::path::PathBuf::from("/tmp/allowed")]),
|
||||
read: Some(vec![absolute_path("/tmp/allowed")]),
|
||||
write: None,
|
||||
}),
|
||||
macos: None,
|
||||
|
||||
@@ -812,8 +812,8 @@ impl From<CoreNetworkApprovalContext> for NetworkApprovalContext {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct AdditionalFileSystemPermissions {
|
||||
pub read: Option<Vec<PathBuf>>,
|
||||
pub write: Option<Vec<PathBuf>>,
|
||||
pub read: Option<Vec<AbsolutePathBuf>>,
|
||||
pub write: Option<Vec<AbsolutePathBuf>>,
|
||||
}
|
||||
|
||||
impl From<CoreFileSystemPermissions> for AdditionalFileSystemPermissions {
|
||||
@@ -4170,6 +4170,37 @@ mod tests {
|
||||
AbsolutePathBuf::from_absolute_path(path).expect("path must be absolute")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_execution_request_approval_rejects_relative_additional_permission_paths() {
|
||||
let err = serde_json::from_value::<CommandExecutionRequestApprovalParams>(json!({
|
||||
"threadId": "thr_123",
|
||||
"turnId": "turn_123",
|
||||
"itemId": "call_123",
|
||||
"command": "cat file",
|
||||
"cwd": "/tmp",
|
||||
"commandActions": null,
|
||||
"reason": null,
|
||||
"networkApprovalContext": null,
|
||||
"additionalPermissions": {
|
||||
"network": null,
|
||||
"fileSystem": {
|
||||
"read": ["relative/path"],
|
||||
"write": null
|
||||
},
|
||||
"macos": null
|
||||
},
|
||||
"proposedExecpolicyAmendment": null,
|
||||
"proposedNetworkPolicyAmendments": null,
|
||||
"availableDecisions": null
|
||||
}))
|
||||
.expect_err("relative additional permission paths should fail");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("AbsolutePathBuf deserialized without a base path"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_policy_round_trips_external_sandbox_network_access() {
|
||||
let v2_policy = SandboxPolicy::ExternalSandbox {
|
||||
|
||||
@@ -710,7 +710,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap
|
||||
Order of messages:
|
||||
|
||||
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
|
||||
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted.
|
||||
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted.
|
||||
3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`.
|
||||
4. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result.
|
||||
|
||||
|
||||
@@ -671,12 +671,17 @@ pub(crate) async fn route_outgoing_envelope(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error_code::OVERLOADED_ERROR_CODE;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
fn absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_server_transport_parses_stdio_listen_url() {
|
||||
let transport = AppServerTransport::from_listen_url(AppServerTransport::DEFAULT_LISTEN_URL)
|
||||
@@ -977,7 +982,7 @@ mod tests {
|
||||
network: None,
|
||||
file_system: Some(
|
||||
codex_app_server_protocol::AdditionalFileSystemPermissions {
|
||||
read: Some(vec![PathBuf::from("/tmp/allowed")]),
|
||||
read: Some(vec![absolute_path("/tmp/allowed")]),
|
||||
write: None,
|
||||
},
|
||||
),
|
||||
@@ -1039,7 +1044,7 @@ mod tests {
|
||||
network: None,
|
||||
file_system: Some(
|
||||
codex_app_server_protocol::AdditionalFileSystemPermissions {
|
||||
read: Some(vec![PathBuf::from("/tmp/allowed")]),
|
||||
read: Some(vec![absolute_path("/tmp/allowed")]),
|
||||
write: None,
|
||||
},
|
||||
),
|
||||
@@ -1060,12 +1065,13 @@ mod tests {
|
||||
.await
|
||||
.expect("request should be delivered to the connection");
|
||||
let json = serde_json::to_value(message).expect("request should serialize");
|
||||
let allowed_path = absolute_path("/tmp/allowed").to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
json["params"]["additionalPermissions"],
|
||||
json!({
|
||||
"network": null,
|
||||
"fileSystem": {
|
||||
"read": ["/tmp/allowed"],
|
||||
"read": [allowed_path],
|
||||
"write": null,
|
||||
},
|
||||
"macos": null,
|
||||
|
||||
@@ -521,9 +521,6 @@ pub(crate) mod errors {
|
||||
SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation(
|
||||
"seatbelt sandbox is only available on macOS".to_string(),
|
||||
),
|
||||
SandboxTransformError::InvalidAdditionalPermissionsPath(path) => {
|
||||
CodexErr::InvalidRequest(format!("invalid additional_permissions path: {path}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +92,6 @@ pub enum SandboxPreference {
|
||||
pub(crate) enum SandboxTransformError {
|
||||
#[error("missing codex-linux-sandbox executable path")]
|
||||
MissingLinuxSandboxExecutable,
|
||||
#[error("invalid additional permissions path: {0}")]
|
||||
InvalidAdditionalPermissionsPath(String),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[error("seatbelt sandbox is only available on macOS")]
|
||||
SeatbeltUnavailable,
|
||||
@@ -101,19 +99,16 @@ pub(crate) enum SandboxTransformError {
|
||||
|
||||
pub(crate) fn normalize_additional_permissions(
|
||||
additional_permissions: PermissionProfile,
|
||||
command_cwd: &Path,
|
||||
) -> Result<PermissionProfile, String> {
|
||||
let Some(file_system) = additional_permissions.file_system else {
|
||||
return Ok(PermissionProfile::default());
|
||||
};
|
||||
let read = file_system
|
||||
.read
|
||||
.map(|paths| normalize_permission_paths(paths, command_cwd, "file_system.read"))
|
||||
.transpose()?;
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.read"));
|
||||
let write = file_system
|
||||
.write
|
||||
.map(|paths| normalize_permission_paths(paths, command_cwd, "file_system.write"))
|
||||
.transpose()?;
|
||||
.map(|paths| normalize_permission_paths(paths, "file_system.write"));
|
||||
Ok(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions { read, write }),
|
||||
..Default::default()
|
||||
@@ -121,48 +116,25 @@ pub(crate) fn normalize_additional_permissions(
|
||||
}
|
||||
|
||||
fn normalize_permission_paths(
|
||||
paths: Vec<PathBuf>,
|
||||
command_cwd: &Path,
|
||||
permission_kind: &str,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
paths: Vec<AbsolutePathBuf>,
|
||||
_permission_kind: &str,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut out = Vec::with_capacity(paths.len());
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for path in paths {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(format!("{permission_kind} contains an empty path"));
|
||||
}
|
||||
|
||||
let resolved = if path.is_absolute() {
|
||||
AbsolutePathBuf::from_absolute_path(path.clone()).map_err(|err| {
|
||||
format!(
|
||||
"{permission_kind} path `{}` is invalid: {err}",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
AbsolutePathBuf::resolve_path_against_base(&path, command_cwd).map_err(|err| {
|
||||
format!(
|
||||
"{permission_kind} path `{}` cannot be resolved against cwd `{}`: {err}",
|
||||
path.display(),
|
||||
command_cwd.display()
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
let canonicalized = resolved
|
||||
let canonicalized = path
|
||||
.as_path()
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
|
||||
.unwrap_or(resolved);
|
||||
let canonicalized = canonicalized.to_path_buf();
|
||||
.unwrap_or(path);
|
||||
if seen.insert(canonicalized.clone()) {
|
||||
out.push(canonicalized);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
out
|
||||
}
|
||||
|
||||
fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
|
||||
@@ -178,37 +150,23 @@ fn dedup_absolute_paths(paths: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
|
||||
|
||||
fn additional_permission_roots(
|
||||
additional_permissions: &PermissionProfile,
|
||||
) -> Result<(Vec<AbsolutePathBuf>, Vec<AbsolutePathBuf>), SandboxTransformError> {
|
||||
let to_abs = |paths: &[PathBuf]| {
|
||||
let mut out = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
let absolute = AbsolutePathBuf::from_absolute_path(path.clone()).map_err(|err| {
|
||||
SandboxTransformError::InvalidAdditionalPermissionsPath(format!(
|
||||
"`{}`: {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
out.push(absolute);
|
||||
}
|
||||
Ok(dedup_absolute_paths(out))
|
||||
};
|
||||
|
||||
Ok((
|
||||
to_abs(
|
||||
) -> (Vec<AbsolutePathBuf>, Vec<AbsolutePathBuf>) {
|
||||
(
|
||||
dedup_absolute_paths(
|
||||
additional_permissions
|
||||
.file_system
|
||||
.as_ref()
|
||||
.and_then(|file_system| file_system.read.as_deref())
|
||||
.and_then(|file_system| file_system.read.clone())
|
||||
.unwrap_or_default(),
|
||||
)?,
|
||||
to_abs(
|
||||
),
|
||||
dedup_absolute_paths(
|
||||
additional_permissions
|
||||
.file_system
|
||||
.as_ref()
|
||||
.and_then(|file_system| file_system.write.as_deref())
|
||||
.and_then(|file_system| file_system.write.clone())
|
||||
.unwrap_or_default(),
|
||||
)?,
|
||||
))
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_read_only_access_with_additional_reads(
|
||||
@@ -239,7 +197,7 @@ fn sandbox_policy_with_additional_permissions(
|
||||
return Ok(sandbox_policy.clone());
|
||||
}
|
||||
|
||||
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions)?;
|
||||
let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions);
|
||||
|
||||
let policy = match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::skills::system::system_cache_root_dir;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use dirs::home_dir;
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
use serde::Deserialize;
|
||||
@@ -573,15 +574,18 @@ fn load_skill_metadata(skill_path: &Path) -> LoadedSkillMetadata {
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: SkillMetadataFile = match serde_yaml::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"ignoring {path}: invalid {label}: {error}",
|
||||
path = metadata_path.display(),
|
||||
label = SKILLS_METADATA_FILENAME
|
||||
);
|
||||
return LoadedSkillMetadata::default();
|
||||
let parsed: SkillMetadataFile = {
|
||||
let _guard = AbsolutePathBufGuard::new(skill_dir);
|
||||
match serde_yaml::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"ignoring {path}: invalid {label}: {error}",
|
||||
path = metadata_path.display(),
|
||||
label = SKILLS_METADATA_FILENAME
|
||||
);
|
||||
return LoadedSkillMetadata::default();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1376,8 +1380,14 @@ permissions:
|
||||
Some(PermissionProfile {
|
||||
network: Some(true),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![PathBuf::from("./data")]),
|
||||
write: Some(vec![PathBuf::from("./output")]),
|
||||
read: Some(vec![
|
||||
AbsolutePathBuf::try_from(normalized(skill_dir.join("data").as_path()))
|
||||
.expect("absolute data path"),
|
||||
]),
|
||||
write: Some(vec![
|
||||
AbsolutePathBuf::try_from(normalized(skill_dir.join("output").as_path()))
|
||||
.expect("absolute output path"),
|
||||
]),
|
||||
}),
|
||||
macos: None,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsAutomationValue;
|
||||
@@ -11,7 +9,6 @@ use codex_protocol::models::MacOsPreferencesValue;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -23,7 +20,7 @@ use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
|
||||
pub(crate) fn compile_permission_profile(
|
||||
skill_dir: &Path,
|
||||
_skill_dir: &Path,
|
||||
permissions: Option<PermissionProfile>,
|
||||
) -> Option<Permissions> {
|
||||
let PermissionProfile {
|
||||
@@ -33,12 +30,10 @@ pub(crate) fn compile_permission_profile(
|
||||
} = permissions?;
|
||||
let file_system = file_system.unwrap_or_default();
|
||||
let fs_read = normalize_permission_paths(
|
||||
skill_dir,
|
||||
file_system.read.as_deref().unwrap_or_default(),
|
||||
"permissions.file_system.read",
|
||||
);
|
||||
let fs_write = normalize_permission_paths(
|
||||
skill_dir,
|
||||
file_system.write.as_deref().unwrap_or_default(),
|
||||
"permissions.file_system.write",
|
||||
);
|
||||
@@ -83,16 +78,12 @@ pub(crate) fn compile_permission_profile(
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_permission_paths(
|
||||
skill_dir: &Path,
|
||||
values: &[PathBuf],
|
||||
field: &str,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
fn normalize_permission_paths(values: &[AbsolutePathBuf], field: &str) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for value in values {
|
||||
let Some(path) = normalize_permission_path(skill_dir, value, field) else {
|
||||
let Some(path) = normalize_permission_path(value, field) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(path.clone()) {
|
||||
@@ -103,26 +94,8 @@ fn normalize_permission_paths(
|
||||
paths
|
||||
}
|
||||
|
||||
fn normalize_permission_path(
|
||||
skill_dir: &Path,
|
||||
value: &Path,
|
||||
field: &str,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
let value = value.to_string_lossy();
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
warn!("ignoring {field}: value is empty");
|
||||
return None;
|
||||
}
|
||||
|
||||
let expanded = expand_home(trimmed);
|
||||
let absolute = if expanded.is_absolute() {
|
||||
expanded
|
||||
} else {
|
||||
skill_dir.join(expanded)
|
||||
};
|
||||
let normalized = normalize_lexically(&absolute);
|
||||
let canonicalized = canonicalize_path(&normalized).unwrap_or(normalized);
|
||||
fn normalize_permission_path(value: &AbsolutePathBuf, field: &str) -> Option<AbsolutePathBuf> {
|
||||
let canonicalized = canonicalize_path(value.as_path()).unwrap_or_else(|_| value.to_path_buf());
|
||||
match AbsolutePathBuf::from_absolute_path(&canonicalized) {
|
||||
Ok(path) => Some(path),
|
||||
Err(error) => {
|
||||
@@ -132,21 +105,6 @@ fn normalize_permission_path(
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_home(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
if let Some(home) = home_dir() {
|
||||
return home;
|
||||
}
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("~/")
|
||||
&& let Some(home) = home_dir()
|
||||
{
|
||||
return home.join(rest);
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_macos_seatbelt_profile_extensions(
|
||||
permissions: &MacOsPermissions,
|
||||
@@ -233,22 +191,6 @@ fn build_macos_seatbelt_profile_extensions(
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_lexically(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {
|
||||
normalized.push(component.as_os_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::compile_permission_profile;
|
||||
@@ -269,7 +211,11 @@ mod tests {
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
|
||||
fn absolute_path(path: &Path) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(path).expect("absolute path")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_permission_profile_normalizes_paths() {
|
||||
@@ -285,11 +231,11 @@ mod tests {
|
||||
network: Some(true),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![
|
||||
PathBuf::from("./data"),
|
||||
PathBuf::from("./data"),
|
||||
PathBuf::from("scripts/../data"),
|
||||
absolute_path(&skill_dir.join("data")),
|
||||
absolute_path(&skill_dir.join("data")),
|
||||
absolute_path(&skill_dir.join("scripts/../data")),
|
||||
]),
|
||||
write: Some(vec![PathBuf::from("./output")]),
|
||||
write: Some(vec![absolute_path(&skill_dir.join("output"))]),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
@@ -389,7 +335,7 @@ mod tests {
|
||||
Some(PermissionProfile {
|
||||
network: Some(true),
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![PathBuf::from("./data")]),
|
||||
read: Some(vec![absolute_path(&skill_dir.join("data"))]),
|
||||
write: Some(Vec::new()),
|
||||
}),
|
||||
..Default::default()
|
||||
|
||||
@@ -249,6 +249,7 @@ pub fn terminal_info() -> TerminalInfo {
|
||||
/// type is split on whitespace to extract a program name plus optional version (for example,
|
||||
/// `ghostty 1.2.3`), while the client term name becomes the `TERM` capability string.
|
||||
/// - Otherwise, `TERM_PROGRAM` (plus `TERM_PROGRAM_VERSION`) drives the detected terminal name.
|
||||
/// This means `TERM_PROGRAM` can mask later probes (for example `WT_SESSION`).
|
||||
/// - Next, terminal-specific variables (WEZTERM, iTerm2, Apple Terminal, kitty, etc.) are checked.
|
||||
/// - Finally, `TERM` is used as the capability fallback with `TerminalName::Unknown`.
|
||||
///
|
||||
|
||||
@@ -16,9 +16,12 @@ mod test_sync;
|
||||
pub(crate) mod unified_exec;
|
||||
mod view_image;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
pub use plan::PLAN_TOOL;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
@@ -56,6 +59,33 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_arguments_with_base_path<T>(
|
||||
arguments: &str,
|
||||
base_path: &Path,
|
||||
) -> Result<T, FunctionCallError>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let _guard = AbsolutePathBufGuard::new(base_path);
|
||||
parse_arguments(arguments)
|
||||
}
|
||||
|
||||
fn resolve_workdir_base_path(
|
||||
arguments: &str,
|
||||
default_cwd: &Path,
|
||||
) -> Result<PathBuf, FunctionCallError> {
|
||||
let arguments: Value = parse_arguments(arguments)?;
|
||||
Ok(arguments
|
||||
.get("workdir")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|workdir| !workdir.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.map_or_else(
|
||||
|| default_cwd.to_path_buf(),
|
||||
|workdir| crate::util::resolve_path(default_cwd, &workdir),
|
||||
))
|
||||
}
|
||||
|
||||
/// Validates feature/policy constraints for `with_additional_permissions` and
|
||||
/// returns normalized absolute paths. Errors if paths are invalid.
|
||||
pub(super) fn normalize_and_validate_additional_permissions(
|
||||
@@ -63,7 +93,7 @@ pub(super) fn normalize_and_validate_additional_permissions(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
cwd: &Path,
|
||||
_cwd: &Path,
|
||||
) -> Result<Option<PermissionProfile>, String> {
|
||||
let uses_additional_permissions = matches!(
|
||||
sandbox_permissions,
|
||||
@@ -91,7 +121,7 @@ pub(super) fn normalize_and_validate_additional_permissions(
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
let normalized = normalize_additional_permissions(additional_permissions, cwd)?;
|
||||
let normalized = normalize_additional_permissions(additional_permissions)?;
|
||||
if normalized.is_empty() {
|
||||
return Err(
|
||||
"`additional_permissions` must include at least one path in `file_system.read` or `file_system.write`"
|
||||
|
||||
@@ -22,7 +22,8 @@ use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_workdir_base_path;
|
||||
use crate::tools::orchestrator::ToolOrchestrator;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
@@ -176,7 +177,9 @@ impl ToolHandler for ShellHandler {
|
||||
|
||||
match payload {
|
||||
ToolPayload::Function { arguments } => {
|
||||
let params: ShellToolCallParams = parse_arguments(&arguments)?;
|
||||
let cwd = resolve_workdir_base_path(&arguments, turn.cwd.as_path())?;
|
||||
let params: ShellToolCallParams =
|
||||
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
|
||||
let prefix_rule = params.prefix_rule.clone();
|
||||
let exec_params =
|
||||
Self::to_exec_params(¶ms, turn.as_ref(), session.conversation_id);
|
||||
@@ -266,7 +269,9 @@ impl ToolHandler for ShellCommandHandler {
|
||||
)));
|
||||
};
|
||||
|
||||
let params: ShellCommandToolCallParams = parse_arguments(&arguments)?;
|
||||
let cwd = resolve_workdir_base_path(&arguments, turn.cwd.as_path())?;
|
||||
let params: ShellCommandToolCallParams =
|
||||
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
|
||||
@@ -13,6 +13,8 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::apply_patch::intercept_apply_patch;
|
||||
use crate::tools::handlers::normalize_and_validate_additional_permissions;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_workdir_base_path;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::unified_exec::ExecCommandRequest;
|
||||
@@ -136,7 +138,9 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
|
||||
let response = match tool_name.as_str() {
|
||||
"exec_command" => {
|
||||
let args: ExecCommandArgs = parse_arguments(&arguments)?;
|
||||
let cwd = resolve_workdir_base_path(&arguments, context.turn.cwd.as_path())?;
|
||||
let args: ExecCommandArgs =
|
||||
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
@@ -183,7 +187,7 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
let workdir = workdir.filter(|value| !value.is_empty());
|
||||
|
||||
let workdir = workdir.map(|dir| context.turn.resolve_path(Some(dir)));
|
||||
let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone());
|
||||
let cwd = workdir.clone().unwrap_or(cwd);
|
||||
let normalized_additional_permissions =
|
||||
match normalize_and_validate_additional_permissions(
|
||||
request_permission_enabled,
|
||||
@@ -336,8 +340,15 @@ fn format_response(response: &UnifiedExecResponse) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::shell::default_user_shell;
|
||||
use crate::tools::handlers::parse_arguments_with_base_path;
|
||||
use crate::tools::handlers::resolve_workdir_base_path;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> {
|
||||
@@ -420,4 +431,37 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_args_resolve_relative_additional_permissions_against_workdir()
|
||||
-> anyhow::Result<()> {
|
||||
let cwd = tempdir()?;
|
||||
let workdir = cwd.path().join("nested");
|
||||
fs::create_dir_all(&workdir)?;
|
||||
let expected_write = workdir.join("relative-write.txt");
|
||||
let json = r#"{
|
||||
"cmd": "echo hello",
|
||||
"workdir": "nested",
|
||||
"additional_permissions": {
|
||||
"file_system": {
|
||||
"write": ["./relative-write.txt"]
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
let base_path = resolve_workdir_base_path(json, cwd.path())?;
|
||||
let args: ExecCommandArgs = parse_arguments_with_base_path(json, base_path.as_path())?;
|
||||
|
||||
assert_eq!(
|
||||
args.additional_permissions,
|
||||
Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![AbsolutePathBuf::try_from(expected_write)?]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
@@ -152,7 +151,9 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![PathBuf::from("./output")]),
|
||||
write: Some(vec![
|
||||
AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(),
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
@@ -31,6 +32,11 @@ use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn absolute_path(path: &Path) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(path).expect("absolute path")
|
||||
}
|
||||
|
||||
struct CommandResult {
|
||||
exit_code: Option<i64>,
|
||||
@@ -91,6 +97,24 @@ fn shell_event_with_request_permissions(
|
||||
Ok(ev_function_call(call_id, "shell_command", &args_str))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn shell_event_with_raw_request_permissions(
|
||||
call_id: &str,
|
||||
command: &str,
|
||||
workdir: Option<&str>,
|
||||
additional_permissions: Value,
|
||||
) -> Result<Value> {
|
||||
let args = json!({
|
||||
"command": command,
|
||||
"workdir": workdir,
|
||||
"timeout_ms": 1_000_u64,
|
||||
"sandbox_permissions": SandboxPermissions::WithAdditionalPermissions,
|
||||
"additional_permissions": additional_permissions,
|
||||
});
|
||||
let args_str = serde_json::to_string(&args)?;
|
||||
Ok(ev_function_call(call_id, "shell_command", &args_str))
|
||||
}
|
||||
|
||||
async fn submit_turn(
|
||||
test: &TestCodex,
|
||||
prompt: &str,
|
||||
@@ -187,7 +211,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![requested_write.clone()]),
|
||||
write: Some(vec![absolute_path(&requested_write)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -241,6 +265,98 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn relative_additional_permissions_resolve_against_tool_workdir() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let sandbox_policy_for_config = sandbox_policy.clone();
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
|
||||
config.features.enable(Feature::RequestPermissions);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
let nested_dir = test.workspace_path("nested");
|
||||
fs::create_dir_all(&nested_dir)?;
|
||||
let requested_write = nested_dir.join("relative-write.txt");
|
||||
let _ = fs::remove_file(&requested_write);
|
||||
|
||||
let call_id = "request_permissions_relative_workdir";
|
||||
let command = "touch relative-write.txt";
|
||||
let expected_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![absolute_path(&requested_write)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let event = shell_event_with_raw_request_permissions(
|
||||
call_id,
|
||||
command,
|
||||
Some("nested"),
|
||||
json!({
|
||||
"file_system": {
|
||||
"write": ["./relative-write.txt"],
|
||||
},
|
||||
}),
|
||||
)?;
|
||||
|
||||
let _ = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-relative-1"),
|
||||
event,
|
||||
ev_completed("resp-relative-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let results = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-relative-1", "done"),
|
||||
ev_completed("resp-relative-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn(&test, call_id, approval_policy, sandbox_policy.clone()).await?;
|
||||
|
||||
let approval = expect_exec_approval(&test, command).await;
|
||||
assert_eq!(
|
||||
approval.additional_permissions,
|
||||
Some(expected_permissions.clone())
|
||||
);
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
wait_for_completion(&test).await;
|
||||
|
||||
let result = parse_result(&results.single_request().function_call_output(call_id));
|
||||
assert!(
|
||||
result.exit_code.is_none() || result.exit_code == Some(0),
|
||||
"unexpected exit code/output: {:?} {}",
|
||||
result.exit_code,
|
||||
result.stdout
|
||||
);
|
||||
assert!(
|
||||
requested_write.exists(),
|
||||
"touch command should create requested path"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write() -> Result<()> {
|
||||
@@ -272,7 +388,7 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write()
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![requested_write.clone()]),
|
||||
write: Some(vec![absolute_path(&requested_write)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -363,7 +479,7 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_tmp_write()
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![requested_write.clone()]),
|
||||
write: Some(vec![absolute_path(&requested_write)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -454,14 +570,16 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() ->
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![outside_dir.path().to_path_buf()]),
|
||||
write: Some(vec![absolute_path(outside_dir.path())]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let normalized_requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![outside_dir.path().canonicalize()?]),
|
||||
write: Some(vec![AbsolutePathBuf::try_from(
|
||||
outside_dir.path().canonicalize()?,
|
||||
)?]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -548,14 +666,16 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![outside_dir.path().to_path_buf()]),
|
||||
write: Some(vec![absolute_path(outside_dir.path())]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let normalized_requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![]),
|
||||
write: Some(vec![outside_dir.path().canonicalize()?]),
|
||||
write: Some(vec![AbsolutePathBuf::try_from(
|
||||
outside_dir.path().canonicalize()?,
|
||||
)?]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::responses::mount_function_call_agent_response;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
@@ -26,6 +27,13 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn absolute_path(path: &Path) -> AbsolutePathBuf {
|
||||
match AbsolutePathBuf::try_from(path) {
|
||||
Ok(path) => path,
|
||||
Err(err) => panic!("absolute path: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_skill_metadata(home: &Path, name: &str, contents: &str) -> Result<()> {
|
||||
let metadata_dir = home.join("skills").join(name).join("agents");
|
||||
fs::create_dir_all(&metadata_dir)?;
|
||||
@@ -330,8 +338,14 @@ permissions:
|
||||
approval.additional_permissions,
|
||||
Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![PathBuf::from("./data")]),
|
||||
write: Some(vec![PathBuf::from("./output")]),
|
||||
read: Some(vec![absolute_path(
|
||||
&test.codex_home_path().join("skills/mbolin-test-skill/data"),
|
||||
)]),
|
||||
write: Some(vec![absolute_path(
|
||||
&test
|
||||
.codex_home_path()
|
||||
.join("skills/mbolin-test-skill/output"),
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -590,7 +604,7 @@ async fn shell_zsh_fork_skill_session_approval_enforces_skill_permissions() -> R
|
||||
Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![allowed_dir.clone()]),
|
||||
write: Some(vec![absolute_path(&allowed_dir)]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_utils_image::load_and_resize_to_fit;
|
||||
use serde::Deserialize;
|
||||
@@ -20,6 +19,7 @@ use crate::protocol::WritableRoot;
|
||||
use crate::user_input::UserInput;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_git::GhostCommit;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_image::error::ImageProcessingError;
|
||||
use schemars::JsonSchema;
|
||||
|
||||
@@ -54,8 +54,8 @@ impl SandboxPermissions {
|
||||
|
||||
#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
pub struct FileSystemPermissions {
|
||||
pub read: Option<Vec<PathBuf>>,
|
||||
pub write: Option<Vec<PathBuf>>,
|
||||
pub read: Option<Vec<AbsolutePathBuf>>,
|
||||
pub write: Option<Vec<AbsolutePathBuf>>,
|
||||
}
|
||||
|
||||
impl FileSystemPermissions {
|
||||
|
||||
@@ -37,26 +37,7 @@ pub fn parse_command(command: &[String]) -> Vec<ParsedCommand> {
|
||||
}
|
||||
deduped.push(cmd);
|
||||
}
|
||||
if deduped
|
||||
.iter()
|
||||
.any(|cmd| matches!(cmd, ParsedCommand::Unknown { .. }))
|
||||
{
|
||||
vec![single_unknown_for_command(command)]
|
||||
} else {
|
||||
deduped
|
||||
}
|
||||
}
|
||||
|
||||
fn single_unknown_for_command(command: &[String]) -> ParsedCommand {
|
||||
if let Some((_, shell_command)) = extract_shell_command(command) {
|
||||
ParsedCommand::Unknown {
|
||||
cmd: shell_command.to_string(),
|
||||
}
|
||||
} else {
|
||||
ParsedCommand::Unknown {
|
||||
cmd: shlex_join(command),
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -138,7 +119,7 @@ mod tests {
|
||||
assert_parsed(
|
||||
&vec_str(&["bash", "-lc", inner]),
|
||||
vec![ParsedCommand::Unknown {
|
||||
cmd: inner.to_string(),
|
||||
cmd: "git status".to_string(),
|
||||
}],
|
||||
);
|
||||
}
|
||||
@@ -160,9 +141,24 @@ mod tests {
|
||||
"rg --version && node -v && pnpm -v && rg --files | wc -l && rg --files | head -n 40";
|
||||
assert_parsed(
|
||||
&vec_str(&["bash", "-lc", inner]),
|
||||
vec![ParsedCommand::Unknown {
|
||||
cmd: inner.to_string(),
|
||||
}],
|
||||
vec![
|
||||
// Expect commands in left-to-right execution order
|
||||
ParsedCommand::Search {
|
||||
cmd: "rg --version".to_string(),
|
||||
query: None,
|
||||
path: None,
|
||||
},
|
||||
ParsedCommand::Unknown {
|
||||
cmd: "node -v".to_string(),
|
||||
},
|
||||
ParsedCommand::Unknown {
|
||||
cmd: "pnpm -v".to_string(),
|
||||
},
|
||||
ParsedCommand::ListFiles {
|
||||
cmd: "rg --files".to_string(),
|
||||
path: None,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,33 +218,16 @@ mod tests {
|
||||
let inner = r#"rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'"#;
|
||||
assert_parsed(
|
||||
&vec_str(&["bash", "-lc", inner]),
|
||||
vec![ParsedCommand::Unknown {
|
||||
cmd: inner.to_string(),
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapses_plain_pipeline_when_any_stage_is_unknown() {
|
||||
let command = shlex_split_safe(
|
||||
"rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'",
|
||||
);
|
||||
assert_parsed(
|
||||
&command,
|
||||
vec![ParsedCommand::Unknown {
|
||||
cmd: shlex_join(&command),
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapses_pipeline_with_helper_when_later_stage_is_unknown() {
|
||||
let command = shlex_split_safe("rg --files | nl -ba | foo");
|
||||
assert_parsed(
|
||||
&command,
|
||||
vec![ParsedCommand::Unknown {
|
||||
cmd: shlex_join(&command),
|
||||
}],
|
||||
vec![
|
||||
ParsedCommand::Search {
|
||||
cmd: "rg -l QkBindingController presentation/src/main/java".to_string(),
|
||||
query: Some("QkBindingController".to_string()),
|
||||
path: Some("java".to_string()),
|
||||
},
|
||||
ParsedCommand::Unknown {
|
||||
cmd: "xargs perl -pi -e s/QkBindingController/QkController/g".to_string(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -416,7 +395,7 @@ mod tests {
|
||||
assert_parsed(
|
||||
&shlex_split_safe("bash -lc 'cd foo && bar'"),
|
||||
vec![ParsedCommand::Unknown {
|
||||
cmd: "cd foo && bar".to_string(),
|
||||
cmd: "bar".to_string(),
|
||||
}],
|
||||
);
|
||||
}
|
||||
@@ -943,6 +922,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_on_semicolon() {
|
||||
assert_parsed(
|
||||
&shlex_split_safe("rg foo ; echo done"),
|
||||
vec![
|
||||
ParsedCommand::Search {
|
||||
cmd: "rg foo".to_string(),
|
||||
query: Some("foo".to_string()),
|
||||
path: None,
|
||||
},
|
||||
ParsedCommand::Unknown {
|
||||
cmd: "echo done".to_string(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_on_or_connector() {
|
||||
// Ensure we split commands on the logical OR operator as well.
|
||||
assert_parsed(
|
||||
&shlex_split_safe("rg foo || echo done"),
|
||||
vec![
|
||||
ParsedCommand::Search {
|
||||
cmd: "rg foo".to_string(),
|
||||
query: Some("foo".to_string()),
|
||||
path: None,
|
||||
},
|
||||
ParsedCommand::Unknown {
|
||||
cmd: "echo done".to_string(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_mixed_sequence_with_pipes_semicolons_and_or() {
|
||||
// Provided long command sequence combining sequencing, pipelines, and ORs.
|
||||
let inner = "pwd; ls -la; rg --files -g '!target' | wc -l; rg -n '^\\[workspace\\]' -n Cargo.toml || true; rg -n '^\\[package\\]' -n */Cargo.toml || true; cargo --version; rustc --version; cargo clippy --workspace --all-targets --all-features -q";
|
||||
let args = vec_str(&["bash", "-lc", inner]);
|
||||
|
||||
let expected = vec![
|
||||
ParsedCommand::Unknown {
|
||||
cmd: "pwd".to_string(),
|
||||
},
|
||||
ParsedCommand::ListFiles {
|
||||
cmd: shlex_join(&shlex_split_safe("ls -la")),
|
||||
path: None,
|
||||
},
|
||||
ParsedCommand::ListFiles {
|
||||
cmd: shlex_join(&shlex_split_safe("rg --files -g '!target'")),
|
||||
path: None,
|
||||
},
|
||||
ParsedCommand::Search {
|
||||
cmd: shlex_join(&shlex_split_safe("rg -n '^\\[workspace\\]' -n Cargo.toml")),
|
||||
query: Some("^\\[workspace\\]".to_string()),
|
||||
path: Some("Cargo.toml".to_string()),
|
||||
},
|
||||
ParsedCommand::Search {
|
||||
cmd: shlex_join(&shlex_split_safe("rg -n '^\\[package\\]' -n */Cargo.toml")),
|
||||
query: Some("^\\[package\\]".to_string()),
|
||||
path: Some("Cargo.toml".to_string()),
|
||||
},
|
||||
ParsedCommand::Unknown {
|
||||
cmd: shlex_join(&shlex_split_safe("cargo --version")),
|
||||
},
|
||||
ParsedCommand::Unknown {
|
||||
cmd: shlex_join(&shlex_split_safe("rustc --version")),
|
||||
},
|
||||
ParsedCommand::Unknown {
|
||||
cmd: shlex_join(&shlex_split_safe(
|
||||
"cargo clippy --workspace --all-targets --all-features -q",
|
||||
)),
|
||||
},
|
||||
];
|
||||
|
||||
assert_parsed(&args, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_true_in_sequence() {
|
||||
// `true` should be dropped from parsed sequences
|
||||
@@ -1868,7 +1926,6 @@ fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
||||
};
|
||||
commands.push(parsed);
|
||||
}
|
||||
|
||||
if commands.len() > 1 {
|
||||
commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
|
||||
// Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`.
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::chatwidget::DEFAULT_MODEL_DISPLAY_NAME;
|
||||
use crate::chatwidget::ExternalEditorState;
|
||||
use crate::cwd_prompt::CwdPromptAction;
|
||||
use crate::diff_render::DiffSummary;
|
||||
@@ -81,6 +82,8 @@ use color_eyre::eyre::WrapErr;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
@@ -723,6 +726,41 @@ impl App {
|
||||
.add_info_message(format!("Opened {url} in your browser."), None);
|
||||
}
|
||||
|
||||
fn insert_history_cell(&mut self, tui: &mut tui::Tui, cell: Arc<dyn HistoryCell>) {
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.insert_cell(cell.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.transcript_cells.push(cell.clone());
|
||||
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
|
||||
if !display.is_empty() {
|
||||
if !cell.is_stream_continuation() {
|
||||
if self.has_emitted_history_lines {
|
||||
display.insert(0, Line::from(""));
|
||||
} else {
|
||||
self.has_emitted_history_lines = true;
|
||||
}
|
||||
}
|
||||
if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_startup_header(&mut self, tui: &mut tui::Tui) {
|
||||
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
|
||||
let header = Arc::new(history_cell::SessionHeaderHistoryCell::new_with_style(
|
||||
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
|
||||
placeholder_style,
|
||||
None,
|
||||
self.config.cwd.clone(),
|
||||
CODEX_CLI_VERSION,
|
||||
)) as Arc<dyn HistoryCell>;
|
||||
self.insert_history_cell(tui, header);
|
||||
}
|
||||
|
||||
fn clear_ui_header_lines_with_version(
|
||||
&self,
|
||||
width: u16,
|
||||
@@ -1199,6 +1237,7 @@ impl App {
|
||||
};
|
||||
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
||||
self.reset_thread_event_state();
|
||||
self.insert_startup_header(tui);
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
@@ -1311,7 +1350,8 @@ impl App {
|
||||
use tokio_stream::StreamExt;
|
||||
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
emit_project_config_warnings(&app_event_tx, &config);
|
||||
let should_insert_startup_header =
|
||||
matches!(&session_selection, SessionSelection::StartFresh);
|
||||
tui.set_notification_method(config.tui_notification_method);
|
||||
|
||||
let harness_overrides =
|
||||
@@ -1493,6 +1533,7 @@ impl App {
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
#[cfg(not(debug_assertions))]
|
||||
let upgrade_version = crate::updates::get_upgrade_version(&config);
|
||||
emit_project_config_warnings(&app_event_tx, &config);
|
||||
|
||||
let mut app = Self {
|
||||
server: thread_manager.clone(),
|
||||
@@ -1531,6 +1572,10 @@ impl App {
|
||||
pending_primary_events: VecDeque::new(),
|
||||
};
|
||||
|
||||
if should_insert_startup_header {
|
||||
app.insert_startup_header(tui);
|
||||
}
|
||||
|
||||
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -1883,29 +1928,7 @@ impl App {
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let cell: Arc<dyn HistoryCell> = cell.into();
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.insert_cell(cell.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.transcript_cells.push(cell.clone());
|
||||
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
|
||||
if !display.is_empty() {
|
||||
// Only insert a separating blank line for new cells that are not
|
||||
// part of an ongoing stream. Streaming continuations should not
|
||||
// accrue extra blank lines between chunks.
|
||||
if !cell.is_stream_continuation() {
|
||||
if self.has_emitted_history_lines {
|
||||
display.insert(0, Line::from(""));
|
||||
} else {
|
||||
self.has_emitted_history_lines = true;
|
||||
}
|
||||
}
|
||||
if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
}
|
||||
}
|
||||
self.insert_history_cell(tui, cell);
|
||||
}
|
||||
AppEvent::ApplyThreadRollback { num_turns } => {
|
||||
if self.apply_non_pending_thread_rollback(num_turns) {
|
||||
@@ -3064,6 +3087,55 @@ impl App {
|
||||
// thread, so unrelated shutdowns cannot consume this marker.
|
||||
self.pending_shutdown_exit_thread_id = None;
|
||||
}
|
||||
if let EventMsg::SessionConfigured(session) = &event.msg
|
||||
&& let Some(loading_header_idx) = self.transcript_cells.iter().rposition(|cell| {
|
||||
matches!(
|
||||
cell.as_ref()
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::SessionHeaderHistoryCell>(),
|
||||
Some(startup_header) if startup_header.is_loading_placeholder()
|
||||
)
|
||||
})
|
||||
{
|
||||
let cell = Arc::new(history_cell::SessionHeaderHistoryCell::new(
|
||||
session.model.clone(),
|
||||
session.reasoning_effort,
|
||||
session.cwd.clone(),
|
||||
CODEX_CLI_VERSION,
|
||||
)) as Arc<dyn HistoryCell>;
|
||||
self.transcript_cells[loading_header_idx] = cell.clone();
|
||||
if matches!(&self.overlay, Some(Overlay::Transcript(_))) {
|
||||
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
if loading_header_idx == 0 {
|
||||
let display = cell.display_lines(tui.terminal.last_known_screen_size.width);
|
||||
tui.replace_top_visible_history_lines(display)?;
|
||||
} else {
|
||||
self.clear_terminal_ui(tui, false)?;
|
||||
self.deferred_history_lines.clear();
|
||||
|
||||
let transcript_cells = self.transcript_cells.clone();
|
||||
for transcript_cell in transcript_cells {
|
||||
let mut display =
|
||||
transcript_cell.display_lines(tui.terminal.last_known_screen_size.width);
|
||||
if !display.is_empty() {
|
||||
if !transcript_cell.is_stream_continuation() {
|
||||
if self.has_emitted_history_lines {
|
||||
display.insert(0, Line::from(""));
|
||||
} else {
|
||||
self.has_emitted_history_lines = true;
|
||||
}
|
||||
}
|
||||
if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines(display);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.handle_codex_event_now(event);
|
||||
if self.backtrack_render_pending {
|
||||
tui.frame_requester().schedule_frame();
|
||||
@@ -3408,8 +3480,8 @@ mod tests {
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::SessionHeaderHistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::new_session_info;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
@@ -3843,7 +3915,7 @@ mod tests {
|
||||
true,
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
let make_header = |is_first| -> Arc<dyn HistoryCell> {
|
||||
let make_header = |_is_first| -> Arc<dyn HistoryCell> {
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
@@ -3860,12 +3932,11 @@ mod tests {
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
};
|
||||
Arc::new(new_session_info(
|
||||
app.chat_widget.config_ref(),
|
||||
app.chat_widget.current_model(),
|
||||
event,
|
||||
is_first,
|
||||
None,
|
||||
Arc::new(SessionHeaderHistoryCell::new(
|
||||
event.model,
|
||||
event.reasoning_effort,
|
||||
event.cwd,
|
||||
crate::version::CODEX_CLI_VERSION,
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
@@ -4340,7 +4411,7 @@ mod tests {
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
let make_header = |is_first| {
|
||||
let make_header = |_is_first| {
|
||||
let event = SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
@@ -4357,12 +4428,11 @@ mod tests {
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
};
|
||||
Arc::new(new_session_info(
|
||||
app.chat_widget.config_ref(),
|
||||
app.chat_widget.current_model(),
|
||||
event,
|
||||
is_first,
|
||||
None,
|
||||
Arc::new(SessionHeaderHistoryCell::new(
|
||||
event.model,
|
||||
event.reasoning_effort,
|
||||
event.cwd,
|
||||
crate::version::CODEX_CLI_VERSION,
|
||||
)) as Arc<dyn HistoryCell>
|
||||
};
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::history_cell::SessionInfoCell;
|
||||
use crate::history_cell::SessionHeaderHistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::tui;
|
||||
@@ -639,7 +639,7 @@ fn nth_user_position(
|
||||
fn user_positions_iter(
|
||||
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
|
||||
) -> impl Iterator<Item = usize> + '_ {
|
||||
let session_start_type = TypeId::of::<SessionInfoCell>();
|
||||
let session_start_type = TypeId::of::<SessionHeaderHistoryCell>();
|
||||
let user_type = TypeId::of::<UserHistoryCell>();
|
||||
let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| cell.as_any().type_id();
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ use tracing::warn;
|
||||
|
||||
use crate::app_event::RealtimeAudioDeviceKind;
|
||||
|
||||
const PREFERRED_INPUT_SAMPLE_RATE: u32 = 24_000;
|
||||
const PREFERRED_INPUT_CHANNELS: u16 = 1;
|
||||
|
||||
pub(crate) fn list_realtime_audio_device_names(
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
) -> Result<Vec<String>, String> {
|
||||
@@ -33,6 +36,35 @@ pub(crate) fn select_configured_output_device_and_config(
|
||||
select_device_and_config(RealtimeAudioDeviceKind::Speaker, config)
|
||||
}
|
||||
|
||||
pub(crate) fn preferred_input_config(
|
||||
device: &cpal::Device,
|
||||
) -> Result<cpal::SupportedStreamConfig, String> {
|
||||
let supported_configs = device
|
||||
.supported_input_configs()
|
||||
.map_err(|err| format!("failed to enumerate input audio configs: {err}"))?;
|
||||
|
||||
supported_configs
|
||||
.filter_map(|range| {
|
||||
let sample_format_rank = match range.sample_format() {
|
||||
cpal::SampleFormat::I16 => 0u8,
|
||||
cpal::SampleFormat::U16 => 1u8,
|
||||
cpal::SampleFormat::F32 => 2u8,
|
||||
_ => return None,
|
||||
};
|
||||
let sample_rate = preferred_input_sample_rate(&range);
|
||||
let sample_rate_penalty = sample_rate.0.abs_diff(PREFERRED_INPUT_SAMPLE_RATE);
|
||||
let channel_penalty = range.channels().abs_diff(PREFERRED_INPUT_CHANNELS);
|
||||
Some((
|
||||
(sample_rate_penalty, channel_penalty, sample_format_rank),
|
||||
range.with_sample_rate(sample_rate),
|
||||
))
|
||||
})
|
||||
.min_by_key(|(score, _)| *score)
|
||||
.map(|(_, config)| config)
|
||||
.or_else(|| device.default_input_config().ok())
|
||||
.ok_or_else(|| "failed to get default input config".to_string())
|
||||
}
|
||||
|
||||
fn select_device_and_config(
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
config: &Config,
|
||||
@@ -53,7 +85,10 @@ fn select_device_and_config(
|
||||
})
|
||||
.ok_or_else(|| missing_device_error(kind, configured_name))?;
|
||||
|
||||
let stream_config = default_config(&selected, kind)?;
|
||||
let stream_config = match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => preferred_input_config(&selected)?,
|
||||
RealtimeAudioDeviceKind::Speaker => default_config(&selected, kind)?,
|
||||
};
|
||||
Ok((selected, stream_config))
|
||||
}
|
||||
|
||||
@@ -109,6 +144,18 @@ fn default_config(
|
||||
}
|
||||
}
|
||||
|
||||
fn preferred_input_sample_rate(range: &cpal::SupportedStreamConfigRange) -> cpal::SampleRate {
|
||||
let min = range.min_sample_rate().0;
|
||||
let max = range.max_sample_rate().0;
|
||||
if (min..=max).contains(&PREFERRED_INPUT_SAMPLE_RATE) {
|
||||
cpal::SampleRate(PREFERRED_INPUT_SAMPLE_RATE)
|
||||
} else if PREFERRED_INPUT_SAMPLE_RATE < min {
|
||||
cpal::SampleRate(min)
|
||||
} else {
|
||||
cpal::SampleRate(max)
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_device_error(kind: RealtimeAudioDeviceKind, configured_name: Option<&str>) -> String {
|
||||
match (kind, configured_name) {
|
||||
(RealtimeAudioDeviceKind::Microphone, Some(name)) => {
|
||||
|
||||
@@ -614,10 +614,15 @@ mod tests {
|
||||
use codex_protocol::protocol::ExecPolicyAmendment;
|
||||
use codex_protocol::protocol::NetworkApprovalProtocol;
|
||||
use codex_protocol::protocol::NetworkPolicyAmendment;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
|
||||
}
|
||||
|
||||
fn render_overlay_lines(view: &ApprovalOverlay, width: u16) -> String {
|
||||
let height = view.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
@@ -634,6 +639,17 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn normalize_snapshot_paths(rendered: String) -> String {
|
||||
[
|
||||
(absolute_path("/tmp/readme.txt"), "/tmp/readme.txt"),
|
||||
(absolute_path("/tmp/out.txt"), "/tmp/out.txt"),
|
||||
]
|
||||
.into_iter()
|
||||
.fold(rendered, |rendered, (path, normalized)| {
|
||||
rendered.replace(&path.display().to_string(), normalized)
|
||||
})
|
||||
}
|
||||
|
||||
fn make_exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
id: "test".to_string(),
|
||||
@@ -851,8 +867,8 @@ mod tests {
|
||||
fn additional_permissions_exec_options_hide_execpolicy_amendment() {
|
||||
let additional_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![PathBuf::from("/tmp/readme.txt")]),
|
||||
write: Some(vec![PathBuf::from("/tmp/out.txt")]),
|
||||
read: Some(vec![absolute_path("/tmp/readme.txt")]),
|
||||
write: Some(vec![absolute_path("/tmp/out.txt")]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -884,8 +900,8 @@ mod tests {
|
||||
network_approval_context: None,
|
||||
additional_permissions: Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![PathBuf::from("/tmp/readme.txt")]),
|
||||
write: Some(vec![PathBuf::from("/tmp/out.txt")]),
|
||||
read: Some(vec![absolute_path("/tmp/readme.txt")]),
|
||||
write: Some(vec![absolute_path("/tmp/out.txt")]),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
@@ -923,8 +939,8 @@ mod tests {
|
||||
network_approval_context: None,
|
||||
additional_permissions: Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: Some(vec![PathBuf::from("/tmp/readme.txt")]),
|
||||
write: Some(vec![PathBuf::from("/tmp/out.txt")]),
|
||||
read: Some(vec![absolute_path("/tmp/readme.txt")]),
|
||||
write: Some(vec![absolute_path("/tmp/out.txt")]),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
@@ -933,7 +949,7 @@ mod tests {
|
||||
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
|
||||
assert_snapshot!(
|
||||
"approval_overlay_additional_permissions_prompt",
|
||||
render_overlay_lines(&view, 120)
|
||||
normalize_snapshot_paths(render_overlay_lines(&view, 120))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -146,8 +146,6 @@ use rand::Rng;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
@@ -157,7 +155,7 @@ use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
use tracing::warn;
|
||||
|
||||
const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
|
||||
pub(crate) const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
|
||||
const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
|
||||
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
|
||||
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
|
||||
@@ -257,8 +255,6 @@ mod agent;
|
||||
use self::agent::spawn_agent;
|
||||
use self::agent::spawn_agent_from_existing;
|
||||
pub(crate) use self::agent::spawn_op_forwarder;
|
||||
mod session_header;
|
||||
use self::session_header::SessionHeader;
|
||||
mod skills;
|
||||
use self::skills::collect_tool_mentions;
|
||||
use self::skills::find_app_mentions;
|
||||
@@ -547,7 +543,6 @@ pub(crate) struct ChatWidget {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
models_manager: Arc<ModelsManager>,
|
||||
otel_manager: OtelManager,
|
||||
session_header: SessionHeader,
|
||||
initial_user_message: Option<UserMessage>,
|
||||
token_info: Option<TokenUsageInfo>,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
|
||||
@@ -1136,7 +1131,6 @@ impl ChatWidget {
|
||||
self.last_copyable_output = None;
|
||||
let forked_from_id = event.forked_from_id;
|
||||
let model_for_header = event.model.clone();
|
||||
self.session_header.set_model(&model_for_header);
|
||||
self.current_collaboration_mode = self.current_collaboration_mode.with_updates(
|
||||
Some(model_for_header.clone()),
|
||||
Some(event.reasoning_effort),
|
||||
@@ -1144,16 +1138,17 @@ impl ChatWidget {
|
||||
);
|
||||
self.refresh_model_display();
|
||||
self.sync_personality_command_enabled();
|
||||
let session_info_cell = history_cell::new_session_info(
|
||||
if let Some(session_info_body) = history_cell::new_session_info_body(
|
||||
&self.config,
|
||||
&model_for_header,
|
||||
event,
|
||||
&event,
|
||||
self.show_welcome_banner,
|
||||
self.auth_manager
|
||||
.auth_cached()
|
||||
.and_then(|auth| auth.account_plan_type()),
|
||||
);
|
||||
self.apply_session_info_cell(session_info_cell);
|
||||
) {
|
||||
self.add_boxed_history(session_info_body);
|
||||
}
|
||||
|
||||
if let Some(messages) = initial_messages {
|
||||
self.replay_initial_messages(messages);
|
||||
@@ -2777,7 +2772,7 @@ impl ChatWidget {
|
||||
.and_then(|mask| mask.model.clone())
|
||||
.unwrap_or_else(|| model_for_header.clone());
|
||||
let fallback_default = Settings {
|
||||
model: header_model.clone(),
|
||||
model: header_model,
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
};
|
||||
@@ -2787,7 +2782,7 @@ impl ChatWidget {
|
||||
settings: fallback_default,
|
||||
};
|
||||
|
||||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||||
let active_cell = None;
|
||||
|
||||
let current_cwd = Some(config.cwd.clone());
|
||||
let queued_message_edit_binding =
|
||||
@@ -2816,7 +2811,6 @@ impl ChatWidget {
|
||||
auth_manager,
|
||||
models_manager,
|
||||
otel_manager,
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
@@ -2954,7 +2948,7 @@ impl ChatWidget {
|
||||
.and_then(|mask| mask.model.clone())
|
||||
.unwrap_or_else(|| model_for_header.clone());
|
||||
let fallback_default = Settings {
|
||||
model: header_model.clone(),
|
||||
model: header_model,
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
};
|
||||
@@ -2964,7 +2958,7 @@ impl ChatWidget {
|
||||
settings: fallback_default,
|
||||
};
|
||||
|
||||
let active_cell = Some(Self::placeholder_session_header_cell(&config));
|
||||
let active_cell = None;
|
||||
let current_cwd = Some(config.cwd.clone());
|
||||
|
||||
let queued_message_edit_binding =
|
||||
@@ -2993,7 +2987,6 @@ impl ChatWidget {
|
||||
auth_manager,
|
||||
models_manager,
|
||||
otel_manager,
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
@@ -3111,6 +3104,7 @@ impl ChatWidget {
|
||||
let header_model = model
|
||||
.clone()
|
||||
.unwrap_or_else(|| session_configured.model.clone());
|
||||
let header_reasoning_effort = session_configured.reasoning_effort;
|
||||
let active_collaboration_mask =
|
||||
Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override);
|
||||
let header_model = active_collaboration_mask
|
||||
@@ -3159,7 +3153,6 @@ impl ChatWidget {
|
||||
auth_manager,
|
||||
models_manager,
|
||||
otel_manager,
|
||||
session_header: SessionHeader::new(header_model),
|
||||
initial_user_message,
|
||||
token_info: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
@@ -3253,6 +3246,17 @@ impl ChatWidget {
|
||||
);
|
||||
widget.update_collaboration_mode_indicator();
|
||||
|
||||
widget
|
||||
.app_event_tx
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::SessionHeaderHistoryCell::new(
|
||||
header_model,
|
||||
header_reasoning_effort,
|
||||
widget.config.cwd.clone(),
|
||||
CODEX_CLI_VERSION,
|
||||
),
|
||||
)));
|
||||
|
||||
widget
|
||||
}
|
||||
|
||||
@@ -3962,15 +3966,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
|
||||
// Keep the placeholder session header as the active cell until real session info arrives,
|
||||
// so we can merge headers instead of committing a duplicate box to history.
|
||||
let keep_placeholder_header_active = !self.is_session_configured()
|
||||
&& self
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.as_any().is::<history_cell::SessionHeaderHistoryCell>());
|
||||
|
||||
if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() {
|
||||
if cell.has_visible_display_lines() {
|
||||
// Only break exec grouping if the cell renders visible lines.
|
||||
self.flush_active_cell();
|
||||
self.needs_final_message_separator = true;
|
||||
@@ -6958,8 +6954,6 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn refresh_model_display(&mut self) {
|
||||
let effective = self.effective_collaboration_mode();
|
||||
self.session_header.set_model(effective.model());
|
||||
// Keep composer paste affordances aligned with the currently effective model.
|
||||
self.sync_image_paste_enabled();
|
||||
}
|
||||
@@ -7090,46 +7084,6 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a placeholder header cell while the session is configuring.
|
||||
fn placeholder_session_header_cell(config: &Config) -> 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(),
|
||||
placeholder_style,
|
||||
None,
|
||||
config.cwd.clone(),
|
||||
CODEX_CLI_VERSION,
|
||||
))
|
||||
}
|
||||
|
||||
/// Merge the real session info cell with any placeholder header to avoid double boxes.
|
||||
fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) {
|
||||
let mut session_info_cell = Some(Box::new(cell) as Box<dyn HistoryCell>);
|
||||
let merged_header = if let Some(active) = self.active_cell.take() {
|
||||
if active
|
||||
.as_any()
|
||||
.is::<history_cell::SessionHeaderHistoryCell>()
|
||||
{
|
||||
// Reuse the existing placeholder header to avoid rendering two boxes.
|
||||
if let Some(cell) = session_info_cell.take() {
|
||||
self.active_cell = Some(cell);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
self.active_cell = Some(active);
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
self.flush_active_cell();
|
||||
|
||||
if !merged_header && let Some(cell) = session_info_cell {
|
||||
self.add_boxed_history(cell);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_info_message(&mut self, message: String, hint: Option<String>) {
|
||||
self.add_to_history(history_cell::new_info_event(message, hint));
|
||||
self.request_redraw();
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
pub(crate) struct SessionHeader {
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl SessionHeader {
|
||||
pub(crate) fn new(model: String) -> Self {
|
||||
Self { model }
|
||||
}
|
||||
|
||||
/// Updates the header's model text.
|
||||
pub(crate) fn set_model(&mut self, model: &str) {
|
||||
if self.model != model {
|
||||
self.model = model.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1668,7 +1668,6 @@ async fn make_chatwidget_manual(
|
||||
auth_manager,
|
||||
models_manager,
|
||||
otel_manager,
|
||||
session_header: SessionHeader::new(resolved_model.clone()),
|
||||
initial_user_message: None,
|
||||
token_info: None,
|
||||
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
|
||||
|
||||
@@ -83,6 +83,8 @@ use crate::terminal_palette::indexed_color;
|
||||
use crate::terminal_palette::rgb_color;
|
||||
use crate::terminal_palette::stdout_color_level;
|
||||
use codex_core::git_info::get_git_repo_root;
|
||||
use codex_core::terminal::TerminalName;
|
||||
use codex_core::terminal::terminal_info;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
|
||||
/// Classifies a diff line for gutter sign rendering and style selection.
|
||||
@@ -110,6 +112,14 @@ enum DiffTheme {
|
||||
Light,
|
||||
}
|
||||
|
||||
/// Palette depth the diff renderer will target.
|
||||
///
|
||||
/// This is the *renderer's own* notion of color depth, derived from — but not
|
||||
/// identical to — the raw [`StdoutColorLevel`] reported by `supports-color`.
|
||||
/// The indirection exists because some terminals (notably Windows Terminal)
|
||||
/// advertise only ANSI-16 support while actually rendering truecolor sequences
|
||||
/// correctly; [`diff_color_level_for_terminal`] promotes those cases so the
|
||||
/// diff output uses the richer palette.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum DiffColorLevel {
|
||||
TrueColor,
|
||||
@@ -117,6 +127,22 @@ enum DiffColorLevel {
|
||||
Ansi16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum RichDiffColorLevel {
|
||||
TrueColor,
|
||||
Ansi256,
|
||||
}
|
||||
|
||||
impl RichDiffColorLevel {
|
||||
fn from_diff_color_level(level: DiffColorLevel) -> Option<Self> {
|
||||
match level {
|
||||
DiffColorLevel::TrueColor => Some(Self::TrueColor),
|
||||
DiffColorLevel::Ansi256 => Some(Self::Ansi256),
|
||||
DiffColorLevel::Ansi16 => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiffSummary {
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
cwd: PathBuf,
|
||||
@@ -876,11 +902,75 @@ fn diff_theme() -> DiffTheme {
|
||||
diff_theme_for_bg(default_bg())
|
||||
}
|
||||
|
||||
/// Return the [`DiffColorLevel`] for the current terminal session.
|
||||
///
|
||||
/// This is the environment-reading adapter: it samples runtime signals
|
||||
/// (`supports-color` level, terminal name, `WT_SESSION`, and `FORCE_COLOR`)
|
||||
/// and forwards them to [`diff_color_level_for_terminal`].
|
||||
///
|
||||
/// Keeping env reads in this thin wrapper lets
|
||||
/// [`diff_color_level_for_terminal`] stay pure and easy to unit test.
|
||||
fn diff_color_level() -> DiffColorLevel {
|
||||
match stdout_color_level() {
|
||||
diff_color_level_for_terminal(
|
||||
stdout_color_level(),
|
||||
terminal_info().name,
|
||||
std::env::var_os("WT_SESSION").is_some(),
|
||||
has_force_color_override(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns whether `FORCE_COLOR` is explicitly set.
|
||||
fn has_force_color_override() -> bool {
|
||||
std::env::var_os("FORCE_COLOR").is_some()
|
||||
}
|
||||
|
||||
/// Map a raw [`StdoutColorLevel`] to a [`DiffColorLevel`] using
|
||||
/// Windows Terminal-specific truecolor promotion rules.
|
||||
///
|
||||
/// This helper is intentionally pure (no env access) so tests can validate
|
||||
/// the policy table by passing explicit inputs.
|
||||
///
|
||||
/// Windows Terminal fully supports 24-bit color but the `supports-color`
|
||||
/// crate often reports only ANSI-16 there because no `COLORTERM` variable
|
||||
/// is set. We detect Windows Terminal two ways — via `terminal_name`
|
||||
/// (parsed from `WT_SESSION` / `TERM_PROGRAM` by `terminal_info()`) and
|
||||
/// via the raw `has_wt_session` flag.
|
||||
///
|
||||
/// These signals are intentionally not equivalent: `terminal_name` is a
|
||||
/// derived classification with `TERM_PROGRAM` precedence, so `WT_SESSION`
|
||||
/// can be present while `terminal_name` is not `WindowsTerminal`.
|
||||
///
|
||||
/// When `WT_SESSION` is present, we promote to truecolor unconditionally
|
||||
/// unless `FORCE_COLOR` is set. This keeps Windows Terminal rendering rich
|
||||
/// by default while preserving explicit `FORCE_COLOR` user intent.
|
||||
///
|
||||
/// Outside `WT_SESSION`, only ANSI-16 is promoted for identified
|
||||
/// `WindowsTerminal` sessions; `Unknown` stays conservative.
|
||||
fn diff_color_level_for_terminal(
|
||||
stdout_level: StdoutColorLevel,
|
||||
terminal_name: TerminalName,
|
||||
has_wt_session: bool,
|
||||
has_force_color_override: bool,
|
||||
) -> DiffColorLevel {
|
||||
if has_wt_session && !has_force_color_override {
|
||||
return DiffColorLevel::TrueColor;
|
||||
}
|
||||
|
||||
let base = match stdout_level {
|
||||
StdoutColorLevel::TrueColor => DiffColorLevel::TrueColor,
|
||||
StdoutColorLevel::Ansi256 => DiffColorLevel::Ansi256,
|
||||
StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => DiffColorLevel::Ansi16,
|
||||
};
|
||||
|
||||
// Outside `WT_SESSION`, keep the existing Windows Terminal promotion for
|
||||
// ANSI-16 sessions that likely support truecolor.
|
||||
if stdout_level == StdoutColorLevel::Ansi16
|
||||
&& terminal_name == TerminalName::WindowsTerminal
|
||||
&& !has_force_color_override
|
||||
{
|
||||
DiffColorLevel::TrueColor
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,10 +998,11 @@ fn diff_color_level() -> DiffColorLevel {
|
||||
/// Context lines intentionally leave the background unset so the terminal
|
||||
/// default shows through.
|
||||
fn style_line_bg_for(kind: DiffLineType, theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
match kind {
|
||||
DiffLineType::Insert => Style::default().bg(add_line_bg(theme, color_level)),
|
||||
DiffLineType::Delete => Style::default().bg(del_line_bg(theme, color_level)),
|
||||
DiffLineType::Context => Style::default(),
|
||||
match (kind, RichDiffColorLevel::from_diff_color_level(color_level)) {
|
||||
(_, None) => Style::default(),
|
||||
(DiffLineType::Insert, Some(level)) => Style::default().bg(add_line_bg(theme, level)),
|
||||
(DiffLineType::Delete, Some(level)) => Style::default().bg(del_line_bg(theme, level)),
|
||||
(DiffLineType::Context, _) => Style::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,25 +1010,21 @@ fn style_context() -> Style {
|
||||
Style::default()
|
||||
}
|
||||
|
||||
fn add_line_bg(theme: DiffTheme, color_level: DiffColorLevel) -> Color {
|
||||
fn add_line_bg(theme: DiffTheme, color_level: RichDiffColorLevel) -> Color {
|
||||
match (theme, color_level) {
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb_color(DARK_TC_ADD_LINE_BG_RGB),
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed_color(DARK_256_ADD_LINE_BG_IDX),
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Green,
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_ADD_LINE_BG_RGB),
|
||||
(DiffTheme::Light, DiffColorLevel::Ansi256) => indexed_color(LIGHT_256_ADD_LINE_BG_IDX),
|
||||
(DiffTheme::Light, DiffColorLevel::Ansi16) => Color::LightGreen,
|
||||
(DiffTheme::Dark, RichDiffColorLevel::TrueColor) => rgb_color(DARK_TC_ADD_LINE_BG_RGB),
|
||||
(DiffTheme::Dark, RichDiffColorLevel::Ansi256) => indexed_color(DARK_256_ADD_LINE_BG_IDX),
|
||||
(DiffTheme::Light, RichDiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_ADD_LINE_BG_RGB),
|
||||
(DiffTheme::Light, RichDiffColorLevel::Ansi256) => indexed_color(LIGHT_256_ADD_LINE_BG_IDX),
|
||||
}
|
||||
}
|
||||
|
||||
fn del_line_bg(theme: DiffTheme, color_level: DiffColorLevel) -> Color {
|
||||
fn del_line_bg(theme: DiffTheme, color_level: RichDiffColorLevel) -> Color {
|
||||
match (theme, color_level) {
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb_color(DARK_TC_DEL_LINE_BG_RGB),
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed_color(DARK_256_DEL_LINE_BG_IDX),
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Red,
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_DEL_LINE_BG_RGB),
|
||||
(DiffTheme::Light, DiffColorLevel::Ansi256) => indexed_color(LIGHT_256_DEL_LINE_BG_IDX),
|
||||
(DiffTheme::Light, DiffColorLevel::Ansi16) => Color::LightRed,
|
||||
(DiffTheme::Dark, RichDiffColorLevel::TrueColor) => rgb_color(DARK_TC_DEL_LINE_BG_RGB),
|
||||
(DiffTheme::Dark, RichDiffColorLevel::Ansi256) => indexed_color(DARK_256_DEL_LINE_BG_IDX),
|
||||
(DiffTheme::Light, RichDiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_DEL_LINE_BG_RGB),
|
||||
(DiffTheme::Light, RichDiffColorLevel::Ansi256) => indexed_color(LIGHT_256_DEL_LINE_BG_IDX),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,19 +1036,17 @@ fn light_gutter_fg(color_level: DiffColorLevel) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
fn light_add_num_bg(color_level: DiffColorLevel) -> Color {
|
||||
fn light_add_num_bg(color_level: RichDiffColorLevel) -> Color {
|
||||
match color_level {
|
||||
DiffColorLevel::TrueColor => rgb_color(LIGHT_TC_ADD_NUM_BG_RGB),
|
||||
DiffColorLevel::Ansi256 => indexed_color(LIGHT_256_ADD_NUM_BG_IDX),
|
||||
DiffColorLevel::Ansi16 => Color::Green,
|
||||
RichDiffColorLevel::TrueColor => rgb_color(LIGHT_TC_ADD_NUM_BG_RGB),
|
||||
RichDiffColorLevel::Ansi256 => indexed_color(LIGHT_256_ADD_NUM_BG_IDX),
|
||||
}
|
||||
}
|
||||
|
||||
fn light_del_num_bg(color_level: DiffColorLevel) -> Color {
|
||||
fn light_del_num_bg(color_level: RichDiffColorLevel) -> Color {
|
||||
match color_level {
|
||||
DiffColorLevel::TrueColor => rgb_color(LIGHT_TC_DEL_NUM_BG_RGB),
|
||||
DiffColorLevel::Ansi256 => indexed_color(LIGHT_256_DEL_NUM_BG_IDX),
|
||||
DiffColorLevel::Ansi16 => Color::Red,
|
||||
RichDiffColorLevel::TrueColor => rgb_color(LIGHT_TC_DEL_NUM_BG_RGB),
|
||||
RichDiffColorLevel::Ansi256 => indexed_color(LIGHT_256_DEL_NUM_BG_IDX),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,13 +1054,23 @@ fn light_del_num_bg(color_level: DiffColorLevel) -> Color {
|
||||
/// tinted background so numbers contrast against the pastel line fill. On
|
||||
/// dark backgrounds a simple `DIM` modifier is sufficient.
|
||||
fn style_gutter_for(kind: DiffLineType, theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
match (theme, kind) {
|
||||
(DiffTheme::Light, DiffLineType::Insert) => Style::default()
|
||||
match (
|
||||
theme,
|
||||
kind,
|
||||
RichDiffColorLevel::from_diff_color_level(color_level),
|
||||
) {
|
||||
(DiffTheme::Light, DiffLineType::Insert, None) => {
|
||||
Style::default().fg(light_gutter_fg(color_level))
|
||||
}
|
||||
(DiffTheme::Light, DiffLineType::Delete, None) => {
|
||||
Style::default().fg(light_gutter_fg(color_level))
|
||||
}
|
||||
(DiffTheme::Light, DiffLineType::Insert, Some(level)) => Style::default()
|
||||
.fg(light_gutter_fg(color_level))
|
||||
.bg(light_add_num_bg(color_level)),
|
||||
(DiffTheme::Light, DiffLineType::Delete) => Style::default()
|
||||
.bg(light_add_num_bg(level)),
|
||||
(DiffTheme::Light, DiffLineType::Delete, Some(level)) => Style::default()
|
||||
.fg(light_gutter_fg(color_level))
|
||||
.bg(light_del_num_bg(color_level)),
|
||||
.bg(light_del_num_bg(level)),
|
||||
_ => style_gutter_dim(),
|
||||
}
|
||||
}
|
||||
@@ -1001,26 +1096,38 @@ fn style_sign_del(theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
/// Content style for insert lines (plain, non-syntax-highlighted text).
|
||||
fn style_add(theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
match (theme, color_level) {
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi16) => Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(add_line_bg(theme, color_level)),
|
||||
(DiffTheme::Light, _) => Style::default().bg(add_line_bg(theme, color_level)),
|
||||
(DiffTheme::Dark, _) => Style::default()
|
||||
(_, DiffColorLevel::Ansi16) => Style::default().fg(Color::Green),
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor) => {
|
||||
Style::default().bg(add_line_bg(theme, RichDiffColorLevel::TrueColor))
|
||||
}
|
||||
(DiffTheme::Light, DiffColorLevel::Ansi256) => {
|
||||
Style::default().bg(add_line_bg(theme, RichDiffColorLevel::Ansi256))
|
||||
}
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor) => Style::default()
|
||||
.fg(Color::Green)
|
||||
.bg(add_line_bg(theme, color_level)),
|
||||
.bg(add_line_bg(theme, RichDiffColorLevel::TrueColor)),
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi256) => Style::default()
|
||||
.fg(Color::Green)
|
||||
.bg(add_line_bg(theme, RichDiffColorLevel::Ansi256)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Content style for delete lines (plain, non-syntax-highlighted text).
|
||||
fn style_del(theme: DiffTheme, color_level: DiffColorLevel) -> Style {
|
||||
match (theme, color_level) {
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi16) => Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(del_line_bg(theme, color_level)),
|
||||
(DiffTheme::Light, _) => Style::default().bg(del_line_bg(theme, color_level)),
|
||||
(DiffTheme::Dark, _) => Style::default()
|
||||
(_, DiffColorLevel::Ansi16) => Style::default().fg(Color::Red),
|
||||
(DiffTheme::Light, DiffColorLevel::TrueColor) => {
|
||||
Style::default().bg(del_line_bg(theme, RichDiffColorLevel::TrueColor))
|
||||
}
|
||||
(DiffTheme::Light, DiffColorLevel::Ansi256) => {
|
||||
Style::default().bg(del_line_bg(theme, RichDiffColorLevel::Ansi256))
|
||||
}
|
||||
(DiffTheme::Dark, DiffColorLevel::TrueColor) => Style::default()
|
||||
.fg(Color::Red)
|
||||
.bg(del_line_bg(theme, color_level)),
|
||||
.bg(del_line_bg(theme, RichDiffColorLevel::TrueColor)),
|
||||
(DiffTheme::Dark, DiffColorLevel::Ansi256) => Style::default()
|
||||
.fg(Color::Red)
|
||||
.bg(del_line_bg(theme, RichDiffColorLevel::Ansi256)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,32 +1146,28 @@ mod tests {
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
#[test]
|
||||
fn dark_ansi16_add_style_has_contrast() {
|
||||
fn ansi16_add_style_uses_foreground_only() {
|
||||
let style = style_add(DiffTheme::Dark, DiffColorLevel::Ansi16);
|
||||
assert_eq!(style.fg, Some(Color::Black));
|
||||
assert_eq!(style.bg, Some(Color::Green));
|
||||
assert_ne!(style.fg, style.bg);
|
||||
assert_eq!(style.fg, Some(Color::Green));
|
||||
assert_eq!(style.bg, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_ansi16_del_style_has_contrast() {
|
||||
fn ansi16_del_style_uses_foreground_only() {
|
||||
let style = style_del(DiffTheme::Dark, DiffColorLevel::Ansi16);
|
||||
assert_eq!(style.fg, Some(Color::Black));
|
||||
assert_eq!(style.bg, Some(Color::Red));
|
||||
assert_ne!(style.fg, style.bg);
|
||||
assert_eq!(style.fg, Some(Color::Red));
|
||||
assert_eq!(style.bg, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_ansi16_sign_styles_have_contrast() {
|
||||
fn ansi16_sign_styles_use_foreground_only() {
|
||||
let add_sign = style_sign_add(DiffTheme::Dark, DiffColorLevel::Ansi16);
|
||||
assert_eq!(add_sign.fg, Some(Color::Black));
|
||||
assert_eq!(add_sign.bg, Some(Color::Green));
|
||||
assert_ne!(add_sign.fg, add_sign.bg);
|
||||
assert_eq!(add_sign.fg, Some(Color::Green));
|
||||
assert_eq!(add_sign.bg, None);
|
||||
|
||||
let del_sign = style_sign_del(DiffTheme::Dark, DiffColorLevel::Ansi16);
|
||||
assert_eq!(del_sign.fg, Some(Color::Black));
|
||||
assert_eq!(del_sign.bg, Some(Color::Red));
|
||||
assert_ne!(del_sign.fg, del_sign.bg);
|
||||
assert_eq!(del_sign.fg, Some(Color::Red));
|
||||
assert_eq!(del_sign.bg, None);
|
||||
}
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
@@ -1466,6 +1569,32 @@ mod tests {
|
||||
snapshot_diff_gallery("diff_gallery_120x40", 120, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_snapshot_ansi16_insert_delete_no_background() {
|
||||
let mut lines = push_wrapped_diff_line_inner_with_theme_and_color_level(
|
||||
1,
|
||||
DiffLineType::Insert,
|
||||
"added in ansi16 mode",
|
||||
80,
|
||||
line_number_width(2),
|
||||
None,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16,
|
||||
);
|
||||
lines.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
|
||||
2,
|
||||
DiffLineType::Delete,
|
||||
"deleted in ansi16 mode",
|
||||
80,
|
||||
line_number_width(2),
|
||||
None,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16,
|
||||
));
|
||||
|
||||
snapshot_lines("ansi16_insert_delete_no_background", lines, 40, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truecolor_dark_theme_uses_configured_backgrounds() {
|
||||
assert_eq!(
|
||||
@@ -1535,6 +1664,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi16_disables_line_and_gutter_backgrounds() {
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Insert,
|
||||
DiffTheme::Dark,
|
||||
DiffColorLevel::Ansi16
|
||||
),
|
||||
Style::default()
|
||||
);
|
||||
assert_eq!(
|
||||
style_line_bg_for(
|
||||
DiffLineType::Delete,
|
||||
DiffTheme::Light,
|
||||
DiffColorLevel::Ansi16
|
||||
),
|
||||
Style::default()
|
||||
);
|
||||
assert_eq!(
|
||||
style_gutter_for(
|
||||
DiffLineType::Insert,
|
||||
DiffTheme::Light,
|
||||
DiffColorLevel::Ansi16
|
||||
),
|
||||
Style::default().fg(Color::Black)
|
||||
);
|
||||
assert_eq!(
|
||||
style_gutter_for(
|
||||
DiffLineType::Delete,
|
||||
DiffTheme::Light,
|
||||
DiffColorLevel::Ansi16
|
||||
),
|
||||
Style::default().fg(Color::Black)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_truecolor_theme_uses_readable_gutter_and_line_backgrounds() {
|
||||
assert_eq!(
|
||||
@@ -1608,6 +1773,97 @@ mod tests {
|
||||
assert_eq!(lines[1].style.bg, Some(rgb_color(LIGHT_TC_ADD_LINE_BG_RGB)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_terminal_promotes_ansi16_to_truecolor_for_diffs() {
|
||||
assert_eq!(
|
||||
diff_color_level_for_terminal(
|
||||
StdoutColorLevel::Ansi16,
|
||||
TerminalName::WindowsTerminal,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
DiffColorLevel::TrueColor
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_session_promotes_ansi16_to_truecolor_for_diffs() {
|
||||
assert_eq!(
|
||||
diff_color_level_for_terminal(
|
||||
StdoutColorLevel::Ansi16,
|
||||
TerminalName::Unknown,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
DiffColorLevel::TrueColor
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_windows_terminal_keeps_ansi16_diff_palette() {
|
||||
assert_eq!(
|
||||
diff_color_level_for_terminal(
|
||||
StdoutColorLevel::Ansi16,
|
||||
TerminalName::WezTerm,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
DiffColorLevel::Ansi16
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_session_promotes_unknown_color_level_to_truecolor() {
|
||||
assert_eq!(
|
||||
diff_color_level_for_terminal(
|
||||
StdoutColorLevel::Unknown,
|
||||
TerminalName::WindowsTerminal,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
DiffColorLevel::TrueColor
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_wt_windows_terminal_keeps_unknown_color_level_conservative() {
|
||||
assert_eq!(
|
||||
diff_color_level_for_terminal(
|
||||
StdoutColorLevel::Unknown,
|
||||
TerminalName::WindowsTerminal,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
DiffColorLevel::Ansi16
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_force_override_keeps_ansi16_on_windows_terminal() {
|
||||
assert_eq!(
|
||||
diff_color_level_for_terminal(
|
||||
StdoutColorLevel::Ansi16,
|
||||
TerminalName::WindowsTerminal,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
DiffColorLevel::Ansi16
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_force_override_keeps_ansi256_on_windows_terminal() {
|
||||
assert_eq!(
|
||||
diff_color_level_for_terminal(
|
||||
StdoutColorLevel::Ansi256,
|
||||
TerminalName::WindowsTerminal,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
DiffColorLevel::Ansi256
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_diff_uses_path_extension_for_highlighting() {
|
||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the
|
||||
//! rendered transcript output can change.
|
||||
|
||||
use crate::chatwidget::DEFAULT_MODEL_DISPLAY_NAME;
|
||||
use crate::diff_render::create_diff_summary;
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::exec_cell::CommandOutput;
|
||||
@@ -95,6 +96,11 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
/// Returns the logical lines for the main chat viewport.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
|
||||
|
||||
/// Returns whether this cell renders any visible display lines when width is unconstrained.
|
||||
fn has_visible_display_lines(&self) -> bool {
|
||||
!self.display_lines(u16::MAX).is_empty()
|
||||
}
|
||||
|
||||
/// Returns the number of viewport rows needed to render this cell.
|
||||
///
|
||||
/// The default delegates to `Paragraph::line_count` with
|
||||
@@ -1019,43 +1025,30 @@ impl HistoryCell for TooltipHistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SessionInfoCell(CompositeHistoryCell);
|
||||
pub(crate) fn new_session_info_body(
|
||||
config: &Config,
|
||||
requested_model: &str,
|
||||
event: &SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
auth_plan: Option<PlanType>,
|
||||
) -> Option<Box<dyn HistoryCell>> {
|
||||
let parts = session_info_body_parts(config, requested_model, event, is_first_event, auth_plan);
|
||||
|
||||
impl HistoryCell for SessionInfoCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.0.display_lines(width)
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.0.desired_height(width)
|
||||
}
|
||||
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.0.transcript_lines(width)
|
||||
match parts.len() {
|
||||
0 => None,
|
||||
1 => parts.into_iter().next(),
|
||||
_ => Some(Box::new(CompositeHistoryCell::new(parts))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_session_info(
|
||||
fn session_info_body_parts(
|
||||
config: &Config,
|
||||
requested_model: &str,
|
||||
event: SessionConfiguredEvent,
|
||||
event: &SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
auth_plan: Option<PlanType>,
|
||||
) -> SessionInfoCell {
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
reasoning_effort,
|
||||
..
|
||||
} = event;
|
||||
// Header box rendered as history (so it appears at the very top)
|
||||
let header = SessionHeaderHistoryCell::new(
|
||||
model.clone(),
|
||||
reasoning_effort,
|
||||
config.cwd.clone(),
|
||||
CODEX_CLI_VERSION,
|
||||
);
|
||||
let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];
|
||||
) -> Vec<Box<dyn HistoryCell>> {
|
||||
let mut parts: Vec<Box<dyn HistoryCell>> = Vec::new();
|
||||
|
||||
if is_first_event {
|
||||
// Help lines below the header (new copy and list)
|
||||
@@ -1098,17 +1091,17 @@ pub(crate) fn new_session_info(
|
||||
{
|
||||
parts.push(Box::new(tooltips));
|
||||
}
|
||||
if requested_model != model {
|
||||
if requested_model != event.model {
|
||||
let lines = vec![
|
||||
"model changed:".magenta().bold().into(),
|
||||
format!("requested: {requested_model}").into(),
|
||||
format!("used: {model}").into(),
|
||||
format!("used: {}", event.model).into(),
|
||||
];
|
||||
parts.push(Box::new(PlainHistoryCell { lines }));
|
||||
}
|
||||
}
|
||||
|
||||
SessionInfoCell(CompositeHistoryCell { parts })
|
||||
parts
|
||||
}
|
||||
|
||||
pub(crate) fn new_user_prompt(
|
||||
@@ -1203,6 +1196,10 @@ impl SessionHeaderHistoryCell {
|
||||
ReasoningEffortConfig::None => "none",
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_loading_placeholder(&self) -> bool {
|
||||
self.model == DEFAULT_MODEL_DISPLAY_NAME
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for SessionHeaderHistoryCell {
|
||||
|
||||
@@ -126,43 +126,7 @@ where
|
||||
|
||||
for line in wrapped {
|
||||
queue!(writer, Print("\r\n"))?;
|
||||
// URL lines can be wider than the terminal and will
|
||||
// character-wrap onto continuation rows. Pre-clear those rows
|
||||
// so stale content from a previously longer line is erased.
|
||||
let physical_rows = line.width().max(1).div_ceil(wrap_width);
|
||||
if physical_rows > 1 {
|
||||
queue!(writer, SavePosition)?;
|
||||
for _ in 1..physical_rows {
|
||||
queue!(writer, MoveDown(1), MoveToColumn(0))?;
|
||||
queue!(writer, Clear(ClearType::UntilNewLine))?;
|
||||
}
|
||||
queue!(writer, RestorePosition)?;
|
||||
}
|
||||
queue!(
|
||||
writer,
|
||||
SetColors(Colors::new(
|
||||
line.style
|
||||
.fg
|
||||
.map(std::convert::Into::into)
|
||||
.unwrap_or(CColor::Reset),
|
||||
line.style
|
||||
.bg
|
||||
.map(std::convert::Into::into)
|
||||
.unwrap_or(CColor::Reset)
|
||||
))
|
||||
)?;
|
||||
queue!(writer, Clear(ClearType::UntilNewLine))?;
|
||||
// Merge line-level style into each span so that ANSI colors reflect
|
||||
// line styles (e.g., blockquotes with green fg).
|
||||
let merged_spans: Vec<Span> = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| Span {
|
||||
style: s.style.patch(line.style),
|
||||
content: s.content.clone(),
|
||||
})
|
||||
.collect();
|
||||
write_spans(writer, merged_spans.iter())?;
|
||||
write_line(writer, &line, wrap_width)?;
|
||||
}
|
||||
|
||||
queue!(writer, ResetScrollRegion)?;
|
||||
@@ -181,6 +145,36 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_top_visible_history_lines<B>(
|
||||
terminal: &mut crate::custom_terminal::Terminal<B>,
|
||||
lines: Vec<Line>,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
{
|
||||
if lines.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let top = terminal
|
||||
.viewport_area
|
||||
.top()
|
||||
.saturating_sub(terminal.visible_history_rows());
|
||||
let wrap_width = terminal.viewport_area.width.max(1) as usize;
|
||||
let last_cursor_pos = terminal.last_known_cursor_pos;
|
||||
let writer = terminal.backend_mut();
|
||||
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let y = top.saturating_add(index as u16);
|
||||
queue!(writer, MoveTo(0, y))?;
|
||||
write_line(writer, line, wrap_width)?;
|
||||
}
|
||||
|
||||
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?;
|
||||
std::io::Write::flush(writer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetScrollRegion(pub std::ops::Range<u16>);
|
||||
|
||||
@@ -329,6 +323,43 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
fn write_line(mut writer: &mut impl Write, line: &Line<'_>, wrap_width: usize) -> io::Result<()> {
|
||||
// URL lines can be wider than the terminal and will character-wrap onto continuation rows.
|
||||
// Pre-clear those rows so stale content from a previously longer line is erased.
|
||||
let physical_rows = line.width().max(1).div_ceil(wrap_width);
|
||||
if physical_rows > 1 {
|
||||
queue!(writer, SavePosition)?;
|
||||
for _ in 1..physical_rows {
|
||||
queue!(writer, MoveDown(1), MoveToColumn(0))?;
|
||||
queue!(writer, Clear(ClearType::UntilNewLine))?;
|
||||
}
|
||||
queue!(writer, RestorePosition)?;
|
||||
}
|
||||
queue!(
|
||||
writer,
|
||||
SetColors(Colors::new(
|
||||
line.style
|
||||
.fg
|
||||
.map(std::convert::Into::into)
|
||||
.unwrap_or(CColor::Reset),
|
||||
line.style
|
||||
.bg
|
||||
.map(std::convert::Into::into)
|
||||
.unwrap_or(CColor::Reset)
|
||||
))
|
||||
)?;
|
||||
queue!(writer, Clear(ClearType::UntilNewLine))?;
|
||||
let merged_spans: Vec<Span> = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| Span {
|
||||
style: s.style.patch(line.style),
|
||||
content: s.content.clone(),
|
||||
})
|
||||
.collect();
|
||||
write_spans(&mut writer, merged_spans.iter())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/diff_render.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"1 +added in ansi16 mode "
|
||||
"2 -deleted in ansi16 mode "
|
||||
" "
|
||||
" "
|
||||
@@ -445,6 +445,16 @@ impl Tui {
|
||||
self.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
pub fn replace_top_visible_history_lines(&mut self, lines: Vec<Line<'static>>) -> Result<()> {
|
||||
let line_count = lines.len();
|
||||
if self.terminal.visible_history_rows() >= line_count as u16 {
|
||||
crate::insert_history::replace_top_visible_history_lines(&mut self.terminal, lines)?;
|
||||
} else if self.pending_history_lines.len() >= line_count {
|
||||
self.pending_history_lines.splice(0..line_count, lines);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_pending_history_lines(&mut self) {
|
||||
self.pending_history_lines.clear();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::trace;
|
||||
|
||||
const AUDIO_MODEL: &str = "gpt-4o-mini-transcribe";
|
||||
const MODEL_AUDIO_SAMPLE_RATE: u32 = 24_000;
|
||||
const MODEL_AUDIO_CHANNELS: u16 = 1;
|
||||
|
||||
struct TranscriptionAuthContext {
|
||||
mode: AuthMode,
|
||||
bearer_token: String,
|
||||
@@ -268,9 +272,7 @@ fn select_default_input_device_and_config()
|
||||
let device = host
|
||||
.default_input_device()
|
||||
.ok_or_else(|| "no input audio device available".to_string())?;
|
||||
let config = device
|
||||
.default_input_config()
|
||||
.map_err(|e| format!("failed to get default input config: {e}"))?;
|
||||
let config = crate::audio_device::preferred_input_config(&device)?;
|
||||
Ok((device, config))
|
||||
}
|
||||
|
||||
@@ -395,20 +397,35 @@ fn send_realtime_audio_chunk(
|
||||
return;
|
||||
}
|
||||
|
||||
let samples = if sample_rate == MODEL_AUDIO_SAMPLE_RATE && channels == MODEL_AUDIO_CHANNELS {
|
||||
samples
|
||||
} else {
|
||||
convert_pcm16(
|
||||
&samples,
|
||||
sample_rate,
|
||||
channels,
|
||||
MODEL_AUDIO_SAMPLE_RATE,
|
||||
MODEL_AUDIO_CHANNELS,
|
||||
)
|
||||
};
|
||||
if samples.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity(samples.len() * 2);
|
||||
for sample in &samples {
|
||||
bytes.extend_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let samples_per_channel = (samples.len() / usize::from(channels)) as u32;
|
||||
let samples_per_channel = (samples.len() / usize::from(MODEL_AUDIO_CHANNELS)) as u32;
|
||||
|
||||
tx.send(AppEvent::CodexOp(Op::RealtimeConversationAudio(
|
||||
ConversationAudioParams {
|
||||
frame: RealtimeAudioFrame {
|
||||
data: encoded,
|
||||
sample_rate,
|
||||
num_channels: channels,
|
||||
sample_rate: MODEL_AUDIO_SAMPLE_RATE,
|
||||
num_channels: MODEL_AUDIO_CHANNELS,
|
||||
samples_per_channel: Some(samples_per_channel),
|
||||
},
|
||||
},
|
||||
@@ -505,7 +522,7 @@ impl RealtimeAudioPlayer {
|
||||
for pair in raw_bytes.chunks_exact(2) {
|
||||
pcm.push(i16::from_le_bytes([pair[0], pair[1]]));
|
||||
}
|
||||
let converted = convert_pcm16_for_output(
|
||||
let converted = convert_pcm16(
|
||||
&pcm,
|
||||
frame.sample_rate,
|
||||
frame.num_channels,
|
||||
@@ -598,7 +615,7 @@ fn fill_output_u16(output: &mut [u16], queue: &Arc<Mutex<VecDeque<i16>>>) {
|
||||
output.fill(32768);
|
||||
}
|
||||
|
||||
fn convert_pcm16_for_output(
|
||||
fn convert_pcm16(
|
||||
input: &[i16],
|
||||
input_sample_rate: u32,
|
||||
input_channels: u16,
|
||||
@@ -672,10 +689,29 @@ fn clip_duration_seconds(audio: &RecordedAudio) -> f32 {
|
||||
}
|
||||
|
||||
fn encode_wav_normalized(audio: &RecordedAudio) -> Result<Vec<u8>, String> {
|
||||
let converted;
|
||||
let (channels, sample_rate, segment) =
|
||||
if audio.channels == MODEL_AUDIO_CHANNELS && audio.sample_rate == MODEL_AUDIO_SAMPLE_RATE {
|
||||
(audio.channels, audio.sample_rate, audio.data.as_slice())
|
||||
} else {
|
||||
converted = convert_pcm16(
|
||||
&audio.data,
|
||||
audio.sample_rate,
|
||||
audio.channels,
|
||||
MODEL_AUDIO_SAMPLE_RATE,
|
||||
MODEL_AUDIO_CHANNELS,
|
||||
);
|
||||
(
|
||||
MODEL_AUDIO_CHANNELS,
|
||||
MODEL_AUDIO_SAMPLE_RATE,
|
||||
converted.as_slice(),
|
||||
)
|
||||
};
|
||||
|
||||
let mut wav_bytes: Vec<u8> = Vec::new();
|
||||
let spec = WavSpec {
|
||||
channels: audio.channels,
|
||||
sample_rate: audio.sample_rate,
|
||||
channels,
|
||||
sample_rate,
|
||||
bits_per_sample: 16,
|
||||
sample_format: SampleFormat::Int,
|
||||
};
|
||||
@@ -684,7 +720,6 @@ fn encode_wav_normalized(audio: &RecordedAudio) -> Result<Vec<u8>, String> {
|
||||
WavWriter::new(&mut cursor, spec).map_err(|_| "failed to create wav writer".to_string())?;
|
||||
|
||||
// Simple peak normalization with headroom to improve audibility on quiet inputs.
|
||||
let segment = &audio.data[..];
|
||||
let mut peak: i16 = 0;
|
||||
for &s in segment {
|
||||
let a = s.unsigned_abs();
|
||||
@@ -782,7 +817,7 @@ async fn transcribe_bytes(
|
||||
.mime_str("audio/wav")
|
||||
.map_err(|e| format!("failed to set mime: {e}"))?;
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.text("model", "gpt-4o-transcribe")
|
||||
.text("model", AUDIO_MODEL)
|
||||
.part("file", part);
|
||||
if let Some(context) = context {
|
||||
form = form.text("prompt", context);
|
||||
@@ -834,3 +869,40 @@ async fn transcribe_bytes(
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RecordedAudio;
|
||||
use super::convert_pcm16;
|
||||
use super::encode_wav_normalized;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn convert_pcm16_downmixes_and_resamples_for_model_input() {
|
||||
let input = vec![100, 300, 200, 400, 500, 700, 600, 800];
|
||||
let converted = convert_pcm16(&input, 48_000, 2, 24_000, 1);
|
||||
assert_eq!(converted, vec![200, 700]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_wav_normalized_outputs_24khz_mono_audio() {
|
||||
let audio = RecordedAudio {
|
||||
data: vec![100, 300, 200, 400, 500, 700, 600, 800],
|
||||
sample_rate: 48_000,
|
||||
channels: 2,
|
||||
};
|
||||
|
||||
let wav = encode_wav_normalized(&audio).expect("wav should encode");
|
||||
let reader = hound::WavReader::new(Cursor::new(wav)).expect("wav should parse");
|
||||
let spec = reader.spec();
|
||||
let samples = reader
|
||||
.into_samples::<i16>()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("samples should decode");
|
||||
|
||||
assert_eq!(spec.channels, 1);
|
||||
assert_eq!(spec.sample_rate, 24_000);
|
||||
assert_eq!(samples, vec![8_426, 29_490]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user