Compare commits

..

16 Commits

Author SHA1 Message Date
rhan-oai
0055796543 Merge branch 'main' into codex/loading 2026-02-27 11:01:07 -08:00
Roy Han
05bc61c428 fix issue with resume 2026-02-27 11:00:32 -08:00
Roy Han
ecaa77e499 reformat line width check 2026-02-27 10:27:22 -08:00
Jeremy Rose
bc0a5843df Align TUI voice transcription audio with 4o ASR (#13030)
## Summary
- switch TUI push-to-talk transcription requests to
`gpt-4o-mini-transcribe`
- prefer 24 kHz mono `i16` microphone configs and normalize voice input
to 24 kHz mono before upload/send
- add unit coverage for the new downmix/resample path

## Testing
- `just fmt`
- `cargo test -p codex-tui`
2026-02-27 18:22:48 +00:00
Roy Han
6f1485d89c constants 2026-02-27 10:14:13 -08:00
Felipe Coury
3b5996f988 fix(tui): promote windows terminal diff ansi16 to truecolor (#13016)
## Summary

- Promote ANSI-16 to truecolor for diff rendering when running inside
Windows Terminal
- Respect explicit `FORCE_COLOR` override, skipping promotion when set
- Extract a pure `diff_color_level_for_terminal` function for
testability
- Strip background tints from ANSI-16 diff output, rendering add/delete
lines with foreground color only
- Introduce `RichDiffColorLevel` to type-safely restrict background
fills to truecolor and ansi256

## Problem

Windows Terminal fully supports 24-bit (truecolor) rendering but often
does not provide the usual TERM metadata (`TERM`, `TERM_PROGRAM`,
`COLORTERM`) in `cmd.exe`/PowerShell sessions. In those environments,
`supports-color` can report only ANSI-16 support. The diff renderer
therefore falls back to a 16-color palette, producing washed-out,
hard-to-read diffs.

The screenshots below demonstrate that both PowerShell and cmd.exe don't
set any `*TERM*` environment variables.

| PowerShell | cmd.exe |
|---|---|
| <img width="2032" height="1162" alt="SCR-20260226-nfvy"
src="https://github.com/user-attachments/assets/59e968cc-4add-4c7b-a415-07163297e86a"
/> | <img width="2032" height="1162" alt="SCR-20260226-nfyc"
src="https://github.com/user-attachments/assets/d06b3e39-bf91-4ce3-9705-82bf9563a01b"
/> |


## Mental model

`StdoutColorLevel` (from `supports-color`) is the _detected_ capability.
`DiffColorLevel` is the _intended_ capability for diff rendering. A new
intermediary — `diff_color_level_for_terminal` — maps one to the other
and is the single place where terminal-specific overrides live.

Windows Terminal is detected two independent ways: the `TerminalName`
parsed by `terminal_info()` and the raw presence of `WT_SESSION`. When
`WT_SESSION` is present and `FORCE_COLOR` is not set, we promote
unconditionally to truecolor. When `WT_SESSION` is absent but
`TerminalName::WindowsTerminal` is detected, we promote only the ANSI-16
level (not `Unknown`).

A single override helper — `has_force_color_override()` — checks whether
`FORCE_COLOR` is set. When it is, both the `WT_SESSION` fast-path and
the `TerminalName`-based promotion are suppressed, preserving explicit
user intent.

| PowerShell | cmd.exe | WSL | Bash for Windows |
|---|---|---|---|
|
![SCR-20260226-msrh](https://github.com/user-attachments/assets/0f6297a6-4241-4dbf-b7ff-cf02da8941b0)
|
![SCR-20260226-nbao](https://github.com/user-attachments/assets/bb5ff8a9-903c-4677-a2de-1f6e1f34b18e)
|
![SCR-20260226-nbej](https://github.com/user-attachments/assets/26ecec2c-a7e9-410a-8702-f73995b490a6)
|
![SCR-20260226-nbkz](https://github.com/user-attachments/assets/80c4bf9a-3b41-40e1-bc87-f5c565f96075)
|

## Non-goals

- This does not change color detection for anything outside the diff
renderer (e.g. the chat widget, markdown rendering).
- This does not add a user-facing config knob; `FORCE_COLOR` already
serves that role.

## Tradeoffs

- The `has_wt_session` signal is intentionally kept separate from
`TerminalName::WindowsTerminal`. `terminal_info()` is derived with
`TERM_PROGRAM` precedence, so it can differ from raw `WT_SESSION`.
- Real-world validation in this issue: in both `cmd.exe` and PowerShell,
`TERM`/`TERM_PROGRAM`/`COLORTERM` were absent, so TERM-based capability
hints were unavailable in those sessions.
- Checking `FORCE_COLOR` for presence rather than parsing its value is a
simplification. In practice `supports-color` has already parsed it, so
our check is a coarse "did the user set _anything_?" gate. The effective
color level still comes from `supports-color`.
- When `WT_SESSION` is present without `FORCE_COLOR`, we promote to
truecolor regardless of `stdout_level` (including `Unknown`). This is
aggressive but correct: `WT_SESSION` is a strong signal that we're in
Windows Terminal.
- ANSI-16 add/delete backgrounds (bright green/red) overpower
syntax-highlighted token colors, making diffs harder to read.
Foreground-only cues (colored text, gutter signs) preserve readability
on low-color terminals.

## Architecture

```
stdout_color_level()  ──┐
terminal_info().name  ──┤
WT_SESSION presence   ──┼──▶ diff_color_level_for_terminal() ──▶ DiffColorLevel
FORCE_COLOR presence  ──┘                                            │
                                                                     ▼
                                                          RichDiffColorLevel::from_diff_color_level()
                                                                     │
                                                          ┌──────────┴──────────┐
                                                          │ Some(TrueColor|256) │ → bg tints
                                                          │ None (Ansi16)       │ → fg only
                                                          └─────────────────────┘
```

`diff_color_level()` is the environment-reading entry point; it gathers
the four runtime signals and delegates to the pure, testable
`diff_color_level_for_terminal()`.

## Observability

No new logs or metrics. Incorrect color selection is immediately visible
as broken diff rendering; the test suite covers the decision matrix
exhaustively.

## Tests

Six new unit tests exercise every branch of
`diff_color_level_for_terminal`:

| Test | Inputs | Expected |
|------|--------|----------|
| `windows_terminal_promotes_ansi16_to_truecolor_for_diffs` | Ansi16 +
WindowsTerminal name | TrueColor |
| `wt_session_promotes_ansi16_to_truecolor_for_diffs` | Ansi16 +
WT_SESSION only | TrueColor |
| `non_windows_terminal_keeps_ansi16_diff_palette` | Ansi16 + WezTerm |
Ansi16 |
| `wt_session_promotes_unknown_color_level_to_truecolor` | Unknown +
WT_SESSION | TrueColor |
| `explicit_force_override_keeps_ansi16_on_windows_terminal` | Ansi16 +
WindowsTerminal + FORCE_COLOR | Ansi16 |
| `explicit_force_override_keeps_ansi256_on_windows_terminal` | Ansi256
+ WT_SESSION + FORCE_COLOR | Ansi256 |
| `ansi16_add_style_uses_foreground_only` | Dark + Ansi16 | fg=Green,
bg=None |
| (and any other new snapshot/assertion tests from commits d757fee and
d7c78b3) | | |

## Test plan

- [x] Verify all new unit tests pass (`cargo test -p codex-tui --lib`)
- [x] On Windows Terminal: confirm diffs render with truecolor
backgrounds
- [x] On Windows Terminal with `FORCE_COLOR` set: confirm promotion is
disabled and output follows the forced `supports-color` level
- [x] On macOS/Linux terminals: confirm no behavior change

Fixes https://github.com/openai/codex/issues/12904 
Fixes https://github.com/openai/codex/issues/12890
Fixes https://github.com/openai/codex/issues/12912
Fixes https://github.com/openai/codex/issues/12840
2026-02-27 10:45:59 -07:00
Michael Bolin
d09a7535ed fix: use AbsolutePathBuf for permission profile file roots (#12970)
## Why
`PermissionProfile` should describe filesystem roots as absolute paths
at the type level. Using `PathBuf` in `FileSystemPermissions` made the
shared type too permissive and blurred together three different
deserialization cases:

- skill metadata in `agents/openai.yaml`, where relative paths should
resolve against the skill directory
- app-server API payloads, where callers should have to send absolute
paths
- local tool-call payloads for commands like `shell_command` and
`exec_command`, where `additional_permissions.file_system` may
legitimately be relative to the command `workdir`

This change tightens the shared model without regressing the existing
local command flow.

## What Changed
- changed `protocol::models::FileSystemPermissions` and the app-server
`AdditionalFileSystemPermissions` mirror to use `AbsolutePathBuf`
- wrapped skill metadata deserialization in `AbsolutePathBufGuard`, so
relative permission roots in `agents/openai.yaml` resolve against the
containing skill directory
- kept app-server/API deserialization strict, so relative
`additionalPermissions.fileSystem.*` paths are rejected at the boundary
- restored cwd/workdir-relative deserialization for local tool-call
payloads by parsing `shell`, `shell_command`, and `exec_command`
arguments under an `AbsolutePathBufGuard` rooted at the resolved command
working directory
- simplified runtime additional-permission normalization so it only
canonicalizes and deduplicates absolute roots instead of trying to
recover relative ones later
- updated the app-server schema fixtures, `app-server/README.md`, and
the affected transport/TUI tests to match the final behavior
2026-02-27 17:42:52 +00:00
Roy Han
6b560a46be clippy 2026-02-26 21:26:51 -08:00
Roy Han
83726aebe6 cleanup 2026-02-26 21:14:43 -08:00
Roy Han
dda7973531 cleanup 2026-02-26 21:08:33 -08:00
Roy Han
d927cea570 cleanup 2026-02-26 20:59:07 -08:00
Roy Han
bee23c7917 cleanup 2026-02-26 20:53:36 -08:00
Roy Han
0ed71a0c3b cleanup 2026-02-26 20:50:51 -08:00
Roy Han
e89f442a57 cleanup 2026-02-26 20:45:23 -08:00
Roy Han
311bc6660d cleanup 2026-02-26 20:41:16 -08:00
Roy Han
c800db5cd5 working draft 2026-02-26 19:36:13 -08:00
36 changed files with 1191 additions and 512 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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, };

View File

@@ -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, };

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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,

View File

@@ -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}"))
}
}
}
}

View File

@@ -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 { .. } => {

View File

@@ -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,
})

View File

@@ -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()

View File

@@ -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`.
///

View File

@@ -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`"

View File

@@ -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(&params, 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(),

View File

@@ -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(())
}
}

View File

@@ -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()
};

View File

@@ -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()
};

View File

@@ -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()
})

View File

@@ -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 {

View File

@@ -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`.

View File

@@ -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>
};

View File

@@ -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();

View File

@@ -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)) => {

View File

@@ -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))
);
}

View File

@@ -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();

View File

@@ -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();
}
}
}

View File

@@ -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(),

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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::*;

View File

@@ -0,0 +1,8 @@
---
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"1 +added in ansi16 mode "
"2 -deleted in ansi16 mode "
" "
" "

View File

@@ -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();
}

View File

@@ -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]);
}
}