mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
Merge branch 'main' into async-tui-1
This commit is contained in:
@@ -12,6 +12,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale-contributor-prs:
|
||||
# Prevent scheduled runs on forks
|
||||
if: github.repository == 'openai/codex'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close inactive PRs from contributors
|
||||
|
||||
@@ -9,7 +9,8 @@ on:
|
||||
jobs:
|
||||
gather-duplicates:
|
||||
name: Identify potential duplicates
|
||||
if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate') }}
|
||||
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
|
||||
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -9,7 +9,8 @@ on:
|
||||
jobs:
|
||||
gather-labels:
|
||||
name: Generate label suggestions
|
||||
if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label') }}
|
||||
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
|
||||
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -14,6 +14,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
# Prevent scheduled runs on forks (no secrets, wastes Actions minutes)
|
||||
if: github.repository == 'openai/codex'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
Generated
+199
-60
@@ -42,7 +42,7 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"bytestring",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"foldhash 0.1.5",
|
||||
"futures-core",
|
||||
@@ -137,7 +137,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"bytestring",
|
||||
"cfg-if",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"foldhash 0.1.5",
|
||||
"futures-core",
|
||||
@@ -360,7 +360,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -912,9 +912,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.5.57"
|
||||
version = "4.5.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d9501bd3f5f09f7bbee01da9a511073ed30a80cd7a509f1214bb74eadea71ad"
|
||||
checksum = "4c0da80818b2d95eca9aa614a30783e42f62bf5fdfee24e68cfb960b071ba8d1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
]
|
||||
@@ -1433,7 +1433,7 @@ dependencies = [
|
||||
"allocative",
|
||||
"anyhow",
|
||||
"clap",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"env_logger",
|
||||
"log",
|
||||
"multimap",
|
||||
@@ -1454,6 +1454,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"sentry",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
@@ -1609,7 +1610,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"strum_macros 0.27.2",
|
||||
"tokio",
|
||||
"tonic",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
@@ -1732,7 +1732,7 @@ dependencies = [
|
||||
"codex-windows-sandbox",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"diffy",
|
||||
"dirs",
|
||||
"dunce",
|
||||
@@ -1805,7 +1805,7 @@ dependencies = [
|
||||
"codex-windows-sandbox",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"derive_more 2.0.1",
|
||||
"derive_more 2.1.1",
|
||||
"diffy",
|
||||
"dirs",
|
||||
"dunce",
|
||||
@@ -1820,6 +1820,7 @@ dependencies = [
|
||||
"pulldown-cmark",
|
||||
"rand 0.9.2",
|
||||
"ratatui",
|
||||
"ratatui-core",
|
||||
"ratatui-macros",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
@@ -1841,6 +1842,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-highlight",
|
||||
"tui-scrollbar",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.1",
|
||||
"url",
|
||||
@@ -2005,6 +2007,20 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -2026,6 +2042,18 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-hex"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"proptest",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
@@ -2037,9 +2065,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.7.1"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
@@ -2426,11 +2454,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl 2.0.1",
|
||||
"derive_more-impl 2.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2448,13 +2476,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case 0.7.1",
|
||||
"convert_case 0.10.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.104",
|
||||
"unicode-xid",
|
||||
]
|
||||
@@ -2570,6 +2599,15 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
@@ -2743,7 +2781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2851,7 +2889,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3736,13 +3774,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "insta"
|
||||
version = "1.44.3"
|
||||
version = "1.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698"
|
||||
checksum = "1b66886d14d18d420ab5052cbff544fc5d34d0b2cdd35eb5976aaa10a4a472e5"
|
||||
dependencies = [
|
||||
"console",
|
||||
"once_cell",
|
||||
"similar",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3791,7 +3830,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3895,6 +3934,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.0",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
@@ -4040,6 +4089,12 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "local-waker"
|
||||
version = "0.1.4"
|
||||
@@ -4661,9 +4716,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6"
|
||||
checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -4675,9 +4730,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-appender-tracing"
|
||||
version = "0.30.1"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e68f63eca5fad47e570e00e893094fc17be959c80c79a7d6ec1abdd5ae6ffc16"
|
||||
checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2"
|
||||
dependencies = [
|
||||
"opentelemetry",
|
||||
"tracing",
|
||||
@@ -4687,9 +4742,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-http"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d"
|
||||
checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -4700,9 +4755,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-otlp"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b"
|
||||
checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf"
|
||||
dependencies = [
|
||||
"http 1.3.1",
|
||||
"opentelemetry",
|
||||
@@ -4720,30 +4775,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc"
|
||||
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"hex",
|
||||
"const-hex",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"prost",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2"
|
||||
checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry_sdk"
|
||||
version = "0.30.0"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b"
|
||||
checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-executor",
|
||||
@@ -4751,7 +4808,6 @@ dependencies = [
|
||||
"opentelemetry",
|
||||
"percent-encoding",
|
||||
"rand 0.9.2",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -5134,10 +5190,25 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.13.5"
|
||||
name = "proptest"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
||||
checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"num-traits",
|
||||
"rand 0.9.2",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_xorshift",
|
||||
"regex-syntax 0.8.5",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
@@ -5145,9 +5216,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.13.5"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -5260,7 +5331,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5347,6 +5418,15 @@ dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
@@ -5354,7 +5434,7 @@ source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"compact_str 0.8.1",
|
||||
"crossterm",
|
||||
"indoc",
|
||||
"instability",
|
||||
@@ -5363,7 +5443,27 @@ dependencies = [
|
||||
"paste",
|
||||
"strum 0.26.3",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-truncate 1.1.0",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"compact_str 0.9.0",
|
||||
"hashbrown 0.16.0",
|
||||
"indoc",
|
||||
"itertools 0.14.0",
|
||||
"kasuari",
|
||||
"lru 0.16.2",
|
||||
"strum 0.27.2",
|
||||
"thiserror 2.0.17",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate 2.0.0",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
@@ -5610,7 +5710,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5623,7 +5723,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6530,6 +6630,9 @@ name = "strum"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros 0.27.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
@@ -6969,9 +7072,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
@@ -7082,15 +7185,13 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.13.1"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9"
|
||||
checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"h2",
|
||||
"http 1.3.1",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -7099,9 +7200,8 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"rustls-native-certs",
|
||||
"socket2 0.5.10",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
@@ -7111,6 +7211,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic-prost"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost",
|
||||
"tonic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.2"
|
||||
@@ -7228,15 +7339,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tracing-opentelemetry"
|
||||
version = "0.31.0"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddcf5959f39507d0d04d6413119c04f33b623f4f951ebcbdddddfad2d0623a9c"
|
||||
checksum = "1e6e5658463dd88089aba75c7791e1d3120633b1bfde22478b28f625a9bb1b8e"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"rustversion",
|
||||
"smallvec",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
@@ -7367,6 +7479,16 @@ dependencies = [
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tui-scrollbar"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c42613099915b2e30e9f144670666e858e2538366f77742e1cf1c2f230efcacd"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
"ratatui-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
@@ -7393,6 +7515,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
@@ -7428,6 +7556,17 @@ dependencies = [
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330"
|
||||
dependencies = [
|
||||
"itertools 0.13.0",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
@@ -7872,7 +8011,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+10
-9
@@ -145,7 +145,7 @@ ignore = "0.4.23"
|
||||
image = { version = "^0.25.9", default-features = false }
|
||||
include_dir = "0.7.4"
|
||||
indexmap = "2.12.0"
|
||||
insta = "1.44.3"
|
||||
insta = "1.46.0"
|
||||
itertools = "0.14.0"
|
||||
keyring = { version = "3.6", default-features = false }
|
||||
landlock = "0.4.4"
|
||||
@@ -160,12 +160,12 @@ notify = "8.2.0"
|
||||
nucleo-matcher = "0.3.1"
|
||||
once_cell = "1.20.2"
|
||||
openssl-sys = "*"
|
||||
opentelemetry = "0.30.0"
|
||||
opentelemetry-appender-tracing = "0.30.0"
|
||||
opentelemetry-otlp = "0.30.0"
|
||||
opentelemetry-semantic-conventions = "0.30.0"
|
||||
opentelemetry_sdk = "0.30.0"
|
||||
tracing-opentelemetry = "0.31.0"
|
||||
opentelemetry = "0.31.0"
|
||||
opentelemetry-appender-tracing = "0.31.0"
|
||||
opentelemetry-otlp = "0.31.0"
|
||||
opentelemetry-semantic-conventions = "0.31.0"
|
||||
opentelemetry_sdk = "0.31.0"
|
||||
tracing-opentelemetry = "0.32.0"
|
||||
os_info = "3.12.0"
|
||||
owo-colors = "4.2.0"
|
||||
path-absolutize = "3.1.1"
|
||||
@@ -176,6 +176,7 @@ pretty_assertions = "1.4.1"
|
||||
pulldown-cmark = "0.10"
|
||||
rand = "0.9"
|
||||
ratatui = "0.29.0"
|
||||
ratatui-core = "0.1.0"
|
||||
ratatui-macros = "0.6.0"
|
||||
regex = "1.12.2"
|
||||
regex-lite = "0.1.8"
|
||||
@@ -206,12 +207,11 @@ thiserror = "2.0.17"
|
||||
time = "0.3"
|
||||
tiny_http = "0.12"
|
||||
tokio = "1"
|
||||
tokio-stream = "0.1.17"
|
||||
tokio-stream = "0.1.18"
|
||||
tokio-test = "0.4"
|
||||
tokio-util = "0.7.16"
|
||||
toml = "0.9.5"
|
||||
toml_edit = "0.24.0"
|
||||
tonic = "0.13.1"
|
||||
tracing = "0.1.43"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = "0.3.22"
|
||||
@@ -220,6 +220,7 @@ tree-sitter = "0.25.10"
|
||||
tree-sitter-bash = "0.25"
|
||||
tree-sitter-highlight = "0.25.10"
|
||||
ts-rs = "11"
|
||||
tui-scrollbar = "0.2.1"
|
||||
uds_windows = "1.1.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2"
|
||||
|
||||
+3
-3
@@ -15,8 +15,8 @@ You can also install via Homebrew (`brew install --cask codex`) or download a pl
|
||||
|
||||
## Documentation quickstart
|
||||
|
||||
- First run with Codex? Follow the walkthrough in [`docs/getting-started.md`](../docs/getting-started.md) for prompts, keyboard shortcuts, and session management.
|
||||
- Already shipping with Codex and want deeper control? Jump to [`docs/advanced.md`](../docs/advanced.md) and the configuration reference at [`docs/config.md`](../docs/config.md).
|
||||
- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management).
|
||||
- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md).
|
||||
|
||||
## What's new in the Rust CLI
|
||||
|
||||
@@ -30,7 +30,7 @@ Codex supports a rich set of configuration options. Note that the Rust CLI uses
|
||||
|
||||
#### MCP client
|
||||
|
||||
Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#mcp_servers) for details.
|
||||
Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details.
|
||||
|
||||
#### MCP server (experimental)
|
||||
|
||||
|
||||
@@ -384,6 +384,8 @@ pub struct SendUserTurnParams {
|
||||
pub model: String,
|
||||
pub effort: Option<ReasoningEffort>,
|
||||
pub summary: ReasoningSummary,
|
||||
/// Optional JSON Schema used to constrain the final assistant message for this turn.
|
||||
pub output_schema: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
|
||||
@@ -1319,6 +1319,8 @@ pub struct TurnStartParams {
|
||||
pub effort: Option<ReasoningEffort>,
|
||||
/// Override the reasoning summary for this turn and subsequent turns.
|
||||
pub summary: Option<ReasoningSummary>,
|
||||
/// Optional JSON Schema used to constrain the final assistant message for this turn.
|
||||
pub output_schema: Option<JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
|
||||
@@ -162,7 +162,7 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio
|
||||
- `{"type":"image","url":"https://…png"}`
|
||||
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
|
||||
|
||||
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread.
|
||||
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn.
|
||||
|
||||
```json
|
||||
{ "method": "turn/start", "id": 30, "params": {
|
||||
@@ -178,7 +178,14 @@ You can optionally specify config overrides on the new turn. If specified, these
|
||||
},
|
||||
"model": "gpt-5.1-codex",
|
||||
"effort": "medium",
|
||||
"summary": "concise"
|
||||
"summary": "concise",
|
||||
// Optional JSON Schema to constrain the final assistant message for this turn.
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"properties": { "answer": { "type": "string" } },
|
||||
"required": ["answer"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
} }
|
||||
{ "id": 30, "result": { "turn": {
|
||||
"id": "turn_456",
|
||||
|
||||
@@ -2579,6 +2579,7 @@ impl CodexMessageProcessor {
|
||||
let _ = conversation
|
||||
.submit(Op::UserInput {
|
||||
items: mapped_items,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -2598,6 +2599,7 @@ impl CodexMessageProcessor {
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
output_schema,
|
||||
} = params;
|
||||
|
||||
let Ok(conversation) = self
|
||||
@@ -2632,7 +2634,7 @@ impl CodexMessageProcessor {
|
||||
model,
|
||||
effort,
|
||||
summary,
|
||||
final_output_json_schema: None,
|
||||
final_output_json_schema: output_schema,
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -2741,6 +2743,7 @@ impl CodexMessageProcessor {
|
||||
let turn_id = conversation
|
||||
.submit(Op::UserInput {
|
||||
items: mapped_items,
|
||||
final_output_json_schema: params.output_schema,
|
||||
})
|
||||
.await;
|
||||
|
||||
|
||||
@@ -17,13 +17,11 @@ use tokio::io::BufReader;
|
||||
use tokio::io::{self};
|
||||
use tokio::sync::mpsc;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::Level;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::filter::Targets;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
@@ -103,11 +101,8 @@ pub async fn run_main(
|
||||
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL)
|
||||
.with_filter(EnvFilter::from_default_env());
|
||||
|
||||
let feedback_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(feedback.make_writer())
|
||||
.with_ansi(false)
|
||||
.with_target(false)
|
||||
.with_filter(Targets::new().with_default(Level::TRACE));
|
||||
let feedback_layer = feedback.logger_layer();
|
||||
let feedback_metadata_layer = feedback.metadata_layer();
|
||||
|
||||
let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer());
|
||||
|
||||
@@ -116,6 +111,7 @@ pub async fn run_main(
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(stderr_fmt)
|
||||
.with(feedback_layer)
|
||||
.with(feedback_metadata_layer)
|
||||
.with(otel_logger_layer)
|
||||
.with(otel_tracing_layer)
|
||||
.try_init();
|
||||
|
||||
@@ -305,6 +305,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> {
|
||||
model: "mock-model".to_string(),
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
summary: ReasoningSummary::Auto,
|
||||
output_schema: None,
|
||||
})
|
||||
.await?;
|
||||
// Acknowledge sendUserTurn
|
||||
@@ -418,6 +419,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
|
||||
model: model.clone(),
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
summary: ReasoningSummary::Auto,
|
||||
output_schema: None,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
@@ -443,6 +445,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<(
|
||||
model: model.clone(),
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
summary: ReasoningSummary::Auto,
|
||||
output_schema: None,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
|
||||
@@ -7,6 +7,7 @@ mod fuzzy_file_search;
|
||||
mod interrupt;
|
||||
mod list_resume;
|
||||
mod login;
|
||||
mod output_schema;
|
||||
mod send_message;
|
||||
mod set_default_model;
|
||||
mod user_agent;
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::AddConversationListenerParams;
|
||||
use codex_app_server_protocol::InputItem;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::NewConversationParams;
|
||||
use codex_app_server_protocol::NewConversationResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SendUserTurnParams;
|
||||
use codex_app_server_protocol::SendUserTurnResponse;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_user_turn_accepts_output_schema_v1() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let response_mock = responses::mount_sse_once(&server, body).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let new_conv_id = mcp
|
||||
.send_new_conversation_request(NewConversationParams {
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let new_conv_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
|
||||
)
|
||||
.await??;
|
||||
let NewConversationResponse {
|
||||
conversation_id, ..
|
||||
} = to_response::<NewConversationResponse>(new_conv_resp)?;
|
||||
|
||||
let listener_id = mcp
|
||||
.send_add_conversation_listener_request(AddConversationListenerParams {
|
||||
conversation_id,
|
||||
experimental_raw_events: false,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(listener_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let output_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": { "type": "string" }
|
||||
},
|
||||
"required": ["answer"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
|
||||
let send_turn_id = mcp
|
||||
.send_send_user_turn_request(SendUserTurnParams {
|
||||
conversation_id,
|
||||
items: vec![InputItem::Text {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
cwd: codex_home.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: "mock-model".to_string(),
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
summary: ReasoningSummary::Auto,
|
||||
output_schema: Some(output_schema.clone()),
|
||||
})
|
||||
.await?;
|
||||
let _send_turn_resp: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("codex/event/task_complete"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let request = response_mock.single_request();
|
||||
let payload = request.body_json();
|
||||
let text = payload.get("text").expect("request missing text field");
|
||||
let format = text
|
||||
.get("format")
|
||||
.expect("request missing text.format field");
|
||||
assert_eq!(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"name": "codex_output_schema",
|
||||
"type": "json_schema",
|
||||
"strict": true,
|
||||
"schema": output_schema,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body1 = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let response_mock1 = responses::mount_sse_once(&server, body1).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let new_conv_id = mcp
|
||||
.send_new_conversation_request(NewConversationParams {
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let new_conv_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
|
||||
)
|
||||
.await??;
|
||||
let NewConversationResponse {
|
||||
conversation_id, ..
|
||||
} = to_response::<NewConversationResponse>(new_conv_resp)?;
|
||||
|
||||
let listener_id = mcp
|
||||
.send_add_conversation_listener_request(AddConversationListenerParams {
|
||||
conversation_id,
|
||||
experimental_raw_events: false,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(listener_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let output_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": { "type": "string" }
|
||||
},
|
||||
"required": ["answer"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
|
||||
let send_turn_id = mcp
|
||||
.send_send_user_turn_request(SendUserTurnParams {
|
||||
conversation_id,
|
||||
items: vec![InputItem::Text {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
cwd: codex_home.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: "mock-model".to_string(),
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
summary: ReasoningSummary::Auto,
|
||||
output_schema: Some(output_schema.clone()),
|
||||
})
|
||||
.await?;
|
||||
let _send_turn_resp: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("codex/event/task_complete"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let payload1 = response_mock1.single_request().body_json();
|
||||
assert_eq!(
|
||||
payload1.pointer("/text/format"),
|
||||
Some(&serde_json::json!({
|
||||
"name": "codex_output_schema",
|
||||
"type": "json_schema",
|
||||
"strict": true,
|
||||
"schema": output_schema,
|
||||
}))
|
||||
);
|
||||
|
||||
let body2 = responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_assistant_message("msg-2", "Done"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]);
|
||||
let response_mock2 = responses::mount_sse_once(&server, body2).await;
|
||||
|
||||
let send_turn_id_2 = mcp
|
||||
.send_send_user_turn_request(SendUserTurnParams {
|
||||
conversation_id,
|
||||
items: vec![InputItem::Text {
|
||||
text: "Hello again".to_string(),
|
||||
}],
|
||||
cwd: codex_home.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: "mock-model".to_string(),
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
summary: ReasoningSummary::Auto,
|
||||
output_schema: None,
|
||||
})
|
||||
.await?;
|
||||
let _send_turn_resp_2: SendUserTurnResponse = to_response::<SendUserTurnResponse>(
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id_2)),
|
||||
)
|
||||
.await??,
|
||||
)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("codex/event/task_complete"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let payload2 = response_mock2.single_request().body_json();
|
||||
assert_eq!(payload2.pointer("/text/format"), None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod account;
|
||||
mod config_rpc;
|
||||
mod model_list;
|
||||
mod output_schema;
|
||||
mod rate_limits;
|
||||
mod review;
|
||||
mod thread_archive;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_accepts_output_schema_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let response_mock = responses::mount_sse_once(&server, body).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let output_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": { "type": "string" }
|
||||
},
|
||||
"required": ["answer"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
output_schema: Some(output_schema.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let request = response_mock.single_request();
|
||||
let payload = request.body_json();
|
||||
let text = payload.get("text").expect("request missing text field");
|
||||
let format = text
|
||||
.get("format")
|
||||
.expect("request missing text.format field");
|
||||
assert_eq!(
|
||||
format,
|
||||
&serde_json::json!({
|
||||
"name": "codex_output_schema",
|
||||
"type": "json_schema",
|
||||
"strict": true,
|
||||
"schema": output_schema,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body1 = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let response_mock1 = responses::mount_sse_once(&server, body1).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let output_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": { "type": "string" }
|
||||
},
|
||||
"required": ["answer"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
|
||||
let turn_req_1 = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
output_schema: Some(output_schema.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp_1: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req_1)),
|
||||
)
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp_1)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let payload1 = response_mock1.single_request().body_json();
|
||||
assert_eq!(
|
||||
payload1.pointer("/text/format"),
|
||||
Some(&serde_json::json!({
|
||||
"name": "codex_output_schema",
|
||||
"type": "json_schema",
|
||||
"strict": true,
|
||||
"schema": output_schema,
|
||||
}))
|
||||
);
|
||||
|
||||
let body2 = responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_assistant_message("msg-2", "Done"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]);
|
||||
let response_mock2 = responses::mount_sse_once(&server, body2).await;
|
||||
|
||||
let turn_req_2 = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello again".to_string(),
|
||||
}],
|
||||
output_schema: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp_2: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req_2)),
|
||||
)
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp_2)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let payload2 = response_mock2.single_request().body_json();
|
||||
assert_eq!(payload2.pointer("/text/format"), None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -540,6 +540,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
model: Some("mock-model".to_string()),
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
summary: Some(ReasoningSummary::Auto),
|
||||
output_schema: None,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
@@ -566,6 +567,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
model: Some("mock-model".to_string()),
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
summary: Some(ReasoningSummary::Auto),
|
||||
output_schema: None,
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
|
||||
@@ -204,24 +204,16 @@ impl<'a> ChatRequestBuilder<'a> {
|
||||
call_id,
|
||||
..
|
||||
} => {
|
||||
let mut msg = json!({
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
}
|
||||
}]
|
||||
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
|
||||
let tool_call = json!({
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
}
|
||||
});
|
||||
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
|
||||
&& let Some(obj) = msg.as_object_mut()
|
||||
{
|
||||
obj.insert("reasoning".to_string(), json!(reasoning));
|
||||
}
|
||||
messages.push(msg);
|
||||
push_tool_call_message(&mut messages, tool_call, reasoning);
|
||||
}
|
||||
ResponseItem::LocalShellCall {
|
||||
id,
|
||||
@@ -229,22 +221,14 @@ impl<'a> ChatRequestBuilder<'a> {
|
||||
status,
|
||||
action,
|
||||
} => {
|
||||
let mut msg = json!({
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": id.clone().unwrap_or_default(),
|
||||
"type": "local_shell_call",
|
||||
"status": status,
|
||||
"action": action,
|
||||
}]
|
||||
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
|
||||
let tool_call = json!({
|
||||
"id": id.clone().unwrap_or_default(),
|
||||
"type": "local_shell_call",
|
||||
"status": status,
|
||||
"action": action,
|
||||
});
|
||||
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
|
||||
&& let Some(obj) = msg.as_object_mut()
|
||||
{
|
||||
obj.insert("reasoning".to_string(), json!(reasoning));
|
||||
}
|
||||
messages.push(msg);
|
||||
push_tool_call_message(&mut messages, tool_call, reasoning);
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { call_id, output } => {
|
||||
let content_value = if let Some(items) = &output.content_items {
|
||||
@@ -277,18 +261,16 @@ impl<'a> ChatRequestBuilder<'a> {
|
||||
input,
|
||||
status: _,
|
||||
} => {
|
||||
messages.push(json!({
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": id,
|
||||
"type": "custom",
|
||||
"custom": {
|
||||
"name": name,
|
||||
"input": input,
|
||||
}
|
||||
}]
|
||||
}));
|
||||
let tool_call = json!({
|
||||
"id": id,
|
||||
"type": "custom",
|
||||
"custom": {
|
||||
"name": name,
|
||||
"input": input,
|
||||
}
|
||||
});
|
||||
let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str);
|
||||
push_tool_call_message(&mut messages, tool_call, reasoning);
|
||||
}
|
||||
ResponseItem::CustomToolCallOutput { call_id, output } => {
|
||||
messages.push(json!({
|
||||
@@ -328,11 +310,50 @@ impl<'a> ChatRequestBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn push_tool_call_message(messages: &mut Vec<Value>, tool_call: Value, reasoning: Option<&str>) {
|
||||
// Chat Completions requires that tool calls are grouped into a single assistant message
|
||||
// (with `tool_calls: [...]`) followed by tool role responses.
|
||||
if let Some(Value::Object(obj)) = messages.last_mut()
|
||||
&& obj.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
&& obj.get("content").is_some_and(Value::is_null)
|
||||
&& let Some(tool_calls) = obj.get_mut("tool_calls").and_then(Value::as_array_mut)
|
||||
{
|
||||
tool_calls.push(tool_call);
|
||||
if let Some(reasoning) = reasoning {
|
||||
if let Some(Value::String(existing)) = obj.get_mut("reasoning") {
|
||||
if !existing.is_empty() {
|
||||
existing.push('\n');
|
||||
}
|
||||
existing.push_str(reasoning);
|
||||
} else {
|
||||
obj.insert(
|
||||
"reasoning".to_string(),
|
||||
Value::String(reasoning.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut msg = json!({
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [tool_call],
|
||||
});
|
||||
if let Some(reasoning) = reasoning
|
||||
&& let Some(obj) = msg.as_object_mut()
|
||||
{
|
||||
obj.insert("reasoning".to_string(), json!(reasoning));
|
||||
}
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::RetryConfig;
|
||||
use crate::provider::WireApi;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use http::HeaderValue;
|
||||
@@ -385,4 +406,89 @@ mod tests {
|
||||
Some(&HeaderValue::from_static("review"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_consecutive_tool_calls_into_a_single_assistant_message() {
|
||||
let prompt_input = vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "read these".to_string(),
|
||||
}],
|
||||
},
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "read_file".to_string(),
|
||||
arguments: r#"{"path":"a.txt"}"#.to_string(),
|
||||
call_id: "call-a".to_string(),
|
||||
},
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "read_file".to_string(),
|
||||
arguments: r#"{"path":"b.txt"}"#.to_string(),
|
||||
call_id: "call-b".to_string(),
|
||||
},
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "read_file".to_string(),
|
||||
arguments: r#"{"path":"c.txt"}"#.to_string(),
|
||||
call_id: "call-c".to_string(),
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-a".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "A".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-b".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "B".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-c".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
content: "C".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let req = ChatRequestBuilder::new("gpt-test", "inst", &prompt_input, &[])
|
||||
.build(&provider())
|
||||
.expect("request");
|
||||
|
||||
let messages = req
|
||||
.body
|
||||
.get("messages")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("messages array");
|
||||
// system + user + assistant(tool_calls=[...]) + 3 tool outputs
|
||||
assert_eq!(messages.len(), 6);
|
||||
|
||||
assert_eq!(messages[0]["role"], "system");
|
||||
assert_eq!(messages[1]["role"], "user");
|
||||
|
||||
let tool_calls_msg = &messages[2];
|
||||
assert_eq!(tool_calls_msg["role"], "assistant");
|
||||
assert_eq!(tool_calls_msg["content"], serde_json::Value::Null);
|
||||
let tool_calls = tool_calls_msg["tool_calls"]
|
||||
.as_array()
|
||||
.expect("tool_calls array");
|
||||
assert_eq!(tool_calls.len(), 3);
|
||||
assert_eq!(tool_calls[0]["id"], "call-a");
|
||||
assert_eq!(tool_calls[1]["id"], "call-b");
|
||||
assert_eq!(tool_calls[2]["id"], "call-c");
|
||||
|
||||
assert_eq!(messages[3]["role"], "tool");
|
||||
assert_eq!(messages[3]["tool_call_id"], "call-a");
|
||||
assert_eq!(messages[4]["role"], "tool");
|
||||
assert_eq!(messages[4]["tool_call_id"], "call-b");
|
||||
assert_eq!(messages[5]["role"], "tool");
|
||||
assert_eq!(messages[5]["tool_call_id"], "call-c");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,21 @@ pub(crate) fn spawn_chat_stream(
|
||||
ResponseStream { rx_event }
|
||||
}
|
||||
|
||||
/// Processes Server-Sent Events from the legacy Chat Completions streaming API.
|
||||
///
|
||||
/// The upstream protocol terminates a streaming response with a final sentinel event
|
||||
/// (`data: [DONE]`). Historically, some of our test stubs have emitted `data: DONE`
|
||||
/// (without brackets) instead.
|
||||
///
|
||||
/// `eventsource_stream` delivers these sentinels as regular events rather than signaling
|
||||
/// end-of-stream. If we try to parse them as JSON, we log and skip them, then keep
|
||||
/// polling for more events.
|
||||
///
|
||||
/// On servers that keep the HTTP connection open after emitting the sentinel (notably
|
||||
/// wiremock on Windows), skipping the sentinel means we never emit `ResponseEvent::Completed`.
|
||||
/// Higher-level workflows/tests that wait for completion before issuing subsequent model
|
||||
/// calls will then stall, which shows up as "expected N requests, got 1" verification
|
||||
/// failures in the mock server.
|
||||
pub async fn process_chat_sse<S>(
|
||||
stream: S,
|
||||
tx_event: mpsc::Sender<Result<ResponseEvent, ApiError>>,
|
||||
@@ -57,6 +72,31 @@ pub async fn process_chat_sse<S>(
|
||||
let mut reasoning_item: Option<ResponseItem> = None;
|
||||
let mut completed_sent = false;
|
||||
|
||||
async fn flush_and_complete(
|
||||
tx_event: &mpsc::Sender<Result<ResponseEvent, ApiError>>,
|
||||
reasoning_item: &mut Option<ResponseItem>,
|
||||
assistant_item: &mut Option<ResponseItem>,
|
||||
) {
|
||||
if let Some(reasoning) = reasoning_item.take() {
|
||||
let _ = tx_event
|
||||
.send(Ok(ResponseEvent::OutputItemDone(reasoning)))
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(assistant) = assistant_item.take() {
|
||||
let _ = tx_event
|
||||
.send(Ok(ResponseEvent::OutputItemDone(assistant)))
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = tx_event
|
||||
.send(Ok(ResponseEvent::Completed {
|
||||
response_id: String::new(),
|
||||
token_usage: None,
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
loop {
|
||||
let start = Instant::now();
|
||||
let response = timeout(idle_timeout, stream.next()).await;
|
||||
@@ -70,24 +110,8 @@ pub async fn process_chat_sse<S>(
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Some(reasoning) = reasoning_item {
|
||||
let _ = tx_event
|
||||
.send(Ok(ResponseEvent::OutputItemDone(reasoning)))
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(assistant) = assistant_item {
|
||||
let _ = tx_event
|
||||
.send(Ok(ResponseEvent::OutputItemDone(assistant)))
|
||||
.await;
|
||||
}
|
||||
if !completed_sent {
|
||||
let _ = tx_event
|
||||
.send(Ok(ResponseEvent::Completed {
|
||||
response_id: String::new(),
|
||||
token_usage: None,
|
||||
}))
|
||||
.await;
|
||||
flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -101,16 +125,25 @@ pub async fn process_chat_sse<S>(
|
||||
|
||||
trace!("SSE event: {}", sse.data);
|
||||
|
||||
if sse.data.trim().is_empty() {
|
||||
let data = sse.data.trim();
|
||||
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value: serde_json::Value = match serde_json::from_str(&sse.data) {
|
||||
if data == "[DONE]" || data == "DONE" {
|
||||
if !completed_sent {
|
||||
flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let value: serde_json::Value = match serde_json::from_str(data) {
|
||||
Ok(val) => val,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"Failed to parse ChatCompletions SSE event: {err}, data: {}",
|
||||
&sse.data
|
||||
data
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -362,6 +395,16 @@ mod tests {
|
||||
body
|
||||
}
|
||||
|
||||
/// Regression test: the stream should complete when we see a `[DONE]` sentinel.
|
||||
///
|
||||
/// This is important for tests/mocks that don't immediately close the underlying
|
||||
/// connection after emitting the sentinel.
|
||||
#[tokio::test]
|
||||
async fn completes_on_done_sentinel_without_json() {
|
||||
let events = collect_events("event: message\ndata: [DONE]\n\n").await;
|
||||
assert_matches!(&events[..], [ResponseEvent::Completed { .. }]);
|
||||
}
|
||||
|
||||
async fn collect_events(body: &str) -> Vec<ResponseEvent> {
|
||||
let reader = ReaderStream::new(std::io::Cursor::new(body.to_string()))
|
||||
.map_err(|err| codex_client::TransportError::Network(err.to_string()));
|
||||
|
||||
+119
-3
@@ -88,6 +88,7 @@ use crate::error::Result as CodexResult;
|
||||
#[cfg(test)]
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::exec_policy::ExecPolicyUpdateError;
|
||||
use crate::feedback_tags;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
|
||||
@@ -810,6 +811,13 @@ impl Session {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Seed usage info from the recorded rollout so UIs can show token counts
|
||||
// immediately on resume/fork.
|
||||
if let Some(info) = Self::last_token_info_from_rollout(&rollout_items) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_token_info(Some(info));
|
||||
}
|
||||
|
||||
// If persisting, persist all rollout items as-is (recorder filters)
|
||||
if persist && !rollout_items.is_empty() {
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
@@ -820,6 +828,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option<TokenUsageInfo> {
|
||||
rollout_items.iter().rev().find_map(|item| match item {
|
||||
RolloutItem::EventMsg(EventMsg::TokenCount(ev)) => ev.info.clone(),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn update_settings(
|
||||
&self,
|
||||
updates: SessionSettingsUpdate,
|
||||
@@ -1761,7 +1776,16 @@ mod handlers {
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
},
|
||||
),
|
||||
Op::UserInput { items } => (items, SessionSettingsUpdate::default()),
|
||||
Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema,
|
||||
} => (
|
||||
items,
|
||||
SessionSettingsUpdate {
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
@@ -2053,7 +2077,7 @@ mod handlers {
|
||||
review_request: ReviewRequest,
|
||||
) {
|
||||
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
|
||||
match resolve_review_request(review_request, config.cwd.as_path()) {
|
||||
match resolve_review_request(review_request, turn_context.cwd.as_path()) {
|
||||
Ok(resolved) => {
|
||||
spawn_review_thread(
|
||||
Arc::clone(sess),
|
||||
@@ -2525,6 +2549,15 @@ async fn try_run_turn(
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
});
|
||||
|
||||
feedback_tags!(
|
||||
model = turn_context.client.get_model(),
|
||||
approval_policy = turn_context.approval_policy,
|
||||
sandbox_policy = turn_context.sandbox_policy,
|
||||
effort = turn_context.client.get_reasoning_effort(),
|
||||
auth_mode = sess.services.auth_manager.get_auth_mode(),
|
||||
features = sess.features.enabled_features(),
|
||||
);
|
||||
|
||||
sess.persist_rollout_items(&[rollout_item]).await;
|
||||
let mut stream = turn_context
|
||||
.client
|
||||
@@ -2617,7 +2650,10 @@ async fn try_run_turn(
|
||||
}
|
||||
ResponseEvent::ModelsEtag(etag) => {
|
||||
// Update internal state with latest models etag
|
||||
sess.services.models_manager.refresh_if_new_etag(etag).await;
|
||||
sess.services
|
||||
.models_manager
|
||||
.refresh_if_new_etag(etag, sess.features.enabled(Feature::RemoteModels))
|
||||
.await;
|
||||
}
|
||||
ResponseEvent::Completed {
|
||||
response_id: _,
|
||||
@@ -2759,6 +2795,9 @@ mod tests {
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::RateLimitWindow;
|
||||
use crate::protocol::ResumedHistory;
|
||||
use crate::protocol::TokenCountEvent;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
@@ -2813,6 +2852,83 @@ mod tests {
|
||||
assert_eq!(expected, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_seeds_token_info_from_rollout() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (mut rollout_items, _expected) = sample_rollout(&session, &turn_context);
|
||||
|
||||
let info1 = TokenUsageInfo {
|
||||
total_token_usage: TokenUsage {
|
||||
input_tokens: 10,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 20,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 30,
|
||||
},
|
||||
last_token_usage: TokenUsage {
|
||||
input_tokens: 3,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 4,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 7,
|
||||
},
|
||||
model_context_window: Some(1_000),
|
||||
};
|
||||
let info2 = TokenUsageInfo {
|
||||
total_token_usage: TokenUsage {
|
||||
input_tokens: 100,
|
||||
cached_input_tokens: 50,
|
||||
output_tokens: 200,
|
||||
reasoning_output_tokens: 25,
|
||||
total_tokens: 375,
|
||||
},
|
||||
last_token_usage: TokenUsage {
|
||||
input_tokens: 10,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 20,
|
||||
reasoning_output_tokens: 5,
|
||||
total_tokens: 35,
|
||||
},
|
||||
model_context_window: Some(2_000),
|
||||
};
|
||||
|
||||
rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
|
||||
TokenCountEvent {
|
||||
info: Some(info1),
|
||||
rate_limits: None,
|
||||
},
|
||||
)));
|
||||
rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
|
||||
TokenCountEvent {
|
||||
info: None,
|
||||
rate_limits: None,
|
||||
},
|
||||
)));
|
||||
rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
|
||||
TokenCountEvent {
|
||||
info: Some(info2.clone()),
|
||||
rate_limits: None,
|
||||
},
|
||||
)));
|
||||
rollout_items.push(RolloutItem::EventMsg(EventMsg::TokenCount(
|
||||
TokenCountEvent {
|
||||
info: None,
|
||||
rate_limits: None,
|
||||
},
|
||||
)));
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: ConversationId::default(),
|
||||
history: rollout_items,
|
||||
rollout_path: PathBuf::from("/tmp/resume.jsonl"),
|
||||
}))
|
||||
.await;
|
||||
|
||||
let actual = session.state.lock().await.token_info();
|
||||
assert_eq!(actual, Some(info2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_forked_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -118,7 +118,11 @@ pub(crate) async fn run_codex_conversation_one_shot(
|
||||
.await?;
|
||||
|
||||
// Send the initial input to kick off the one-shot turn.
|
||||
io.submit(Op::UserInput { items: input }).await?;
|
||||
io.submit(Op::UserInput {
|
||||
items: input,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Bridge events so we can observe completion and shut down automatically.
|
||||
let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
@@ -184,6 +188,10 @@ async fn forward_events(
|
||||
id: _,
|
||||
msg: EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_),
|
||||
} => {}
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::TokenCount(_),
|
||||
} => {}
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::SessionConfigured(_),
|
||||
|
||||
@@ -2050,6 +2050,7 @@ trust_level = "trusted"
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(codex_home.path())?;
|
||||
@@ -2170,6 +2171,7 @@ trust_level = "trusted"
|
||||
managed_config_path: Some(managed_path),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(codex_home.path())?;
|
||||
|
||||
@@ -755,6 +755,7 @@ remote_compaction = true
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -835,6 +836,7 @@ remote_compaction = true
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -937,6 +939,7 @@ remote_compaction = true
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -984,6 +987,7 @@ remote_compaction = true
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1029,6 +1033,7 @@ remote_compaction = true
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -33,11 +33,13 @@ pub(super) async fn load_config_layers_internal(
|
||||
let LoaderOverrides {
|
||||
managed_config_path,
|
||||
managed_preferences_base64,
|
||||
..
|
||||
} = overrides;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let LoaderOverrides {
|
||||
managed_config_path,
|
||||
..
|
||||
} = overrides;
|
||||
|
||||
let managed_config_path = AbsolutePathBuf::from_absolute_path(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::config_requirements::ConfigRequirementsToml;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use core_foundation::base::TCFType;
|
||||
@@ -10,6 +11,7 @@ use toml::Value as TomlValue;
|
||||
|
||||
const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex";
|
||||
const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
|
||||
const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64";
|
||||
|
||||
pub(crate) async fn load_managed_admin_config_layer(
|
||||
override_base64: Option<&str>,
|
||||
@@ -19,82 +21,126 @@ pub(crate) async fn load_managed_admin_config_layer(
|
||||
return if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
parse_managed_preferences_base64(trimmed).map(Some)
|
||||
parse_managed_config_base64(trimmed).map(Some)
|
||||
};
|
||||
}
|
||||
|
||||
const LOAD_ERROR: &str = "Failed to load managed preferences configuration";
|
||||
|
||||
match task::spawn_blocking(load_managed_admin_config).await {
|
||||
Ok(result) => result,
|
||||
Err(join_err) => {
|
||||
if join_err.is_cancelled() {
|
||||
tracing::error!("Managed preferences load task was cancelled");
|
||||
tracing::error!("Managed config load task was cancelled");
|
||||
} else {
|
||||
tracing::error!("Managed preferences load task failed: {join_err}");
|
||||
tracing::error!("Managed config load task failed: {join_err}");
|
||||
}
|
||||
Err(io::Error::other(LOAD_ERROR))
|
||||
Err(io::Error::other("Failed to load managed config"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_managed_admin_config() -> io::Result<Option<TomlValue>> {
|
||||
load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(parse_managed_config_base64)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn load_managed_admin_requirements_toml(
|
||||
target: &mut ConfigRequirementsToml,
|
||||
override_base64: Option<&str>,
|
||||
) -> io::Result<()> {
|
||||
if let Some(encoded) = override_base64 {
|
||||
let trimmed = encoded.trim();
|
||||
if !trimmed.is_empty() {
|
||||
target.merge_unset_fields(parse_managed_requirements_base64(trimmed)?);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match task::spawn_blocking(load_managed_admin_requirements).await {
|
||||
Ok(result) => {
|
||||
if let Some(requirements) = result? {
|
||||
target.merge_unset_fields(requirements);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(join_err) => {
|
||||
if join_err.is_cancelled() {
|
||||
tracing::error!("Managed requirements load task was cancelled");
|
||||
} else {
|
||||
tracing::error!("Managed requirements load task failed: {join_err}");
|
||||
}
|
||||
Err(io::Error::other("Failed to load managed requirements"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_managed_admin_requirements() -> io::Result<Option<ConfigRequirementsToml>> {
|
||||
load_managed_preference(MANAGED_PREFERENCES_REQUIREMENTS_KEY)?
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(parse_managed_requirements_base64)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn load_managed_preference(key_name: &str) -> io::Result<Option<String>> {
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void;
|
||||
}
|
||||
|
||||
let application_id = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID);
|
||||
let key = CFString::new(MANAGED_PREFERENCES_CONFIG_KEY);
|
||||
|
||||
let value_ref = unsafe {
|
||||
CFPreferencesCopyAppValue(
|
||||
key.as_concrete_TypeRef(),
|
||||
application_id.as_concrete_TypeRef(),
|
||||
CFString::new(key_name).as_concrete_TypeRef(),
|
||||
CFString::new(MANAGED_PREFERENCES_APPLICATION_ID).as_concrete_TypeRef(),
|
||||
)
|
||||
};
|
||||
|
||||
if value_ref.is_null() {
|
||||
tracing::debug!(
|
||||
"Managed preferences for {} key {} not found",
|
||||
MANAGED_PREFERENCES_APPLICATION_ID,
|
||||
MANAGED_PREFERENCES_CONFIG_KEY
|
||||
"Managed preferences for {MANAGED_PREFERENCES_APPLICATION_ID} key {key_name} not found",
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) };
|
||||
let contents = value.to_string();
|
||||
let trimmed = contents.trim();
|
||||
|
||||
parse_managed_preferences_base64(trimmed).map(Some)
|
||||
let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) }.to_string();
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn parse_managed_preferences_base64(encoded: &str) -> io::Result<TomlValue> {
|
||||
let decoded = BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
|
||||
tracing::error!("Failed to decode managed preferences as base64: {err}");
|
||||
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||
})?;
|
||||
|
||||
let decoded_str = String::from_utf8(decoded).map_err(|err| {
|
||||
tracing::error!("Managed preferences base64 contents were not valid UTF-8: {err}");
|
||||
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||
})?;
|
||||
|
||||
match toml::from_str::<TomlValue>(&decoded_str) {
|
||||
fn parse_managed_config_base64(encoded: &str) -> io::Result<TomlValue> {
|
||||
match toml::from_str::<TomlValue>(&decode_managed_preferences_base64(encoded)?) {
|
||||
Ok(TomlValue::Table(parsed)) => Ok(TomlValue::Table(parsed)),
|
||||
Ok(other) => {
|
||||
tracing::error!(
|
||||
"Managed preferences TOML must have a table at the root, found {other:?}",
|
||||
);
|
||||
tracing::error!("Managed config TOML must have a table at the root, found {other:?}",);
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"managed preferences root must be a table",
|
||||
"managed config root must be a table",
|
||||
))
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to parse managed preferences TOML: {err}");
|
||||
tracing::error!("Failed to parse managed config TOML: {err}");
|
||||
Err(io::Error::new(io::ErrorKind::InvalidData, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_managed_requirements_base64(encoded: &str) -> io::Result<ConfigRequirementsToml> {
|
||||
toml::from_str::<ConfigRequirementsToml>(&decode_managed_preferences_base64(encoded)?).map_err(
|
||||
|err| {
|
||||
tracing::error!("Failed to parse managed requirements TOML: {err}");
|
||||
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn decode_managed_preferences_base64(encoded: &str) -> io::Result<String> {
|
||||
String::from_utf8(BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
|
||||
tracing::error!("Failed to decode managed value as base64: {err}",);
|
||||
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||
})?)
|
||||
.map_err(|err| {
|
||||
tracing::error!("Managed value base64 contents were not valid UTF-8: {err}",);
|
||||
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,8 +78,14 @@ pub async fn load_config_layers_state(
|
||||
) -> io::Result<ConfigLayerStack> {
|
||||
let mut config_requirements_toml = ConfigRequirementsToml::default();
|
||||
|
||||
// TODO(gt): Support an entry in MDM for config requirements and use it
|
||||
// with `config_requirements_toml.merge_unset_fields(...)`, if present.
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::load_managed_admin_requirements_toml(
|
||||
&mut config_requirements_toml,
|
||||
overrides
|
||||
.macos_managed_config_requirements_base64
|
||||
.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Honor /etc/codex/requirements.toml.
|
||||
if cfg!(unix) {
|
||||
@@ -101,8 +107,6 @@ pub async fn load_config_layers_state(
|
||||
|
||||
let mut layers = Vec::<ConfigLayerEntry>::new();
|
||||
|
||||
// TODO(gt): Honor managed preferences (macOS only).
|
||||
|
||||
// Include an entry for the "system" config folder, loading its config.toml,
|
||||
// if it exists.
|
||||
let system_config_toml_file = if cfg!(unix) {
|
||||
|
||||
@@ -12,11 +12,14 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
/// LoaderOverrides overrides managed configuration inputs (primarily for tests).
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct LoaderOverrides {
|
||||
pub managed_config_path: Option<PathBuf>,
|
||||
//TODO(gt): Add a macos_ prefix to this field and remove the target_os check.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub managed_preferences_base64: Option<String>,
|
||||
pub macos_managed_config_requirements_base64: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::config_loader::config_requirements::ConfigRequirementsToml;
|
||||
use crate::config_loader::fingerprint::version_for_toml;
|
||||
use crate::config_loader::load_requirements_toml;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
@@ -43,6 +45,7 @@ extra = true
|
||||
managed_config_path: Some(managed_path),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
|
||||
@@ -73,10 +76,12 @@ extra = true
|
||||
async fn returns_empty_when_all_layers_missing() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
|
||||
let overrides = LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
|
||||
@@ -141,12 +146,6 @@ async fn returns_empty_when_all_layers_missing() {
|
||||
async fn managed_preferences_take_highest_precedence() {
|
||||
use base64::Engine;
|
||||
|
||||
let managed_payload = r#"
|
||||
[nested]
|
||||
value = "managed"
|
||||
flag = false
|
||||
"#;
|
||||
let encoded = base64::prelude::BASE64_STANDARD.encode(managed_payload.as_bytes());
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
|
||||
@@ -168,7 +167,17 @@ flag = true
|
||||
|
||||
let overrides = LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
managed_preferences_base64: Some(encoded),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
[nested]
|
||||
value = "managed"
|
||||
flag = false
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
|
||||
@@ -192,6 +201,108 @@ flag = true
|
||||
assert_eq!(nested.get("flag"), Some(&TomlValue::Boolean(false)));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let tmp = tempdir()?;
|
||||
|
||||
let state = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
state.requirements().approval_policy.value(),
|
||||
AskForApproval::Never
|
||||
);
|
||||
assert_eq!(
|
||||
*state.requirements().sandbox_policy.get(),
|
||||
SandboxPolicy::ReadOnly
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.requirements()
|
||||
.approval_policy
|
||||
.can_set(&AskForApproval::OnRequest)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.requirements()
|
||||
.sandbox_policy
|
||||
.can_set(&SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let tmp = tempdir()?;
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
|
||||
tokio::fs::write(&managed_path, "approval_policy = \"on-request\"\n").await?;
|
||||
|
||||
let state = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
state.requirements().approval_policy.value(),
|
||||
AskForApproval::Never
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.requirements()
|
||||
.approval_policy
|
||||
.can_set(&AskForApproval::OnRequest)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn load_requirements_toml_produces_expected_constraints() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
@@ -255,6 +255,10 @@ impl Features {
|
||||
|
||||
features
|
||||
}
|
||||
|
||||
pub fn enabled_features(&self) -> Vec<Feature> {
|
||||
self.enabled.iter().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys accepted in `[features]` tables.
|
||||
|
||||
@@ -79,26 +79,60 @@ pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Default timeout for individual tool calls.
|
||||
const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
|
||||
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
|
||||
/// name we expose to the model by replacing any disallowed character with `_`.
|
||||
fn sanitize_responses_api_tool_name(name: &str) -> String {
|
||||
let mut sanitized = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
|
||||
sanitized.push(c);
|
||||
} else {
|
||||
sanitized.push('_');
|
||||
}
|
||||
}
|
||||
|
||||
if sanitized.is_empty() {
|
||||
"_".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn sha1_hex(s: &str) -> String {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(s.as_bytes());
|
||||
let sha1 = hasher.finalize();
|
||||
format!("{sha1:x}")
|
||||
}
|
||||
|
||||
fn qualify_tools<I>(tools: I) -> HashMap<String, ToolInfo>
|
||||
where
|
||||
I: IntoIterator<Item = ToolInfo>,
|
||||
{
|
||||
let mut used_names = HashSet::new();
|
||||
let mut seen_raw_names = HashSet::new();
|
||||
let mut qualified_tools = HashMap::new();
|
||||
for tool in tools {
|
||||
let mut qualified_name = format!(
|
||||
let qualified_name_raw = format!(
|
||||
"mcp{}{}{}{}",
|
||||
MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name
|
||||
);
|
||||
if !seen_raw_names.insert(qualified_name_raw.clone()) {
|
||||
warn!("skipping duplicated tool {}", qualified_name_raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start from a "pretty" name (sanitized), then deterministically disambiguate on
|
||||
// collisions by appending a hash of the *raw* (unsanitized) qualified name. This
|
||||
// ensures tools like `foo.bar` and `foo_bar` don't collapse to the same key.
|
||||
let mut qualified_name = sanitize_responses_api_tool_name(&qualified_name_raw);
|
||||
|
||||
// Enforce length constraints early; use the raw name for the hash input so the
|
||||
// output remains stable even when sanitization changes.
|
||||
if qualified_name.len() > MAX_TOOL_NAME_LENGTH {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(qualified_name.as_bytes());
|
||||
let sha1 = hasher.finalize();
|
||||
let sha1_str = format!("{sha1:x}");
|
||||
|
||||
// Truncate to make room for the hash suffix
|
||||
let sha1_str = sha1_hex(&qualified_name_raw);
|
||||
let prefix_len = MAX_TOOL_NAME_LENGTH - sha1_str.len();
|
||||
|
||||
qualified_name = format!("{}{}", &qualified_name[..prefix_len], sha1_str);
|
||||
}
|
||||
|
||||
@@ -1035,6 +1069,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qualify_tools_sanitizes_invalid_characters() {
|
||||
let tools = vec![create_test_tool("server.one", "tool.two")];
|
||||
|
||||
let qualified_tools = qualify_tools(tools);
|
||||
|
||||
assert_eq!(qualified_tools.len(), 1);
|
||||
let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool");
|
||||
assert_eq!(qualified_name, "mcp__server_one__tool_two");
|
||||
|
||||
// The key is sanitized for OpenAI, but we keep original parts for the actual MCP call.
|
||||
assert_eq!(tool.server_name, "server.one");
|
||||
assert_eq!(tool.tool_name, "tool.two");
|
||||
|
||||
assert!(
|
||||
qualified_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
|
||||
"qualified name must be Responses API compatible: {qualified_name:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_filter_allows_by_default() {
|
||||
let filter = ToolFilter::default();
|
||||
|
||||
@@ -91,11 +91,15 @@ impl ModelsManager {
|
||||
if self.try_load_cache().await {
|
||||
return Ok(());
|
||||
}
|
||||
self.refresh_available_models_no_cache().await
|
||||
self.refresh_available_models_no_cache(config.features.enabled(Feature::RemoteModels))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn refresh_available_models_no_cache(&self) -> CoreResult<()> {
|
||||
if self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey) {
|
||||
pub(crate) async fn refresh_available_models_no_cache(
|
||||
&self,
|
||||
remote_models_feature: bool,
|
||||
) -> CoreResult<()> {
|
||||
if !remote_models_feature || self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey) {
|
||||
return Ok(());
|
||||
}
|
||||
let auth = self.auth_manager.auth();
|
||||
@@ -175,12 +179,15 @@ impl ModelsManager {
|
||||
}
|
||||
OPENAI_DEFAULT_API_MODEL.to_string()
|
||||
}
|
||||
pub async fn refresh_if_new_etag(&self, etag: String) {
|
||||
pub async fn refresh_if_new_etag(&self, etag: String, remote_models_feature: bool) {
|
||||
let current_etag = self.get_etag().await;
|
||||
if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self.refresh_available_models_no_cache().await {
|
||||
if let Err(err) = self
|
||||
.refresh_available_models_no_cache(remote_models_feature)
|
||||
.await
|
||||
{
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,9 +513,9 @@ mod tests {
|
||||
)
|
||||
.unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md"));
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let expected = format!(
|
||||
"base doc\n\n## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}"
|
||||
"base doc\n\n## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n### How to use skills\n{usage_rules}"
|
||||
);
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
@@ -537,9 +537,9 @@ mod tests {
|
||||
dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path())
|
||||
.unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md"));
|
||||
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
|
||||
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let usage_rules = "- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
|
||||
let expected = format!(
|
||||
"## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}"
|
||||
"## Skills\nA skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.\n### Available skills\n- linting: run clippy (file: {expected_path_str})\n### How to use skills\n{usage_rules}"
|
||||
);
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("## Skills".to_string());
|
||||
lines.push("These skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.".to_string());
|
||||
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
|
||||
lines.push("### Available skills".to_string());
|
||||
|
||||
for skill in skills {
|
||||
let path_str = skill.path.to_string_lossy().replace('\\', "/");
|
||||
@@ -16,22 +17,22 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
lines.push(format!("- {name}: {description} (file: {path_str})"));
|
||||
}
|
||||
|
||||
lines.push("### How to use skills".to_string());
|
||||
lines.push(
|
||||
r###"- Discovery: Available skills are listed in project docs and may also appear in a runtime "## Skills" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.
|
||||
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
|
||||
r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
|
||||
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
|
||||
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
|
||||
- How to use a skill (progressive disclosure):
|
||||
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
|
||||
2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
|
||||
3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
|
||||
4) If `assets/` or templates exist, reuse them instead of recreating from scratch.
|
||||
- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.
|
||||
- Coordination and sequencing:
|
||||
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
|
||||
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
|
||||
- Context hygiene:
|
||||
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
|
||||
- Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.
|
||||
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
|
||||
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
|
||||
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
|
||||
.to_string(),
|
||||
|
||||
@@ -47,7 +47,7 @@ use super::session::OutputBuffer;
|
||||
use super::session::OutputHandles;
|
||||
use super::session::UnifiedExecSession;
|
||||
|
||||
const UNIFIED_EXEC_ENV: [(&str, &str); 8] = [
|
||||
const UNIFIED_EXEC_ENV: [(&str, &str); 9] = [
|
||||
("NO_COLOR", "1"),
|
||||
("TERM", "dumb"),
|
||||
("LANG", "C.UTF-8"),
|
||||
@@ -56,6 +56,7 @@ const UNIFIED_EXEC_ENV: [(&str, &str); 8] = [
|
||||
("COLORTERM", ""),
|
||||
("PAGER", "cat"),
|
||||
("GIT_PAGER", "cat"),
|
||||
("GH_PAGER", "cat"),
|
||||
];
|
||||
|
||||
fn apply_unified_exec_env(mut env: HashMap<String, String>) -> HashMap<String, String> {
|
||||
@@ -679,6 +680,7 @@ mod tests {
|
||||
("COLORTERM".to_string(), String::new()),
|
||||
("PAGER".to_string(), "cat".to_string()),
|
||||
("GIT_PAGER".to_string(), "cat".to_string()),
|
||||
("GH_PAGER".to_string(), "cat".to_string()),
|
||||
]);
|
||||
|
||||
assert_eq!(env, expected);
|
||||
|
||||
@@ -9,6 +9,31 @@ use tracing::error;
|
||||
const INITIAL_DELAY_MS: u64 = 200;
|
||||
const BACKOFF_FACTOR: f64 = 2.0;
|
||||
|
||||
/// Emit structured feedback metadata as key/value pairs.
|
||||
///
|
||||
/// This logs a tracing event with `target: "feedback_tags"`. If
|
||||
/// `codex_feedback::CodexFeedback::metadata_layer()` is installed, these fields are captured and
|
||||
/// later attached as tags when feedback is uploaded.
|
||||
///
|
||||
/// Values are wrapped with [`tracing::field::DebugValue`], so the expression only needs to
|
||||
/// implement [`std::fmt::Debug`].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// ```rust
|
||||
/// codex_core::feedback_tags!(model = "gpt-5", cached = true);
|
||||
/// codex_core::feedback_tags!(provider = provider_id, request_id = request_id);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! feedback_tags {
|
||||
($( $key:ident = $value:expr ),+ $(,)?) => {
|
||||
::tracing::info!(
|
||||
target: "feedback_tags",
|
||||
$( $key = ::tracing::field::debug(&$value) ),+
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn backoff(attempt: u64) -> Duration {
|
||||
let exp = BACKOFF_FACTOR.powi(attempt.saturating_sub(1) as i32);
|
||||
let base = (INITIAL_DELAY_MS as f64 * exp) as u64;
|
||||
@@ -74,4 +99,12 @@ mod tests {
|
||||
let message = try_parse_error_message(text);
|
||||
assert_eq!(message, r#"{"message": "test"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_tags_macro_compiles() {
|
||||
#[derive(Debug)]
|
||||
struct OnlyDebug;
|
||||
|
||||
feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "start sleep".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -101,6 +102,7 @@ async fn interrupt_tool_records_history_entries() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "start history recording".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -117,6 +119,7 @@ async fn interrupt_tool_records_history_entries() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "follow up".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -290,6 +290,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -365,6 +366,7 @@ async fn includes_conversation_id_and_model_headers_in_request() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -424,6 +426,7 @@ async fn includes_base_instructions_override_in_request() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -488,6 +491,7 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -582,6 +586,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -622,6 +627,7 @@ async fn includes_user_instructions_message_in_request() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -692,6 +698,7 @@ async fn skills_append_to_instructions() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -741,6 +748,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -777,6 +785,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -811,6 +820,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_fami
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -849,6 +859,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -887,6 +898,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -919,6 +931,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -958,6 +971,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -996,6 +1010,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1050,6 +1065,7 @@ async fn includes_developer_instructions_message_in_request() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1280,6 +1296,7 @@ async fn token_count_includes_rate_limits_snapshot() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1437,6 +1454,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.expect("submission should succeed while emitting usage limit error events");
|
||||
@@ -1506,6 +1524,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res
|
||||
items: vec![UserInput::Text {
|
||||
text: "seed turn".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -1516,6 +1535,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res
|
||||
items: vec![UserInput::Text {
|
||||
text: "trigger context window".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -1635,6 +1655,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1717,6 +1738,7 @@ async fn env_var_overrides_loaded_auth() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1800,6 +1822,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: "U1".into() }],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1809,6 +1832,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: "U2".into() }],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1818,6 +1842,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: "U3".into() }],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -8,11 +8,14 @@ use codex_core::compact::SUMMARIZATION_PROMPT;
|
||||
use codex_core::compact::SUMMARY_PREFIX;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::RolloutItem;
|
||||
use codex_core::protocol::RolloutLine;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::WarningEvent;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::responses::ev_local_shell_call;
|
||||
@@ -158,6 +161,7 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello world".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -178,6 +182,7 @@ async fn summarize_context_three_requests_and_instructions() {
|
||||
items: vec![UserInput::Text {
|
||||
text: THIRD_USER_MSG.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -577,6 +582,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
|
||||
items: vec![UserInput::Text {
|
||||
text: user_message.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.expect("submit user input");
|
||||
@@ -1081,6 +1087,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
items: vec![UserInput::Text {
|
||||
text: FIRST_AUTO_MSG.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1092,6 +1099,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
items: vec![UserInput::Text {
|
||||
text: SECOND_AUTO_MSG.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1103,6 +1111,7 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
items: vec![UserInput::Text {
|
||||
text: POST_AUTO_USER_MSG.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1228,6 +1237,117 @@ async fn auto_compact_runs_after_token_limit_hit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let limit = 200_000;
|
||||
let over_limit_tokens = 250_000;
|
||||
let remote_summary = "REMOTE_COMPACT_SUMMARY";
|
||||
|
||||
let compacted_history = vec![
|
||||
codex_protocol::models::ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![codex_protocol::models::ContentItem::OutputText {
|
||||
text: remote_summary.to_string(),
|
||||
}],
|
||||
},
|
||||
codex_protocol::models::ResponseItem::Compaction {
|
||||
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
|
||||
},
|
||||
];
|
||||
let compact_mock =
|
||||
mount_compact_json_once(&server, serde_json::json!({ "output": compacted_history })).await;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
set_test_compact_prompt(config);
|
||||
config.model_auto_compact_token_limit = Some(limit);
|
||||
config.features.enable(Feature::RemoteCompaction);
|
||||
});
|
||||
let initial = builder.build(&server).await.unwrap();
|
||||
let home = initial.home.clone();
|
||||
let rollout_path = initial.session_configured.rollout_path.clone();
|
||||
|
||||
// A single over-limit completion should not auto-compact until the next user message.
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("m1", FIRST_REPLY),
|
||||
ev_completed_with_tokens("r1", over_limit_tokens),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
initial.submit_turn("OVER_LIMIT_TURN").await.unwrap();
|
||||
|
||||
assert!(
|
||||
compact_mock.requests().is_empty(),
|
||||
"remote compaction should not run before the next user message"
|
||||
);
|
||||
|
||||
let mut resume_builder = test_codex().with_config(move |config| {
|
||||
set_test_compact_prompt(config);
|
||||
config.model_auto_compact_token_limit = Some(limit);
|
||||
config.features.enable(Feature::RemoteCompaction);
|
||||
});
|
||||
let resumed = resume_builder
|
||||
.resume(&server, home, rollout_path)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let follow_up_user = "AFTER_RESUME_USER";
|
||||
let sse_follow_up = sse(vec![
|
||||
ev_assistant_message("m2", FINAL_REPLY),
|
||||
ev_completed("r2"),
|
||||
]);
|
||||
|
||||
let follow_up_matcher = move |req: &wiremock::Request| {
|
||||
let body = std::str::from_utf8(&req.body).unwrap_or("");
|
||||
body.contains(follow_up_user) && body.contains(remote_summary)
|
||||
};
|
||||
mount_sse_once_match(&server, follow_up_matcher, sse_follow_up).await;
|
||||
|
||||
resumed
|
||||
.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: follow_up_user.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: resumed.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: resumed.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&resumed.codex, |event| {
|
||||
matches!(event, EventMsg::ContextCompacted(_))
|
||||
})
|
||||
.await;
|
||||
wait_for_event(&resumed.codex, |event| {
|
||||
matches!(event, EventMsg::TaskComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let compact_requests = compact_mock.requests();
|
||||
assert_eq!(
|
||||
compact_requests.len(),
|
||||
1,
|
||||
"remote compaction should run once after resume"
|
||||
);
|
||||
assert_eq!(
|
||||
compact_requests[0].path(),
|
||||
"/v1/responses/compact",
|
||||
"remote compaction should hit the compact endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn auto_compact_persists_rollout_entries() {
|
||||
skip_if_no_network!();
|
||||
@@ -1304,6 +1424,7 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
items: vec![UserInput::Text {
|
||||
text: FIRST_AUTO_MSG.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1314,6 +1435,7 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
items: vec![UserInput::Text {
|
||||
text: SECOND_AUTO_MSG.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1324,6 +1446,7 @@ async fn auto_compact_persists_rollout_entries() {
|
||||
items: vec![UserInput::Text {
|
||||
text: POST_AUTO_USER_MSG.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1415,6 +1538,7 @@ async fn manual_compact_retries_after_context_window_error() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "first turn".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1547,6 +1671,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
|
||||
items: vec![UserInput::Text {
|
||||
text: first_user_message.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1560,6 +1685,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
|
||||
items: vec![UserInput::Text {
|
||||
text: second_user_message.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1573,6 +1699,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() {
|
||||
items: vec![UserInput::Text {
|
||||
text: final_user_message.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1752,6 +1879,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: user.into() }],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1864,6 +1992,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
|
||||
items: vec![UserInput::Text {
|
||||
text: FUNCTION_CALL_LIMIT_MSG.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1875,6 +2004,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
|
||||
items: vec![UserInput::Text {
|
||||
text: follow_up_user.into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1989,6 +2119,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: user.into() }],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello remote compact".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
@@ -86,6 +87,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "after compact".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
@@ -191,6 +193,7 @@ async fn remote_compact_runs_automatically() -> Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello remote compact".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
let message = wait_for_event_match(&codex, |ev| match ev {
|
||||
@@ -263,6 +266,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()>
|
||||
items: vec![UserInput::Text {
|
||||
text: "needs compaction".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
@@ -884,6 +884,7 @@ async fn user_turn(conversation: &Arc<CodexConversation>, text: &str) {
|
||||
conversation
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: text.into() }],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.expect("submit user turn");
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn fork_conversation_twice_drops_to_first_message() {
|
||||
items: vec![UserInput::Text {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -43,6 +43,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> {
|
||||
items: (vec![UserInput::Text {
|
||||
text: "please inspect sample.txt".into(),
|
||||
}]),
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -99,6 +100,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please summarize results".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -155,6 +157,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "explain your reasoning".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -213,6 +216,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "find the weather".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -265,6 +269,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please stream text".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -330,6 +335,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "reason through it".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -387,6 +393,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "show raw reasoning".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ async fn responses_api_emits_api_request_event() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -87,6 +88,7 @@ async fn process_sse_emits_tracing_for_output_item() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -125,6 +127,7 @@ async fn process_sse_emits_failed_event_on_parse_error() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -164,6 +167,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed()
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -223,6 +227,7 @@ async fn process_sse_failed_event_records_response_error_message() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -280,6 +285,7 @@ async fn process_sse_failed_event_logs_parse_error() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -324,6 +330,7 @@ async fn process_sse_failed_event_logs_missing_error() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -377,6 +384,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -427,6 +435,7 @@ async fn process_sse_emits_completed_telemetry() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -494,6 +503,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -558,6 +568,7 @@ async fn record_responses_sets_span_fields_for_response_events() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -637,6 +648,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -704,6 +716,7 @@ async fn handle_response_item_records_tool_result_for_function_call() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -781,6 +794,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -842,6 +856,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -946,6 +961,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -994,6 +1010,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "approved".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1052,6 +1069,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
|
||||
items: vec![UserInput::Text {
|
||||
text: "persist".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1110,6 +1128,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "retry".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1168,6 +1187,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "deny".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1226,6 +1246,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
|
||||
items: vec![UserInput::Text {
|
||||
text: "persist".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1285,6 +1306,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "deny".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -102,6 +102,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
@@ -111,6 +112,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
@@ -175,6 +177,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -184,6 +187,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -238,6 +242,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
@@ -247,6 +252,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
@@ -307,6 +313,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
@@ -334,6 +341,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 2".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
@@ -412,6 +420,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul
|
||||
items: vec![UserInput::Text {
|
||||
text: "first message".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -504,6 +513,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello 1".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
@@ -44,6 +44,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "quota?".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -37,6 +37,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> {
|
||||
items: vec![UserInput::Text {
|
||||
text: "Record some messages".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -89,6 +90,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()>
|
||||
items: vec![UserInput::Text {
|
||||
text: "Record reasoning messages".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -665,6 +665,7 @@ async fn review_history_surfaces_in_parent_session() {
|
||||
items: vec![UserInput::Text {
|
||||
text: followup.clone(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -709,6 +710,105 @@ async fn review_history_surfaces_in_parent_session() {
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
/// `/review` should use the session's current cwd (including runtime overrides)
|
||||
/// when resolving base-branch review prompts (merge-base computation).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_uses_overridden_cwd_for_base_branch_merge_base() {
|
||||
skip_if_no_network!();
|
||||
|
||||
let sse_raw = r#"[{"type":"response.completed", "response": {"id": "__ID__"}}]"#;
|
||||
let server = start_responses_server_with_sse(sse_raw, 1).await;
|
||||
|
||||
let initial_cwd = TempDir::new().unwrap();
|
||||
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo_path = repo_dir.path();
|
||||
|
||||
fn run_git(repo_path: &std::path::Path, args: &[&str]) {
|
||||
let output = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("spawn git");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {:?} failed: stdout={:?} stderr={:?}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
run_git(repo_path, &["init", "-b", "main"]);
|
||||
run_git(repo_path, &["config", "user.email", "test@example.com"]);
|
||||
run_git(repo_path, &["config", "user.name", "Test User"]);
|
||||
std::fs::write(repo_path.join("file.txt"), "hello\n").unwrap();
|
||||
run_git(repo_path, &["add", "."]);
|
||||
run_git(repo_path, &["commit", "-m", "initial"]);
|
||||
|
||||
let head_sha = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.expect("rev-parse HEAD");
|
||||
assert!(head_sha.status.success());
|
||||
let head_sha = String::from_utf8(head_sha.stdout)
|
||||
.expect("utf8 sha")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let codex = new_conversation_for_server(&server, &codex_home, |config| {
|
||||
config.cwd = initial_cwd.path().to_path_buf();
|
||||
})
|
||||
.await;
|
||||
|
||||
codex
|
||||
.submit(Op::OverrideTurnContext {
|
||||
cwd: Some(repo_path.to_path_buf()),
|
||||
approval_policy: None,
|
||||
sandbox_policy: None,
|
||||
model: None,
|
||||
effort: None,
|
||||
summary: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
codex
|
||||
.submit(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
target: ReviewTarget::BaseBranch {
|
||||
branch: "main".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await;
|
||||
let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = get_responses_requests(&server).await;
|
||||
assert_eq!(requests.len(), 1);
|
||||
let body = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
let input = body["input"].as_array().expect("input array");
|
||||
|
||||
let saw_merge_base_sha = input
|
||||
.iter()
|
||||
.filter_map(|msg| msg["content"][0]["text"].as_str())
|
||||
.any(|text| text.contains(&head_sha));
|
||||
assert!(
|
||||
saw_merge_base_sha,
|
||||
"expected review prompt to include merge-base sha {head_sha}"
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
/// Start a mock Responses API server and mount the given SSE stream body.
|
||||
async fn start_responses_server_with_sse(sse_raw: &str, expected_requests: usize) -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -89,6 +89,7 @@ async fn continue_after_stream_error() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "first message".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -106,6 +107,7 @@ async fn continue_after_stream_error() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "follow up".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -96,6 +96,7 @@ async fn retries_on_early_close() {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -61,6 +61,7 @@ echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#,
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello world".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
@@ -70,7 +70,9 @@ Response: `{ conversationId, model, reasoningEffort?, rolloutPath }`
|
||||
Send input to the active turn:
|
||||
|
||||
- `sendUserMessage` → enqueue items to the conversation
|
||||
- `sendUserTurn` → structured turn with explicit `cwd`, `approvalPolicy`, `sandboxPolicy`, `model`, optional `effort`, and `summary`
|
||||
- `sendUserTurn` → structured turn with explicit `cwd`, `approvalPolicy`, `sandboxPolicy`, `model`, optional `effort`, `summary`, and optional `outputSchema` (JSON Schema for the final assistant message)
|
||||
|
||||
For v2 threads, `turn/start` also accepts `outputSchema` to constrain the final assistant message for that turn.
|
||||
|
||||
Interrupt a running turn: `interruptConversation`.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ license.workspace = true
|
||||
anyhow = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
sentry = { version = "0.46" }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::btree_map::Entry;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{self};
|
||||
@@ -11,12 +13,20 @@ use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use tracing::Event;
|
||||
use tracing::Level;
|
||||
use tracing::field::Visit;
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::filter::Targets;
|
||||
use tracing_subscriber::fmt::writer::MakeWriter;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB
|
||||
const SENTRY_DSN: &str =
|
||||
"https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458";
|
||||
const UPLOAD_TIMEOUT_SECS: u64 = 10;
|
||||
const FEEDBACK_TAGS_TARGET: &str = "feedback_tags";
|
||||
const MAX_FEEDBACK_TAGS: usize = 64;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CodexFeedback {
|
||||
@@ -46,13 +56,50 @@ impl CodexFeedback {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [`tracing_subscriber`] layer that captures full-fidelity logs into this feedback
|
||||
/// ring buffer.
|
||||
///
|
||||
/// This is intended for initialization code so call sites don't have to duplicate the exact
|
||||
/// `fmt::layer()` configuration and filter logic.
|
||||
pub fn logger_layer<S>(&self) -> impl Layer<S> + Send + Sync + 'static
|
||||
where
|
||||
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(self.make_writer())
|
||||
.with_ansi(false)
|
||||
.with_target(false)
|
||||
// Capture everything, regardless of the caller's `RUST_LOG`, so feedback includes the
|
||||
// full trace when the user uploads a report.
|
||||
.with_filter(Targets::new().with_default(Level::TRACE))
|
||||
}
|
||||
|
||||
/// Returns a [`tracing_subscriber`] layer that collects structured metadata for feedback.
|
||||
///
|
||||
/// Events with `target: "feedback_tags"` are treated as key/value tags to attach to feedback
|
||||
/// uploads later.
|
||||
pub fn metadata_layer<S>(&self) -> impl Layer<S> + Send + Sync + 'static
|
||||
where
|
||||
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
FeedbackMetadataLayer {
|
||||
inner: self.inner.clone(),
|
||||
}
|
||||
.with_filter(Targets::new().with_target(FEEDBACK_TAGS_TARGET, Level::TRACE))
|
||||
}
|
||||
|
||||
pub fn snapshot(&self, session_id: Option<ConversationId>) -> CodexLogSnapshot {
|
||||
let bytes = {
|
||||
let guard = self.inner.ring.lock().expect("mutex poisoned");
|
||||
guard.snapshot_bytes()
|
||||
};
|
||||
let tags = {
|
||||
let guard = self.inner.tags.lock().expect("mutex poisoned");
|
||||
guard.clone()
|
||||
};
|
||||
CodexLogSnapshot {
|
||||
bytes,
|
||||
tags,
|
||||
thread_id: session_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or("no-active-thread-".to_string() + &ConversationId::new().to_string()),
|
||||
@@ -62,12 +109,14 @@ impl CodexFeedback {
|
||||
|
||||
struct FeedbackInner {
|
||||
ring: Mutex<RingBuffer>,
|
||||
tags: Mutex<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
impl FeedbackInner {
|
||||
fn new(max_bytes: usize) -> Self {
|
||||
Self {
|
||||
ring: Mutex::new(RingBuffer::new(max_bytes)),
|
||||
tags: Mutex::new(BTreeMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +201,7 @@ impl RingBuffer {
|
||||
|
||||
pub struct CodexLogSnapshot {
|
||||
bytes: Vec<u8>,
|
||||
tags: BTreeMap<String, String>,
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
@@ -212,6 +262,22 @@ impl CodexLogSnapshot {
|
||||
tags.insert(String::from("reason"), r.to_string());
|
||||
}
|
||||
|
||||
let reserved = [
|
||||
"thread_id",
|
||||
"classification",
|
||||
"cli_version",
|
||||
"session_source",
|
||||
"reason",
|
||||
];
|
||||
for (key, value) in &self.tags {
|
||||
if reserved.contains(&key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Entry::Vacant(entry) = tags.entry(key.clone()) {
|
||||
entry.insert(value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let level = match classification {
|
||||
"bug" | "bad_result" => Level::Error,
|
||||
_ => Level::Info,
|
||||
@@ -280,9 +346,80 @@ fn display_classification(classification: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FeedbackMetadataLayer {
|
||||
inner: Arc<FeedbackInner>,
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for FeedbackMetadataLayer
|
||||
where
|
||||
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
|
||||
// This layer is filtered by `Targets`, but keep the guard anyway in case it is used without
|
||||
// the filter.
|
||||
if event.metadata().target() != FEEDBACK_TAGS_TARGET {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut visitor = FeedbackTagsVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
if visitor.tags.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut guard = self.inner.tags.lock().expect("mutex poisoned");
|
||||
for (key, value) in visitor.tags {
|
||||
if guard.len() >= MAX_FEEDBACK_TAGS && !guard.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
guard.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FeedbackTagsVisitor {
|
||||
tags: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Visit for FeedbackTagsVisitor {
|
||||
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
|
||||
self.tags
|
||||
.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
|
||||
self.tags
|
||||
.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
|
||||
self.tags
|
||||
.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
|
||||
self.tags
|
||||
.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
self.tags
|
||||
.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
self.tags
|
||||
.insert(field.name().to_string(), format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
#[test]
|
||||
fn ring_buffer_drops_front_when_full() {
|
||||
@@ -296,4 +433,18 @@ mod tests {
|
||||
// Capacity 8: after writing 10 bytes, we should keep the last 8.
|
||||
pretty_assertions::assert_eq!(std::str::from_utf8(snap.as_bytes()).unwrap(), "cdefghij");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_layer_records_tags_from_feedback_target() {
|
||||
let fb = CodexFeedback::new();
|
||||
let _guard = tracing_subscriber::registry()
|
||||
.with(fb.metadata_layer())
|
||||
.set_default();
|
||||
|
||||
tracing::info!(target: FEEDBACK_TAGS_TARGET, model = "gpt-5", cached = true, "tags");
|
||||
|
||||
let snap = fb.snapshot(None);
|
||||
pretty_assertions::assert_eq!(snap.tags.get("model").map(String::as_str), Some("gpt-5"));
|
||||
pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,26 +137,27 @@ async fn poll_for_token(
|
||||
}
|
||||
}
|
||||
|
||||
fn print_device_code_prompt(code: &str) {
|
||||
fn print_device_code_prompt(code: &str, issuer_base_url: &str) {
|
||||
println!(
|
||||
"\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\
|
||||
\nFollow these steps to sign in with ChatGPT using device code authorization:\n\
|
||||
\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\
|
||||
\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}{issuer_base_url}/codex/device{ANSI_RESET}\n\
|
||||
\n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\
|
||||
\n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n",
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
code = code
|
||||
code = code,
|
||||
issuer_base_url = issuer_base_url
|
||||
);
|
||||
}
|
||||
|
||||
/// Full device code login flow.
|
||||
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let base_url = opts.issuer.trim_end_matches('/');
|
||||
let api_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/'));
|
||||
let issuer_base_url = opts.issuer.trim_end_matches('/');
|
||||
let api_base_url = format!("{issuer_base_url}/api/accounts");
|
||||
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
|
||||
|
||||
print_device_code_prompt(&uc.user_code);
|
||||
print_device_code_prompt(&uc.user_code, issuer_base_url);
|
||||
|
||||
let code_resp = poll_for_token(
|
||||
&client,
|
||||
@@ -171,10 +172,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
||||
code_verifier: code_resp.code_verifier,
|
||||
code_challenge: code_resp.code_challenge,
|
||||
};
|
||||
let redirect_uri = format!("{base_url}/deviceauth/callback");
|
||||
let redirect_uri = format!("{issuer_base_url}/deviceauth/callback");
|
||||
|
||||
let tokens = crate::server::exchange_code_for_tokens(
|
||||
base_url,
|
||||
issuer_base_url,
|
||||
&opts.client_id,
|
||||
&redirect_uri,
|
||||
&pkce,
|
||||
|
||||
@@ -94,6 +94,7 @@ pub async fn run_codex_tool_session(
|
||||
items: vec![UserInput::Text {
|
||||
text: initial_prompt.clone(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -128,6 +129,7 @@ pub async fn run_codex_tool_session_reply(
|
||||
if let Err(e) = conversation
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text { text: prompt }],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -44,11 +44,6 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
strum_macros = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tonic = { workspace = true, features = [
|
||||
"transport",
|
||||
"tls-native-roots",
|
||||
"tls-ring",
|
||||
] }
|
||||
tracing = { workspace = true }
|
||||
tracing-opentelemetry = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
@@ -70,7 +70,7 @@ impl OtelManager {
|
||||
let session_span = trace_span!("new_session", conversation_id = %conversation_id, session_source = %session_source);
|
||||
|
||||
if let Some(context) = traceparent_context_from_env() {
|
||||
session_span.set_parent(context);
|
||||
let _ = session_span.set_parent(context);
|
||||
}
|
||||
|
||||
Self {
|
||||
|
||||
@@ -22,6 +22,10 @@ use opentelemetry_otlp::SpanExporter;
|
||||
use opentelemetry_otlp::WithExportConfig;
|
||||
use opentelemetry_otlp::WithHttpConfig;
|
||||
use opentelemetry_otlp::WithTonicConfig;
|
||||
use opentelemetry_otlp::tonic_types::metadata::MetadataMap;
|
||||
use opentelemetry_otlp::tonic_types::transport::Certificate as TonicCertificate;
|
||||
use opentelemetry_otlp::tonic_types::transport::ClientTlsConfig;
|
||||
use opentelemetry_otlp::tonic_types::transport::Identity as TonicIdentity;
|
||||
use opentelemetry_sdk::Resource;
|
||||
use opentelemetry_sdk::logs::SdkLoggerProvider;
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
@@ -44,10 +48,6 @@ use std::io::{self};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use tonic::metadata::MetadataMap;
|
||||
use tonic::transport::Certificate as TonicCertificate;
|
||||
use tonic::transport::ClientTlsConfig;
|
||||
use tonic::transport::Identity as TonicIdentity;
|
||||
use tracing::debug;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing::warn;
|
||||
@@ -102,7 +102,7 @@ impl OtelProvider {
|
||||
.map(|provider| provider.tracer(settings.service_name.clone()));
|
||||
|
||||
if let Some(provider) = tracer_provider.clone() {
|
||||
let _ = global::set_tracer_provider(provider);
|
||||
global::set_tracer_provider(provider);
|
||||
global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
}
|
||||
if tracer.is_some() {
|
||||
|
||||
@@ -74,6 +74,9 @@ pub enum Op {
|
||||
UserInput {
|
||||
/// User input items, see `InputItem`
|
||||
items: Vec<UserInput>,
|
||||
/// Optional JSON Schema used to constrain the final assistant message for this turn.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
final_output_json_schema: Option<Value>,
|
||||
},
|
||||
|
||||
/// Similar to [`Op::UserInput`], but contains additional context required
|
||||
@@ -844,7 +847,7 @@ pub struct TaskStartedEvent {
|
||||
pub model_context_window: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, JsonSchema, TS)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct TokenUsage {
|
||||
#[ts(type = "number")]
|
||||
pub input_tokens: i64,
|
||||
@@ -858,7 +861,7 @@ pub struct TokenUsage {
|
||||
pub total_tokens: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct TokenUsageInfo {
|
||||
pub total_token_usage: TokenUsage,
|
||||
pub last_token_usage: TokenUsage,
|
||||
@@ -1928,6 +1931,62 @@ mod tests {
|
||||
assert!(event.as_legacy_events(false).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> {
|
||||
let op = Op::UserInput {
|
||||
items: Vec::new(),
|
||||
final_output_json_schema: None,
|
||||
};
|
||||
|
||||
let json_op = serde_json::to_value(op)?;
|
||||
assert_eq!(json_op, json!({ "type": "user_input", "items": [] }));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_deserializes_without_final_output_json_schema_field() -> Result<()> {
|
||||
let op: Op = serde_json::from_value(json!({ "type": "user_input", "items": [] }))?;
|
||||
|
||||
assert_eq!(
|
||||
op,
|
||||
Op::UserInput {
|
||||
items: Vec::new(),
|
||||
final_output_json_schema: None,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_serialization_includes_final_output_json_schema_when_some() -> Result<()> {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": { "type": "string" }
|
||||
},
|
||||
"required": ["answer"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
let op = Op::UserInput {
|
||||
items: Vec::new(),
|
||||
final_output_json_schema: Some(schema.clone()),
|
||||
};
|
||||
|
||||
let json_op = serde_json::to_value(op)?;
|
||||
assert_eq!(
|
||||
json_op,
|
||||
json!({
|
||||
"type": "user_input",
|
||||
"items": [],
|
||||
"final_output_json_schema": schema,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize Event to verify that its JSON representation has the expected
|
||||
/// amount of nesting.
|
||||
#[test]
|
||||
|
||||
@@ -1985,7 +1985,10 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
self.codex_op_tx
|
||||
.send(Op::UserInput { items })
|
||||
.send(Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("failed to send message: {e}");
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ use std::path::PathBuf;
|
||||
use tracing::error;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::filter::Targets;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
mod additional_dirs;
|
||||
@@ -282,13 +281,8 @@ pub async fn run_main(
|
||||
.with_filter(env_filter());
|
||||
|
||||
let feedback = codex_feedback::CodexFeedback::new();
|
||||
let targets = Targets::new().with_default(tracing::Level::TRACE);
|
||||
|
||||
let feedback_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(feedback.make_writer())
|
||||
.with_ansi(false)
|
||||
.with_target(false)
|
||||
.with_filter(targets);
|
||||
let feedback_layer = feedback.logger_layer();
|
||||
let feedback_metadata_layer = feedback.metadata_layer();
|
||||
|
||||
if cli.oss && model_provider_override.is_some() {
|
||||
// We're in the oss section, so provider_id should be Some
|
||||
@@ -323,6 +317,7 @@ pub async fn run_main(
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(file_layer)
|
||||
.with(feedback_layer)
|
||||
.with(feedback_metadata_layer)
|
||||
.with(otel_logger_layer)
|
||||
.with(otel_tracing_layer)
|
||||
.try_init();
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
use crate::color::perceptual_distance;
|
||||
use ratatui::style::Color;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
static DEFAULT_PALETTE_VERSION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn bump_palette_version() {
|
||||
DEFAULT_PALETTE_VERSION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Returns the closest color to the target color that the terminal can display.
|
||||
pub fn best_color(target: (u8, u8, u8)) -> Color {
|
||||
@@ -27,6 +35,7 @@ pub fn best_color(target: (u8, u8, u8)) -> Color {
|
||||
|
||||
pub fn requery_default_colors() {
|
||||
imp::requery_default_colors();
|
||||
bump_palette_version();
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -47,6 +56,14 @@ pub fn default_bg() -> Option<(u8, u8, u8)> {
|
||||
default_colors().map(|c| c.bg)
|
||||
}
|
||||
|
||||
/// Returns a monotonic counter that increments whenever `requery_default_colors()` runs
|
||||
/// successfully so cached renderers can know when their styling assumptions (e.g.
|
||||
/// background colors baked into cached transcript rows) are stale and need invalidation.
|
||||
#[allow(dead_code)]
|
||||
pub fn palette_version() -> u64 {
|
||||
DEFAULT_PALETTE_VERSION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(test)))]
|
||||
mod imp {
|
||||
use super::DefaultColors;
|
||||
|
||||
@@ -62,6 +62,7 @@ ratatui = { workspace = true, features = [
|
||||
"unstable-rendered-line-info",
|
||||
"unstable-widget-ref",
|
||||
] }
|
||||
ratatui-core = { workspace = true }
|
||||
ratatui-macros = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
@@ -73,6 +74,7 @@ strum_macros = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
textwrap = { workspace = true }
|
||||
tui-scrollbar = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
|
||||
+125
-86
@@ -3,7 +3,6 @@ use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::clipboard_copy;
|
||||
use crate::custom_terminal::Frame;
|
||||
use crate::diff_render::DiffSummary;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
@@ -16,11 +15,19 @@ use crate::pager_overlay::Overlay;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::resume_picker::ResumeSelection;
|
||||
use crate::transcript_copy_action::TranscriptCopyAction;
|
||||
use crate::transcript_copy_action::TranscriptCopyFeedback;
|
||||
use crate::transcript_copy_ui::TranscriptCopyUi;
|
||||
use crate::transcript_multi_click::TranscriptMultiClick;
|
||||
use crate::transcript_scrollbar::render_transcript_scrollbar_if_active;
|
||||
use crate::transcript_scrollbar::split_transcript_area;
|
||||
use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseEvent;
|
||||
use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseHandling;
|
||||
use crate::transcript_scrollbar_ui::TranscriptScrollbarUi;
|
||||
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
|
||||
use crate::transcript_selection::TranscriptSelection;
|
||||
use crate::transcript_selection::TranscriptSelectionPoint;
|
||||
use crate::transcript_view_cache::TranscriptViewCache;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::tui::scrolling::MouseScrollState;
|
||||
@@ -28,7 +35,6 @@ use crate::tui::scrolling::ScrollConfig;
|
||||
use crate::tui::scrolling::ScrollConfigOverrides;
|
||||
use crate::tui::scrolling::ScrollDirection;
|
||||
use crate::tui::scrolling::ScrollUpdate;
|
||||
use crate::tui::scrolling::TranscriptLineMeta;
|
||||
use crate::tui::scrolling::TranscriptScroll;
|
||||
use crate::update_action::UpdateAction;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
@@ -164,6 +170,7 @@ pub(crate) struct App {
|
||||
pub(crate) file_search: FileSearchManager,
|
||||
|
||||
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
||||
transcript_view_cache: TranscriptViewCache,
|
||||
|
||||
#[allow(dead_code)]
|
||||
transcript_scroll: TranscriptScroll,
|
||||
@@ -172,6 +179,8 @@ pub(crate) struct App {
|
||||
transcript_view_top: usize,
|
||||
transcript_total_lines: usize,
|
||||
transcript_copy_ui: TranscriptCopyUi,
|
||||
transcript_copy_action: TranscriptCopyAction,
|
||||
transcript_scrollbar_ui: TranscriptScrollbarUi,
|
||||
|
||||
// Pager overlay state (Transcript or Static like Diff)
|
||||
pub(crate) overlay: Option<Overlay>,
|
||||
@@ -333,12 +342,15 @@ impl App {
|
||||
file_search,
|
||||
enhanced_keys_supported,
|
||||
transcript_cells: Vec::new(),
|
||||
transcript_view_cache: TranscriptViewCache::new(),
|
||||
transcript_scroll: TranscriptScroll::default(),
|
||||
transcript_selection: TranscriptSelection::default(),
|
||||
transcript_multi_click: TranscriptMultiClick::default(),
|
||||
transcript_view_top: 0,
|
||||
transcript_total_lines: 0,
|
||||
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut),
|
||||
transcript_copy_action: TranscriptCopyAction::default(),
|
||||
transcript_scrollbar_ui: TranscriptScrollbarUi::default(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
@@ -506,11 +518,14 @@ impl App {
|
||||
self.transcript_total_lines,
|
||||
))
|
||||
};
|
||||
let copy_selection_key = self.copy_selection_key();
|
||||
let copy_feedback = self.transcript_copy_feedback_for_footer();
|
||||
self.chat_widget.set_transcript_ui_state(
|
||||
transcript_scrolled,
|
||||
selection_active,
|
||||
scroll_position,
|
||||
self.copy_selection_key(),
|
||||
copy_selection_key,
|
||||
copy_feedback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -541,35 +556,33 @@ impl App {
|
||||
return area.y;
|
||||
}
|
||||
|
||||
let transcript_area = Rect {
|
||||
let transcript_full_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: max_transcript_height,
|
||||
};
|
||||
let (transcript_area, _) = split_transcript_area(transcript_full_area);
|
||||
|
||||
let transcript =
|
||||
crate::transcript_render::build_wrapped_transcript_lines(cells, transcript_area.width);
|
||||
let (lines, line_meta) = (transcript.lines, transcript.meta);
|
||||
if lines.is_empty() {
|
||||
Clear.render_ref(transcript_area, frame.buffer);
|
||||
self.transcript_view_cache
|
||||
.ensure_wrapped(cells, transcript_area.width);
|
||||
let total_lines = self.transcript_view_cache.lines().len();
|
||||
if total_lines == 0 {
|
||||
Clear.render_ref(transcript_full_area, frame.buffer);
|
||||
self.transcript_scroll = TranscriptScroll::default();
|
||||
self.transcript_view_top = 0;
|
||||
self.transcript_total_lines = 0;
|
||||
return area.y;
|
||||
}
|
||||
|
||||
let is_user_cell: Vec<bool> = cells
|
||||
.iter()
|
||||
.map(|c| c.as_any().is::<UserHistoryCell>())
|
||||
.collect();
|
||||
|
||||
let total_lines = lines.len();
|
||||
self.transcript_total_lines = total_lines;
|
||||
let max_visible = std::cmp::min(max_transcript_height as usize, total_lines);
|
||||
let max_start = total_lines.saturating_sub(max_visible);
|
||||
|
||||
let (scroll_state, top_offset) = self.transcript_scroll.resolve_top(&line_meta, max_start);
|
||||
let (scroll_state, top_offset) = {
|
||||
let line_meta = self.transcript_view_cache.line_meta();
|
||||
self.transcript_scroll.resolve_top(line_meta, max_start)
|
||||
};
|
||||
self.transcript_scroll = scroll_state;
|
||||
self.transcript_view_top = top_offset;
|
||||
|
||||
@@ -596,12 +609,19 @@ impl App {
|
||||
);
|
||||
}
|
||||
|
||||
let transcript_area = Rect {
|
||||
let transcript_full_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: transcript_visible_height,
|
||||
};
|
||||
let (transcript_area, transcript_scrollbar_area) =
|
||||
split_transcript_area(transcript_full_area);
|
||||
|
||||
// Cache a few viewports worth of rasterized rows so redraws during streaming can cheaply
|
||||
// copy already-rendered `Cell`s instead of re-running grapheme segmentation.
|
||||
self.transcript_view_cache
|
||||
.set_raster_capacity(max_visible.saturating_mul(4).max(256));
|
||||
|
||||
for (row_index, line_index) in (top_offset..total_lines).enumerate() {
|
||||
if row_index >= max_visible {
|
||||
@@ -616,21 +636,8 @@ impl App {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
let is_user_row = line_meta
|
||||
.get(line_index)
|
||||
.and_then(TranscriptLineMeta::cell_index)
|
||||
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if is_user_row {
|
||||
let base_style = crate::style::user_message_style();
|
||||
for x in row_area.x..row_area.right() {
|
||||
let cell = &mut frame.buffer[(x, y)];
|
||||
let style = cell.style().patch(base_style);
|
||||
cell.set_style(style);
|
||||
}
|
||||
}
|
||||
|
||||
lines[line_index].render_ref(row_area, frame.buffer);
|
||||
self.transcript_view_cache
|
||||
.render_row_index_into(line_index, row_area, frame.buffer);
|
||||
}
|
||||
|
||||
self.apply_transcript_selection(transcript_area, frame.buffer);
|
||||
@@ -650,6 +657,13 @@ impl App {
|
||||
} else {
|
||||
self.transcript_copy_ui.clear_affordance();
|
||||
}
|
||||
render_transcript_scrollbar_if_active(
|
||||
frame.buffer,
|
||||
transcript_scrollbar_area,
|
||||
total_lines,
|
||||
max_visible,
|
||||
top_offset,
|
||||
);
|
||||
chat_top
|
||||
}
|
||||
|
||||
@@ -698,21 +712,45 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
let transcript_area = Rect {
|
||||
let transcript_full_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height: transcript_height,
|
||||
};
|
||||
let (transcript_area, transcript_scrollbar_area) =
|
||||
split_transcript_area(transcript_full_area);
|
||||
let base_x = transcript_area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
|
||||
let max_x = transcript_area.right().saturating_sub(1);
|
||||
|
||||
if matches!(
|
||||
self.transcript_scrollbar_ui
|
||||
.handle_mouse_event(TranscriptScrollbarMouseEvent {
|
||||
tui,
|
||||
mouse_event,
|
||||
transcript_area,
|
||||
scrollbar_area: transcript_scrollbar_area,
|
||||
transcript_cells: &self.transcript_cells,
|
||||
transcript_view_cache: &mut self.transcript_view_cache,
|
||||
transcript_scroll: &mut self.transcript_scroll,
|
||||
transcript_view_top: &mut self.transcript_view_top,
|
||||
transcript_total_lines: &mut self.transcript_total_lines,
|
||||
mouse_scroll_state: &mut self.scroll_state,
|
||||
}),
|
||||
TranscriptScrollbarMouseHandling::Handled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Treat the transcript as the only interactive region for transcript selection.
|
||||
//
|
||||
// This prevents clicks in the composer/footer from starting or extending a transcript
|
||||
// selection, while still allowing a left-click outside the transcript to clear an
|
||||
// existing highlight.
|
||||
if mouse_event.row < transcript_area.y || mouse_event.row >= transcript_area.bottom() {
|
||||
if !self.transcript_scrollbar_ui.pointer_capture_active()
|
||||
&& (mouse_event.row < transcript_full_area.y
|
||||
|| mouse_event.row >= transcript_full_area.bottom())
|
||||
{
|
||||
if matches!(
|
||||
mouse_event.kind,
|
||||
MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
|
||||
@@ -743,7 +781,14 @@ impl App {
|
||||
.transcript_copy_ui
|
||||
.hit_test(mouse_event.column, mouse_event.row)
|
||||
{
|
||||
self.copy_transcript_selection(tui);
|
||||
if self.transcript_copy_action.copy_and_handle(
|
||||
tui,
|
||||
chat_height,
|
||||
&self.transcript_cells,
|
||||
self.transcript_selection,
|
||||
) {
|
||||
self.transcript_selection = TranscriptSelection::default();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -919,7 +964,15 @@ impl App {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((transcript_height as usize, width))
|
||||
let transcript_full_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height: transcript_height,
|
||||
};
|
||||
let (transcript_area, _) = split_transcript_area(transcript_full_area);
|
||||
|
||||
Some((transcript_height as usize, transcript_area.width))
|
||||
}
|
||||
|
||||
/// Scroll the transcript by a number of visual lines.
|
||||
@@ -943,12 +996,12 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
let transcript =
|
||||
crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width);
|
||||
let line_meta = transcript.meta;
|
||||
self.transcript_view_cache
|
||||
.ensure_wrapped(&self.transcript_cells, width);
|
||||
let line_meta = self.transcript_view_cache.line_meta();
|
||||
self.transcript_scroll =
|
||||
self.transcript_scroll
|
||||
.scrolled_by(delta_lines, &line_meta, visible_lines);
|
||||
.scrolled_by(delta_lines, line_meta, visible_lines);
|
||||
|
||||
if schedule_frame {
|
||||
// Request a redraw; the frame scheduler coalesces bursts and clamps to 60fps.
|
||||
@@ -968,9 +1021,10 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
let transcript =
|
||||
crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width);
|
||||
let (lines, line_meta) = (transcript.lines, transcript.meta);
|
||||
self.transcript_view_cache
|
||||
.ensure_wrapped(&self.transcript_cells, width);
|
||||
let lines = self.transcript_view_cache.lines();
|
||||
let line_meta = self.transcript_view_cache.line_meta();
|
||||
if lines.is_empty() || line_meta.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -984,13 +1038,14 @@ impl App {
|
||||
let max_start = total_lines.saturating_sub(max_visible);
|
||||
let top_offset = match self.transcript_scroll {
|
||||
TranscriptScroll::ToBottom => max_start,
|
||||
TranscriptScroll::Scrolled { .. } => {
|
||||
TranscriptScroll::Scrolled { .. }
|
||||
| TranscriptScroll::ScrolledSpacerBeforeCell { .. } => {
|
||||
// Already anchored; nothing to lock.
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(scroll_state) = TranscriptScroll::anchor_for(&line_meta, top_offset) {
|
||||
if let Some(scroll_state) = TranscriptScroll::anchor_for(line_meta, top_offset) {
|
||||
self.transcript_scroll = scroll_state;
|
||||
}
|
||||
}
|
||||
@@ -1087,46 +1142,8 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the currently selected transcript region to the system clipboard.
|
||||
///
|
||||
/// The selection is defined in terms of flattened wrapped transcript line
|
||||
/// indices and columns, and this method reconstructs the same wrapped
|
||||
/// transcript used for on-screen rendering so the copied text closely
|
||||
/// matches the highlighted region.
|
||||
///
|
||||
/// Important: copy operates on the selection's full content-relative range,
|
||||
/// not just the current viewport. A selection can extend outside the visible
|
||||
/// region (for example, by scrolling after selecting, or by selecting while
|
||||
/// autoscrolling), and we still want the clipboard payload to reflect the
|
||||
/// entire selected transcript.
|
||||
fn copy_transcript_selection(&mut self, tui: &tui::Tui) {
|
||||
let size = tui.terminal.last_known_screen_size;
|
||||
let width = size.width;
|
||||
let height = size.height;
|
||||
if width == 0 || height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let chat_height = self.chat_widget.desired_height(width);
|
||||
if chat_height >= height {
|
||||
return;
|
||||
}
|
||||
|
||||
let transcript_height = height.saturating_sub(chat_height);
|
||||
if transcript_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells(
|
||||
&self.transcript_cells,
|
||||
self.transcript_selection,
|
||||
width,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = clipboard_copy::copy_text(text) {
|
||||
tracing::error!(error = %err, "failed to copy selection to clipboard");
|
||||
}
|
||||
fn transcript_copy_feedback_for_footer(&mut self) -> Option<TranscriptCopyFeedback> {
|
||||
self.transcript_copy_action.footer_feedback()
|
||||
}
|
||||
|
||||
fn copy_selection_key(&self) -> crate::key_hint::KeyBinding {
|
||||
@@ -1732,7 +1749,22 @@ impl App {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} if self.transcript_copy_ui.is_copy_key(ch, modifiers) => {
|
||||
self.copy_transcript_selection(tui);
|
||||
let size = tui.terminal.last_known_screen_size;
|
||||
let width = size.width;
|
||||
let height = size.height;
|
||||
if width == 0 || height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let chat_height = self.chat_widget.desired_height(width);
|
||||
if self.transcript_copy_action.copy_and_handle(
|
||||
tui,
|
||||
chat_height,
|
||||
&self.transcript_cells,
|
||||
self.transcript_selection,
|
||||
) {
|
||||
self.transcript_selection = TranscriptSelection::default();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::PageUp,
|
||||
@@ -1876,6 +1908,7 @@ mod tests {
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::new_session_info;
|
||||
use crate::transcript_copy_ui::CopySelectionShortcut;
|
||||
use crate::tui::scrolling::TranscriptLineMeta;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ConversationManager;
|
||||
@@ -1913,6 +1946,7 @@ mod tests {
|
||||
active_profile: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
transcript_view_cache: TranscriptViewCache::new(),
|
||||
transcript_scroll: TranscriptScroll::default(),
|
||||
transcript_selection: TranscriptSelection::default(),
|
||||
transcript_multi_click: TranscriptMultiClick::default(),
|
||||
@@ -1921,6 +1955,8 @@ mod tests {
|
||||
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
|
||||
CopySelectionShortcut::CtrlShiftC,
|
||||
),
|
||||
transcript_copy_action: TranscriptCopyAction::default(),
|
||||
transcript_scrollbar_ui: TranscriptScrollbarUi::default(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
@@ -1963,6 +1999,7 @@ mod tests {
|
||||
active_profile: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
transcript_view_cache: TranscriptViewCache::new(),
|
||||
transcript_scroll: TranscriptScroll::default(),
|
||||
transcript_selection: TranscriptSelection::default(),
|
||||
transcript_multi_click: TranscriptMultiClick::default(),
|
||||
@@ -1971,6 +2008,8 @@ mod tests {
|
||||
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
|
||||
CopySelectionShortcut::CtrlShiftC,
|
||||
),
|
||||
transcript_copy_action: TranscriptCopyAction::default(),
|
||||
transcript_scrollbar_ui: TranscriptScrollbarUi::default(),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
use crate::transcript_copy_action::TranscriptCopyFeedback;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -124,6 +125,7 @@ pub(crate) struct ChatComposer {
|
||||
transcript_selection_active: bool,
|
||||
transcript_scroll_position: Option<(usize, usize)>,
|
||||
transcript_copy_selection_key: KeyBinding,
|
||||
transcript_copy_feedback: Option<TranscriptCopyFeedback>,
|
||||
skills: Option<Vec<SkillMetadata>>,
|
||||
dismissed_skill_popup_token: Option<String>,
|
||||
}
|
||||
@@ -176,6 +178,7 @@ impl ChatComposer {
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
skills: None,
|
||||
dismissed_skill_popup_token: None,
|
||||
};
|
||||
@@ -1545,6 +1548,7 @@ impl ChatComposer {
|
||||
transcript_selection_active: self.transcript_selection_active,
|
||||
transcript_scroll_position: self.transcript_scroll_position,
|
||||
transcript_copy_selection_key: self.transcript_copy_selection_key,
|
||||
transcript_copy_feedback: self.transcript_copy_feedback,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1577,11 +1581,13 @@ impl ChatComposer {
|
||||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: KeyBinding,
|
||||
copy_feedback: Option<TranscriptCopyFeedback>,
|
||||
) -> bool {
|
||||
if self.transcript_scrolled == scrolled
|
||||
&& self.transcript_selection_active == selection_active
|
||||
&& self.transcript_scroll_position == scroll_position
|
||||
&& self.transcript_copy_selection_key == copy_selection_key
|
||||
&& self.transcript_copy_feedback == copy_feedback
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1590,6 +1596,7 @@ impl ChatComposer {
|
||||
self.transcript_selection_active = selection_active;
|
||||
self.transcript_scroll_position = scroll_position;
|
||||
self.transcript_copy_selection_key = copy_selection_key;
|
||||
self.transcript_copy_feedback = copy_feedback;
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::status::format_tokens_compact;
|
||||
use crate::transcript_copy_action::TranscriptCopyFeedback;
|
||||
use crate::ui_consts::FOOTER_INDENT_COLS;
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -26,6 +27,7 @@ pub(crate) struct FooterProps {
|
||||
pub(crate) transcript_selection_active: bool,
|
||||
pub(crate) transcript_scroll_position: Option<(usize, usize)>,
|
||||
pub(crate) transcript_copy_selection_key: KeyBinding,
|
||||
pub(crate) transcript_copy_feedback: Option<TranscriptCopyFeedback>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -80,11 +82,26 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
|
||||
}
|
||||
|
||||
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option<TranscriptCopyFeedback>) {
|
||||
let Some(line) = lines.first_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(feedback) = feedback else {
|
||||
return;
|
||||
};
|
||||
|
||||
line.push_span(" · ".dim());
|
||||
match feedback {
|
||||
TranscriptCopyFeedback::Copied => line.push_span("Copied".green().bold()),
|
||||
TranscriptCopyFeedback::Failed => line.push_span("Copy failed".red().bold()),
|
||||
}
|
||||
}
|
||||
|
||||
// Show the context indicator on the left, appended after the primary hint
|
||||
// (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when
|
||||
// the shortcut hint is hidden). Hide it only for the multi-line
|
||||
// ShortcutOverlay.
|
||||
match props.mode {
|
||||
let mut lines = match props.mode {
|
||||
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
|
||||
is_task_running: props.is_task_running,
|
||||
})],
|
||||
@@ -139,7 +156,9 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
props.context_window_percent,
|
||||
props.context_window_used_tokens,
|
||||
)],
|
||||
}
|
||||
};
|
||||
apply_copy_feedback(&mut lines, props.transcript_copy_feedback);
|
||||
lines
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -469,6 +488,7 @@ mod tests {
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -485,6 +505,7 @@ mod tests {
|
||||
transcript_selection_active: true,
|
||||
transcript_scroll_position: Some((3, 42)),
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -501,6 +522,7 @@ mod tests {
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -517,6 +539,7 @@ mod tests {
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -533,6 +556,7 @@ mod tests {
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -549,6 +573,7 @@ mod tests {
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -565,6 +590,7 @@ mod tests {
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -581,6 +607,7 @@ mod tests {
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -597,6 +624,24 @@ mod tests {
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_copy_feedback_copied",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutSummary,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
transcript_copy_feedback: Some(TranscriptCopyFeedback::Copied),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,12 +388,14 @@ impl BottomPane {
|
||||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: crate::key_hint::KeyBinding,
|
||||
copy_feedback: Option<crate::transcript_copy_action::TranscriptCopyFeedback>,
|
||||
) {
|
||||
let updated = self.composer.set_transcript_ui_state(
|
||||
scrolled,
|
||||
selection_active,
|
||||
scroll_position,
|
||||
copy_selection_key,
|
||||
copy_feedback,
|
||||
);
|
||||
if updated {
|
||||
self.request_redraw();
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui2/src/bottom_pane/footer.rs
|
||||
assertion_line: 473
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts · Copied "
|
||||
@@ -1792,7 +1792,10 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
self.codex_op_tx
|
||||
.send(Op::UserInput { items })
|
||||
.send(Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("failed to send message: {e}");
|
||||
});
|
||||
@@ -3138,12 +3141,14 @@ impl ChatWidget {
|
||||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: crate::key_hint::KeyBinding,
|
||||
copy_feedback: Option<crate::transcript_copy_action::TranscriptCopyFeedback>,
|
||||
) {
|
||||
self.bottom_pane.set_transcript_ui_state(
|
||||
scrolled,
|
||||
selection_active,
|
||||
scroll_position,
|
||||
copy_selection_key,
|
||||
copy_feedback,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ use std::path::PathBuf;
|
||||
use tracing::error;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::filter::Targets;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
mod additional_dirs;
|
||||
@@ -78,10 +77,14 @@ mod terminal_palette;
|
||||
mod text_formatting;
|
||||
mod tooltips;
|
||||
mod transcript_copy;
|
||||
mod transcript_copy_action;
|
||||
mod transcript_copy_ui;
|
||||
mod transcript_multi_click;
|
||||
mod transcript_render;
|
||||
mod transcript_scrollbar;
|
||||
mod transcript_scrollbar_ui;
|
||||
mod transcript_selection;
|
||||
mod transcript_view_cache;
|
||||
mod tui;
|
||||
mod ui_consts;
|
||||
pub mod update_action;
|
||||
@@ -292,13 +295,8 @@ pub async fn run_main(
|
||||
.with_filter(env_filter());
|
||||
|
||||
let feedback = codex_feedback::CodexFeedback::new();
|
||||
let targets = Targets::new().with_default(tracing::Level::TRACE);
|
||||
|
||||
let feedback_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(feedback.make_writer())
|
||||
.with_ansi(false)
|
||||
.with_target(false)
|
||||
.with_filter(targets);
|
||||
let feedback_layer = feedback.logger_layer();
|
||||
let feedback_metadata_layer = feedback.metadata_layer();
|
||||
|
||||
if cli.oss && model_provider_override.is_some() {
|
||||
// We're in the oss section, so provider_id should be Some
|
||||
@@ -333,6 +331,7 @@ pub async fn run_main(
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(file_layer)
|
||||
.with(feedback_layer)
|
||||
.with(feedback_metadata_layer)
|
||||
.with(otel_tracing_layer)
|
||||
.with(otel_logger_layer)
|
||||
.try_init();
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
use crate::color::perceptual_distance;
|
||||
use ratatui::style::Color;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
static DEFAULT_PALETTE_VERSION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn bump_palette_version() {
|
||||
DEFAULT_PALETTE_VERSION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Returns the closest color to the target color that the terminal can display.
|
||||
pub fn best_color(target: (u8, u8, u8)) -> Color {
|
||||
@@ -27,6 +35,7 @@ pub fn best_color(target: (u8, u8, u8)) -> Color {
|
||||
|
||||
pub fn requery_default_colors() {
|
||||
imp::requery_default_colors();
|
||||
bump_palette_version();
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -47,6 +56,10 @@ pub fn default_bg() -> Option<(u8, u8, u8)> {
|
||||
default_colors().map(|c| c.bg)
|
||||
}
|
||||
|
||||
pub fn palette_version() -> u64 {
|
||||
DEFAULT_PALETTE_VERSION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(test)))]
|
||||
mod imp {
|
||||
use super::DefaultColors;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Performs "copy selection" and manages transient UI feedback.
|
||||
//!
|
||||
//! `transcript_copy` is intentionally pure: it reconstructs clipboard text from a
|
||||
//! [`TranscriptSelection`], preserving wrapping, indentation, and Markdown markers.
|
||||
//!
|
||||
//! This module is the side-effecting layer on top of that pure logic:
|
||||
//! - writes the reconstructed text to the system clipboard
|
||||
//! - stores short-lived state so the footer can show `"Copied"` / `"Copy failed"`
|
||||
//! - schedules redraws so feedback appears promptly and then clears itself
|
||||
//!
|
||||
//! Keeping these responsibilities separate reduces cognitive load:
|
||||
//! - `transcript_copy` answers *what text should be copied?*
|
||||
//! - `transcript_copy_action` answers *do the copy and tell the user it happened*
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::transcript_scrollbar::split_transcript_area;
|
||||
use crate::transcript_selection::TranscriptSelection;
|
||||
use crate::tui;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
/// User-visible feedback shown briefly after a copy attempt.
|
||||
///
|
||||
/// The footer renders this value when present, and it expires automatically.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum TranscriptCopyFeedback {
|
||||
/// Copy succeeded and the clipboard was updated.
|
||||
Copied,
|
||||
/// Copy failed (typically due to OS clipboard integration issues).
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The outcome of attempting to copy the current selection.
|
||||
///
|
||||
/// This is a compact signal for UI code:
|
||||
/// - `NoSelection` means the action is a no-op (nothing to dismiss).
|
||||
/// - `Copied`/`Failed` mean the action was triggered and the selection should be dismissed.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum CopySelectionOutcome {
|
||||
/// No active selection exists (or the terminal is too small to compute one).
|
||||
NoSelection,
|
||||
/// Clipboard write succeeded.
|
||||
Copied,
|
||||
/// Clipboard write failed.
|
||||
Failed,
|
||||
}
|
||||
|
||||
const TRANSCRIPT_COPY_FEEDBACK_DURATION: Duration = Duration::from_millis(1500);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct TranscriptCopyFeedbackState {
|
||||
kind: TranscriptCopyFeedback,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// Performs the copy action and tracks transient footer feedback.
|
||||
///
|
||||
/// `App` owns one instance and calls [`Self::copy_and_handle`] when the user triggers "copy
|
||||
/// selection" (either via the on-screen copy pill or the keyboard shortcut).
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TranscriptCopyAction {
|
||||
feedback: Option<TranscriptCopyFeedbackState>,
|
||||
}
|
||||
|
||||
impl TranscriptCopyAction {
|
||||
/// Attempt to copy the current selection and record feedback.
|
||||
///
|
||||
/// Returns `true` when a copy attempt was made (success or failure). Callers should treat that
|
||||
/// as a signal to dismiss the selection highlight.
|
||||
pub(crate) fn copy_and_handle(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
chat_height: u16,
|
||||
transcript_cells: &[Arc<dyn HistoryCell>],
|
||||
transcript_selection: TranscriptSelection,
|
||||
) -> bool {
|
||||
let outcome =
|
||||
copy_transcript_selection(tui, chat_height, transcript_cells, transcript_selection);
|
||||
self.handle_copy_outcome(tui, outcome)
|
||||
}
|
||||
|
||||
/// Return footer feedback to render for the current frame, if any.
|
||||
///
|
||||
/// This is called from `App`'s render loop. It clears expired feedback lazily so callers do
|
||||
/// not need separate timer plumbing.
|
||||
pub(crate) fn footer_feedback(&mut self) -> Option<TranscriptCopyFeedback> {
|
||||
let state = self.feedback?;
|
||||
|
||||
if Instant::now() >= state.expires_at {
|
||||
self.feedback = None;
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(state.kind)
|
||||
}
|
||||
|
||||
/// Record the outcome of a copy attempt and schedule redraws.
|
||||
///
|
||||
/// Returns `true` when a copy attempt happened (success or failure). This is the signal to
|
||||
/// dismiss the selection highlight.
|
||||
pub(crate) fn handle_copy_outcome(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
outcome: CopySelectionOutcome,
|
||||
) -> bool {
|
||||
match outcome {
|
||||
CopySelectionOutcome::NoSelection => false,
|
||||
CopySelectionOutcome::Copied => {
|
||||
self.set_feedback(tui, TranscriptCopyFeedback::Copied);
|
||||
true
|
||||
}
|
||||
CopySelectionOutcome::Failed => {
|
||||
self.set_feedback(tui, TranscriptCopyFeedback::Failed);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Store feedback state and schedule a redraw for its appearance + expiration.
|
||||
fn set_feedback(&mut self, tui: &mut tui::Tui, kind: TranscriptCopyFeedback) {
|
||||
let expires_at = Instant::now()
|
||||
.checked_add(TRANSCRIPT_COPY_FEEDBACK_DURATION)
|
||||
.unwrap_or_else(Instant::now);
|
||||
self.feedback = Some(TranscriptCopyFeedbackState { kind, expires_at });
|
||||
|
||||
tui.frame_requester().schedule_frame();
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(TRANSCRIPT_COPY_FEEDBACK_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the current transcript selection to the system clipboard.
|
||||
///
|
||||
/// This function ties together layout validation, selection-to-text reconstruction via
|
||||
/// `transcript_copy`, and the actual clipboard write.
|
||||
pub(crate) fn copy_transcript_selection(
|
||||
tui: &tui::Tui,
|
||||
chat_height: u16,
|
||||
transcript_cells: &[Arc<dyn HistoryCell>],
|
||||
transcript_selection: TranscriptSelection,
|
||||
) -> CopySelectionOutcome {
|
||||
// This function is intentionally "dumb plumbing":
|
||||
// - validate layout prerequisites
|
||||
// - reconstruct clipboard text (`transcript_copy`)
|
||||
// - write to clipboard
|
||||
//
|
||||
// UI state management (feedback + redraw scheduling) lives in `TranscriptCopyAction`.
|
||||
let size = tui.terminal.last_known_screen_size;
|
||||
let width = size.width;
|
||||
let height = size.height;
|
||||
if width == 0 || height == 0 {
|
||||
return CopySelectionOutcome::NoSelection;
|
||||
}
|
||||
|
||||
if chat_height >= height {
|
||||
return CopySelectionOutcome::NoSelection;
|
||||
}
|
||||
|
||||
let transcript_height = height.saturating_sub(chat_height);
|
||||
if transcript_height == 0 {
|
||||
return CopySelectionOutcome::NoSelection;
|
||||
}
|
||||
|
||||
let transcript_full_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height: transcript_height,
|
||||
};
|
||||
let (transcript_area, _) = split_transcript_area(transcript_full_area);
|
||||
|
||||
let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells(
|
||||
transcript_cells,
|
||||
transcript_selection,
|
||||
transcript_area.width,
|
||||
) else {
|
||||
return CopySelectionOutcome::NoSelection;
|
||||
};
|
||||
|
||||
if let Err(err) = crate::clipboard_copy::copy_text(text) {
|
||||
tracing::error!(error = %err, "failed to copy selection to clipboard");
|
||||
return CopySelectionOutcome::Failed;
|
||||
}
|
||||
|
||||
CopySelectionOutcome::Copied
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn footer_feedback_returns_value_before_expiration() {
|
||||
let mut action = TranscriptCopyAction {
|
||||
feedback: Some(TranscriptCopyFeedbackState {
|
||||
kind: TranscriptCopyFeedback::Copied,
|
||||
expires_at: Instant::now() + Duration::from_secs(10),
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
action.footer_feedback(),
|
||||
Some(TranscriptCopyFeedback::Copied)
|
||||
);
|
||||
assert_eq!(
|
||||
action.footer_feedback(),
|
||||
Some(TranscriptCopyFeedback::Copied)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_feedback_clears_after_expiration() {
|
||||
let mut action = TranscriptCopyAction {
|
||||
feedback: Some(TranscriptCopyFeedbackState {
|
||||
kind: TranscriptCopyFeedback::Copied,
|
||||
expires_at: Instant::now() - Duration::from_secs(1),
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(action.footer_feedback(), None);
|
||||
assert!(action.feedback.is_none());
|
||||
assert_eq!(action.footer_feedback(), None);
|
||||
}
|
||||
}
|
||||
@@ -249,8 +249,16 @@ impl TranscriptCopyUi {
|
||||
let Some((y, to_x)) = last_visible_segment else {
|
||||
return;
|
||||
};
|
||||
// Place the pill on the row below the last visible selection segment.
|
||||
let Some(y) = y.checked_add(1).filter(|y| *y < area.bottom()) else {
|
||||
// Prefer placing the pill on the row below the last visible selection segment. If the
|
||||
// selection ends on the last visible row, fall back to placing it above (or, if the view
|
||||
// is only one row tall, on the same row).
|
||||
let Some(y) = y
|
||||
.checked_add(1)
|
||||
.filter(|y| *y < area.bottom())
|
||||
.or_else(|| y.checked_sub(1).filter(|y| *y >= area.y))
|
||||
.or(Some(y))
|
||||
.filter(|y| *y < area.bottom())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -275,8 +283,8 @@ impl TranscriptCopyUi {
|
||||
};
|
||||
|
||||
let pill_area = Rect::new(x, y, pill_width, 1);
|
||||
let base_style = Style::new().bg(Color::DarkGray);
|
||||
let icon_style = base_style.fg(Color::Cyan);
|
||||
let base_style = Style::new().bg(Color::DarkGray).fg(Color::White);
|
||||
let icon_style = base_style.add_modifier(Modifier::BOLD).fg(Color::LightCyan);
|
||||
let bold_style = base_style.add_modifier(Modifier::BOLD);
|
||||
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
@@ -329,4 +337,26 @@ mod tests {
|
||||
assert!(!rendered.contains("ctrl + shift + c"));
|
||||
assert!(ui.affordance_rect.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pill_renders_when_selection_on_last_row() {
|
||||
let area = Rect::new(0, 0, 60, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
for y in 0..area.height {
|
||||
for x in 2..area.width.saturating_sub(1) {
|
||||
buf[(x, y)].set_symbol("X");
|
||||
}
|
||||
}
|
||||
|
||||
let mut ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlShiftC);
|
||||
ui.render_copy_pill(area, &mut buf, (2, 2), (2, 6), 0, 3);
|
||||
|
||||
let rendered = buf_to_string(&buf, area);
|
||||
assert!(rendered.contains("copy"));
|
||||
assert!(rendered.contains("ctrl + shift + c"));
|
||||
|
||||
let rect = ui.affordance_rect.expect("expected pill to render");
|
||||
assert_eq!(rect.y, 1);
|
||||
assert!(ui.hit_test(rect.x, rect.y));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,9 +113,6 @@ pub(crate) fn build_wrapped_transcript_lines(
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
width: u16,
|
||||
) -> TranscriptLines {
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use ratatui::style::Color;
|
||||
|
||||
if width == 0 {
|
||||
return TranscriptLines {
|
||||
lines: Vec::new(),
|
||||
@@ -124,110 +121,140 @@ pub(crate) fn build_wrapped_transcript_lines(
|
||||
};
|
||||
}
|
||||
|
||||
let mut transcript = TranscriptLines {
|
||||
lines: Vec::new(),
|
||||
meta: Vec::new(),
|
||||
joiner_before: Vec::new(),
|
||||
};
|
||||
let mut has_emitted_lines = false;
|
||||
let base_opts: crate::wrapping::RtOptions<'_> =
|
||||
crate::wrapping::RtOptions::new(width.max(1) as usize);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut meta: Vec<TranscriptLineMeta> = Vec::new();
|
||||
let mut joiner_before: Vec<Option<String>> = Vec::new();
|
||||
let mut has_emitted_lines = false;
|
||||
|
||||
for (cell_index, cell) in cells.iter().enumerate() {
|
||||
// Start from each cell's transcript view (prefixes/indents already applied), then apply
|
||||
// viewport wrapping to prose while keeping preformatted content intact.
|
||||
let rendered = cell.transcript_lines_with_joiners(width);
|
||||
if rendered.lines.is_empty() {
|
||||
append_wrapped_transcript_cell(
|
||||
&mut transcript,
|
||||
&mut has_emitted_lines,
|
||||
cell_index,
|
||||
cell,
|
||||
width,
|
||||
&base_opts,
|
||||
);
|
||||
}
|
||||
|
||||
transcript
|
||||
}
|
||||
|
||||
/// Append a single history cell to an existing wrapped transcript.
|
||||
///
|
||||
/// This is the incremental building block used by transcript caching: it applies the same
|
||||
/// flattening and viewport-wrapping rules as [`build_wrapped_transcript_lines`], but for one cell
|
||||
/// at a time.
|
||||
///
|
||||
/// `has_emitted_lines` tracks whether the output already contains any non-spacer lines and is used
|
||||
/// to decide when to insert an inter-cell spacer row.
|
||||
pub(crate) fn append_wrapped_transcript_cell(
|
||||
out: &mut TranscriptLines,
|
||||
has_emitted_lines: &mut bool,
|
||||
cell_index: usize,
|
||||
cell: &Arc<dyn HistoryCell>,
|
||||
width: u16,
|
||||
base_opts: &crate::wrapping::RtOptions<'_>,
|
||||
) {
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use ratatui::style::Color;
|
||||
|
||||
if width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start from each cell's transcript view (prefixes/indents already applied), then apply
|
||||
// viewport wrapping to prose while keeping preformatted content intact.
|
||||
let rendered = cell.transcript_lines_with_joiners(width);
|
||||
if rendered.lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !cell.is_stream_continuation() {
|
||||
if *has_emitted_lines {
|
||||
out.lines.push(Line::from(""));
|
||||
out.meta.push(TranscriptLineMeta::Spacer);
|
||||
out.joiner_before.push(None);
|
||||
} else {
|
||||
*has_emitted_lines = true;
|
||||
}
|
||||
}
|
||||
|
||||
// `visual_line_in_cell` counts the output visual lines produced from this cell *after* any
|
||||
// viewport wrapping. This is distinct from `base_idx` (the index into the cell's input
|
||||
// lines), since a single input line may wrap into multiple visual lines.
|
||||
let mut visual_line_in_cell: usize = 0;
|
||||
let mut first = true;
|
||||
for (base_idx, base_line) in rendered.lines.iter().enumerate() {
|
||||
// Preserve code blocks (and other preformatted text) by not applying
|
||||
// viewport wrapping, so indentation remains meaningful for copy/paste.
|
||||
if base_line.style.fg == Some(Color::Cyan) {
|
||||
out.lines.push(base_line.clone());
|
||||
out.meta.push(TranscriptLineMeta::CellLine {
|
||||
cell_index,
|
||||
line_in_cell: visual_line_in_cell,
|
||||
});
|
||||
visual_line_in_cell = visual_line_in_cell.saturating_add(1);
|
||||
// Preformatted lines are treated as hard breaks; we keep the cell-provided joiner
|
||||
// (which is typically `None`).
|
||||
out.joiner_before.push(
|
||||
rendered
|
||||
.joiner_before
|
||||
.get(base_idx)
|
||||
.cloned()
|
||||
.unwrap_or(None),
|
||||
);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !cell.is_stream_continuation() {
|
||||
if has_emitted_lines {
|
||||
lines.push(Line::from(""));
|
||||
meta.push(TranscriptLineMeta::Spacer);
|
||||
joiner_before.push(None);
|
||||
} else {
|
||||
has_emitted_lines = true;
|
||||
}
|
||||
}
|
||||
let opts = if first {
|
||||
base_opts.clone()
|
||||
} else {
|
||||
// For subsequent input lines within a cell, treat the "initial" indent as the cell's
|
||||
// subsequent indent (matches textarea wrapping expectations).
|
||||
base_opts
|
||||
.clone()
|
||||
.initial_indent(base_opts.subsequent_indent.clone())
|
||||
};
|
||||
// `word_wrap_line_with_joiners` returns both the wrapped visual lines and, for each
|
||||
// continuation segment, the exact joiner substring that should be inserted instead of a
|
||||
// newline when copying as a logical line.
|
||||
let (wrapped, wrapped_joiners) =
|
||||
crate::wrapping::word_wrap_line_with_joiners(base_line, opts);
|
||||
|
||||
// `visual_line_in_cell` counts the output visual lines produced from this cell *after* any
|
||||
// viewport wrapping. This is distinct from `base_idx` (the index into the cell's input
|
||||
// lines), since a single input line may wrap into multiple visual lines.
|
||||
let mut visual_line_in_cell: usize = 0;
|
||||
let mut first = true;
|
||||
for (base_idx, base_line) in rendered.lines.iter().enumerate() {
|
||||
// Preserve code blocks (and other preformatted text) by not applying
|
||||
// viewport wrapping, so indentation remains meaningful for copy/paste.
|
||||
if base_line.style.fg == Some(Color::Cyan) {
|
||||
lines.push(base_line.clone());
|
||||
meta.push(TranscriptLineMeta::CellLine {
|
||||
cell_index,
|
||||
line_in_cell: visual_line_in_cell,
|
||||
});
|
||||
visual_line_in_cell = visual_line_in_cell.saturating_add(1);
|
||||
// Preformatted lines are treated as hard breaks; we keep the cell-provided joiner
|
||||
// (which is typically `None`).
|
||||
joiner_before.push(
|
||||
for (seg_idx, (wrapped_line, seg_joiner)) in
|
||||
wrapped.into_iter().zip(wrapped_joiners).enumerate()
|
||||
{
|
||||
out.lines.push(line_to_static(&wrapped_line));
|
||||
out.meta.push(TranscriptLineMeta::CellLine {
|
||||
cell_index,
|
||||
line_in_cell: visual_line_in_cell,
|
||||
});
|
||||
visual_line_in_cell = visual_line_in_cell.saturating_add(1);
|
||||
|
||||
if seg_idx == 0 {
|
||||
// The first wrapped segment corresponds to the original input line, so we use the
|
||||
// cell-provided joiner (hard break vs soft break *between input lines*).
|
||||
out.joiner_before.push(
|
||||
rendered
|
||||
.joiner_before
|
||||
.get(base_idx)
|
||||
.cloned()
|
||||
.unwrap_or(None),
|
||||
);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let opts = if first {
|
||||
base_opts.clone()
|
||||
} else {
|
||||
// For subsequent input lines within a cell, treat the "initial" indent as the
|
||||
// cell's subsequent indent (matches textarea wrapping expectations).
|
||||
base_opts
|
||||
.clone()
|
||||
.initial_indent(base_opts.subsequent_indent.clone())
|
||||
};
|
||||
// `word_wrap_line_with_joiners` returns both the wrapped visual lines and, for each
|
||||
// continuation segment, the exact joiner substring that should be inserted instead of a
|
||||
// newline when copying as a logical line.
|
||||
let (wrapped, wrapped_joiners) =
|
||||
crate::wrapping::word_wrap_line_with_joiners(base_line, opts);
|
||||
|
||||
for (seg_idx, (wrapped_line, seg_joiner)) in
|
||||
wrapped.into_iter().zip(wrapped_joiners).enumerate()
|
||||
{
|
||||
lines.push(line_to_static(&wrapped_line));
|
||||
meta.push(TranscriptLineMeta::CellLine {
|
||||
cell_index,
|
||||
line_in_cell: visual_line_in_cell,
|
||||
});
|
||||
visual_line_in_cell = visual_line_in_cell.saturating_add(1);
|
||||
|
||||
if seg_idx == 0 {
|
||||
// The first wrapped segment corresponds to the original input line, so we use
|
||||
// the cell-provided joiner (hard break vs soft break *between input lines*).
|
||||
joiner_before.push(
|
||||
rendered
|
||||
.joiner_before
|
||||
.get(base_idx)
|
||||
.cloned()
|
||||
.unwrap_or(None),
|
||||
);
|
||||
} else {
|
||||
// Subsequent wrapped segments are soft-wrap continuations produced by viewport
|
||||
// wrapping, so we use the wrap-derived joiner.
|
||||
joiner_before.push(seg_joiner);
|
||||
}
|
||||
// Subsequent wrapped segments are soft-wrap continuations produced by viewport
|
||||
// wrapping, so we use the wrap-derived joiner.
|
||||
out.joiner_before.push(seg_joiner);
|
||||
}
|
||||
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
TranscriptLines {
|
||||
lines,
|
||||
meta,
|
||||
joiner_before,
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,4 +423,56 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_wrapped_transcript_cell_matches_full_build() {
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
|
||||
let cells: Vec<Arc<dyn HistoryCell>> = vec![
|
||||
Arc::new(FakeCell {
|
||||
lines: vec![Line::from("• hello world")],
|
||||
joiner_before: vec![None],
|
||||
is_stream_continuation: false,
|
||||
}),
|
||||
// A preformatted line should not be viewport-wrapped.
|
||||
Arc::new(FakeCell {
|
||||
lines: vec![Line::from("• 1234567890").style(Style::default().fg(Color::Cyan))],
|
||||
joiner_before: vec![None],
|
||||
is_stream_continuation: false,
|
||||
}),
|
||||
// A stream continuation should not get an inter-cell spacer row.
|
||||
Arc::new(FakeCell {
|
||||
lines: vec![Line::from("• wrap me please")],
|
||||
joiner_before: vec![None],
|
||||
is_stream_continuation: true,
|
||||
}),
|
||||
];
|
||||
|
||||
let width = 7;
|
||||
let full = build_wrapped_transcript_lines(&cells, width);
|
||||
|
||||
let mut out = TranscriptLines {
|
||||
lines: Vec::new(),
|
||||
meta: Vec::new(),
|
||||
joiner_before: Vec::new(),
|
||||
};
|
||||
let mut has_emitted_lines = false;
|
||||
let base_opts: crate::wrapping::RtOptions<'_> =
|
||||
crate::wrapping::RtOptions::new(width.max(1) as usize);
|
||||
for (cell_index, cell) in cells.iter().enumerate() {
|
||||
append_wrapped_transcript_cell(
|
||||
&mut out,
|
||||
&mut has_emitted_lines,
|
||||
cell_index,
|
||||
cell,
|
||||
width,
|
||||
&base_opts,
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(out.lines, full.lines);
|
||||
assert_eq!(out.meta, full.meta);
|
||||
assert_eq!(out.joiner_before, full.joiner_before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
//! Transcript scrollbar rendering.
|
||||
//!
|
||||
//! The transcript in `codex-tui2` is rendered as a flattened list of wrapped visual lines. The
|
||||
//! viewport is tracked as a top-row offset (`transcript_view_top`) into that flattened list (see
|
||||
//! `tui/scrolling.rs` and `tui_viewport_and_history.md`).
|
||||
//!
|
||||
//! This module adds a scrollbar to that viewport using the `tui-scrollbar` widget, but does so in
|
||||
//! a way that keeps the transcript hot path simple and avoids visual layout jank.
|
||||
//!
|
||||
//! # Layout and invariants
|
||||
//!
|
||||
//! The transcript area is split into:
|
||||
//!
|
||||
//! - `content_area`: where transcript text is rendered
|
||||
//! - `scrollbar_area`: a 1-column region used to render the scrollbar
|
||||
//!
|
||||
//! Additionally, we reserve a 1-column *gap* between content and scrollbar. This produces a
|
||||
//! slightly more stable/intentional look (the scrollbar reads as an affordance, not part of the
|
||||
//! transcript content) and avoids accidental overlap with selection/copy UI.
|
||||
//!
|
||||
//! Important invariant: **any code that computes transcript wrapping, scrolling, selection, or
|
||||
//! copy must use the same width as on-screen transcript rendering**. In practice that means:
|
||||
//!
|
||||
//! - Use [`split_transcript_area`] and pass `content_area.width` into anything that depends on
|
||||
//! transcript width (wrapping, scroll deltas, selection reconstruction for copy, etc.).
|
||||
//! - Do not mix `terminal.width` and `content_area.width` for transcript operations; doing so
|
||||
//! causes off-by-one/off-by-two behaviors where the selection highlights and copied text do not
|
||||
//! match what the user sees.
|
||||
//!
|
||||
//! `App` follows this rule by deriving `content_area.width` anywhere it needs transcript width.
|
||||
//!
|
||||
//! # When the scrollbar is shown
|
||||
//!
|
||||
//! The scrollbar is only drawn when the transcript is *not* pinned to the bottom:
|
||||
//!
|
||||
//! - `offset < max_offset` → draw scrollbar
|
||||
//! - `offset == max_offset` → keep the column reserved but blank
|
||||
//!
|
||||
//! This keeps the UI clean during normal operation (where the viewport follows streaming output),
|
||||
//! while still providing a clear affordance when the user is actively reading scrollback.
|
||||
//!
|
||||
//! # Styling and theme heuristics
|
||||
//!
|
||||
//! `tui-scrollbar` 0.2.1 changed defaults (no arrows, space track + dark background). We keep
|
||||
//! default glyphs/arrows so the widget controls its own shape, but override track/thumb colors so
|
||||
//! it matches `codex-tui2`’s existing "user prompt block" styling:
|
||||
//!
|
||||
//! - The track background is a small blend toward the terminal foreground so it looks like a
|
||||
//! subtle indent against the terminal background.
|
||||
//! - The thumb foreground is a stronger blend so it reads as the active element.
|
||||
//! - In light themes (terminal background is light), the thumb is intentionally a *darker* shade
|
||||
//! than the track so it reads as an inset element.
|
||||
//!
|
||||
//! We derive these colors from the terminal’s default foreground/background (when available via
|
||||
//! `terminal_palette`). When defaults are unknown (tests / unsupported terminals), we fall back to
|
||||
//! ANSI colors so the scrollbar remains visible.
|
||||
//!
|
||||
//! # Pointer interaction (mouse click/drag)
|
||||
//!
|
||||
//! `tui-scrollbar` includes an interaction helper that translates pointer events into
|
||||
//! `ScrollCommand::SetOffset(...)` updates, including "grab offset" handling so dragging keeps the
|
||||
//! pointer anchored within the thumb.
|
||||
//!
|
||||
//! `codex-tui2` uses that helper rather than reimplementing scrollbar hit testing and drag math.
|
||||
//! The app owns the actual transcript scroll state (anchors in `tui/scrolling.rs`), so we only use
|
||||
//! `tui-scrollbar` to decide *which* offset the user requested. `App` then converts that raw
|
||||
//! `offset` back into a stable [`TranscriptScroll`] anchor.
|
||||
//!
|
||||
//! Note: we use `tui-scrollbar`’s backend-agnostic [`ScrollEvent`] types instead of its optional
|
||||
//! `crossterm` adapter, because the workspace uses a patched `crossterm` and we want to avoid
|
||||
//! pulling in multiple `crossterm` versions (which would make `MouseEvent` types incompatible).
|
||||
//!
|
||||
//! Because the scrollbar is visually hidden while pinned-to-bottom, `App` also keeps a tiny
|
||||
//! "pointer capture" bool so a drag that reaches the bottom doesn't accidentally turn into a text
|
||||
//! selection once the scrollbar disappears.
|
||||
//!
|
||||
//! # `ratatui` vs `ratatui-core`
|
||||
//!
|
||||
//! `codex-tui2` uses the `ratatui` crate, while `tui-scrollbar` is built on `ratatui-core`.
|
||||
//! Because the buffer and style types are distinct, we render the scrollbar into a small
|
||||
//! `ratatui-core` scratch buffer and then copy the resulting glyphs into the main `ratatui`
|
||||
//! buffer with `ratatui` styles.
|
||||
//!
|
||||
//! ## Upgrade note: Ratatui 0.30+
|
||||
//!
|
||||
//! Ratatui 0.30 split many core types (including `Buffer`, `Rect`, and `Widget`) into the new
|
||||
//! `ratatui-core` crate. `codex-tui2` is currently pinned to an older Ratatui, so it still works
|
||||
//! with `ratatui::buffer::Buffer` / `ratatui::layout::Rect`, while `tui-scrollbar` is already on
|
||||
//! `ratatui-core`.
|
||||
//!
|
||||
//! That mismatch forces two bits of "glue" that should go away once `codex-tui2` upgrades to
|
||||
//! Ratatui 0.30:
|
||||
//!
|
||||
//! - Rendering: `render_transcript_scrollbar_if_active` currently renders into a `ratatui-core`
|
||||
//! scratch buffer and copies glyphs/styles into the `ratatui` buffer. With Ratatui 0.30, the
|
||||
//! app’s buffer/rect types should unify with `tui-scrollbar`’s `ratatui-core` types, so we can
|
||||
//! render directly without copying.
|
||||
//! - Input: we currently translate `crossterm::MouseEvent` into `tui-scrollbar`’s backend-agnostic
|
||||
//! `ScrollEvent` types (and intentionally avoid `tui-scrollbar`’s optional `crossterm` adapter)
|
||||
//! to prevent multiple `crossterm` versions in the dependency graph. Once the Ratatui upgrade
|
||||
//! is complete, this should be revisited; if the workspace’s `crossterm` resolves to a single
|
||||
//! version, we can use `tui-scrollbar`’s adapter and reduce more local glue.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui_core::buffer::Buffer as CoreBuffer;
|
||||
use ratatui_core::layout::Rect as CoreRect;
|
||||
use ratatui_core::widgets::Widget as _;
|
||||
use tui_scrollbar::PointerButton;
|
||||
use tui_scrollbar::PointerEvent;
|
||||
use tui_scrollbar::PointerEventKind;
|
||||
use tui_scrollbar::ScrollBar;
|
||||
use tui_scrollbar::ScrollBarInteraction;
|
||||
use tui_scrollbar::ScrollCommand;
|
||||
use tui_scrollbar::ScrollEvent;
|
||||
use tui_scrollbar::ScrollLengths;
|
||||
use tui_scrollbar::TrackClickBehavior;
|
||||
|
||||
/// Number of columns reserved between transcript content and the scrollbar track.
|
||||
///
|
||||
/// This exists purely for visual separation and to avoid selection/copy UI feeling "attached" to
|
||||
/// the scrollbar.
|
||||
const TRANSCRIPT_SCROLLBAR_GAP_COLS: u16 = 1;
|
||||
/// Width of the scrollbar track itself (in terminal cells).
|
||||
///
|
||||
/// `tui-scrollbar` renders a vertical scrollbar into a 1-column area.
|
||||
const TRANSCRIPT_SCROLLBAR_TRACK_COLS: u16 = 1;
|
||||
/// Total columns reserved for transcript scrollbar UI (gap + track).
|
||||
pub(crate) const TRANSCRIPT_SCROLLBAR_COLS: u16 =
|
||||
TRANSCRIPT_SCROLLBAR_GAP_COLS + TRANSCRIPT_SCROLLBAR_TRACK_COLS;
|
||||
|
||||
/// Split a transcript viewport into content + scrollbar regions.
|
||||
///
|
||||
/// `codex-tui2` reserves space for the transcript scrollbar even when it is not visible so the
|
||||
/// transcript does not "reflow" when the user scrolls away from the bottom.
|
||||
///
|
||||
/// Layout:
|
||||
/// - `content_area`: original area minus [`TRANSCRIPT_SCROLLBAR_COLS`] on the right.
|
||||
/// - `scrollbar_area`: the last column of the original area (1 cell wide).
|
||||
/// - The remaining column (immediately left of `scrollbar_area`) is the "gap" and is intentionally
|
||||
/// left unused so the scrollbar reads as a separate affordance.
|
||||
///
|
||||
/// Returns `(area, None)` when the terminal is too narrow to reserve the required columns.
|
||||
pub(crate) fn split_transcript_area(area: Rect) -> (Rect, Option<Rect>) {
|
||||
if area.width <= TRANSCRIPT_SCROLLBAR_COLS {
|
||||
return (area, None);
|
||||
}
|
||||
|
||||
let content_width = area.width.saturating_sub(TRANSCRIPT_SCROLLBAR_COLS);
|
||||
let content_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: content_width,
|
||||
height: area.height,
|
||||
};
|
||||
let scrollbar_area = Rect {
|
||||
x: area.right().saturating_sub(1),
|
||||
y: area.y,
|
||||
width: TRANSCRIPT_SCROLLBAR_TRACK_COLS,
|
||||
height: area.height,
|
||||
};
|
||||
|
||||
(content_area, Some(scrollbar_area))
|
||||
}
|
||||
|
||||
/// Whether the transcript scrollbar should be visible.
|
||||
///
|
||||
/// The scrollbar is treated as "active" when the transcript is scrollable and the viewport is not
|
||||
/// pinned to the bottom. This is used both for rendering (draw vs. keep blank) and for interaction
|
||||
/// (whether the scrollbar should be hit-testable).
|
||||
///
|
||||
/// Note that `codex-tui2` still reserves space for the scrollbar even when it is inactive; see
|
||||
/// [`split_transcript_area`].
|
||||
pub(crate) fn is_transcript_scrollbar_active(
|
||||
total_lines: usize,
|
||||
viewport_lines: usize,
|
||||
top_offset: usize,
|
||||
) -> bool {
|
||||
if total_lines <= viewport_lines {
|
||||
return false;
|
||||
}
|
||||
|
||||
let max_offset = total_lines.saturating_sub(viewport_lines);
|
||||
top_offset < max_offset
|
||||
}
|
||||
|
||||
/// Render the transcript scrollbar into `buf` when the viewport is scrolled away from bottom.
|
||||
///
|
||||
/// The scrollbar is hidden (but its column(s) remain reserved) while the viewport follows the
|
||||
/// latest output.
|
||||
///
|
||||
/// Implementation notes:
|
||||
/// - We keep `tui-scrollbar`’s default glyph selection and shape logic, but override colors to
|
||||
/// better match `codex-tui2`’s theme heuristics (see module docs).
|
||||
/// - Because `tui-scrollbar` renders into a `ratatui-core` buffer while `codex-tui2` uses `ratatui`
|
||||
/// (pre-0.30), we render into a scratch buffer and then copy the resulting symbols into the main
|
||||
/// buffer.
|
||||
pub(crate) fn render_transcript_scrollbar_if_active(
|
||||
buf: &mut Buffer,
|
||||
scrollbar_area: Option<Rect>,
|
||||
total_lines: usize,
|
||||
viewport_lines: usize,
|
||||
top_offset: usize,
|
||||
) {
|
||||
let Some(scrollbar_area) = scrollbar_area else {
|
||||
return;
|
||||
};
|
||||
|
||||
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if !is_transcript_scrollbar_active(total_lines, viewport_lines, top_offset) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lengths = ScrollLengths {
|
||||
content_len: total_lines,
|
||||
viewport_len: viewport_lines,
|
||||
};
|
||||
|
||||
let scrollbar = ScrollBar::vertical(lengths).offset(top_offset);
|
||||
|
||||
let core_bar_area = CoreRect {
|
||||
x: scrollbar_area.x,
|
||||
y: scrollbar_area.y,
|
||||
width: scrollbar_area.width,
|
||||
height: scrollbar_area.height,
|
||||
};
|
||||
let mut scratch = CoreBuffer::empty(core_bar_area);
|
||||
(&scrollbar).render(core_bar_area, &mut scratch);
|
||||
|
||||
let (track_style, thumb_style) = scrollbar_styles();
|
||||
for row in 0..scrollbar_area.height {
|
||||
let x = scrollbar_area.x;
|
||||
let y = scrollbar_area.y + row;
|
||||
let src = &scratch[(x, y)];
|
||||
let dst = &mut buf[(x, y)];
|
||||
let symbol = src.symbol();
|
||||
dst.set_symbol(symbol);
|
||||
if symbol == " " {
|
||||
dst.set_style(track_style);
|
||||
} else {
|
||||
dst.set_style(thumb_style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `crossterm` mouse event into a requested transcript offset for the scrollbar.
|
||||
///
|
||||
/// This is a thin wrapper over `tui-scrollbar`’s pointer interaction logic:
|
||||
/// - It builds a `ScrollBar` configured with the current `top_offset`.
|
||||
/// - It translates the mouse event into a backend-agnostic [`ScrollEvent`].
|
||||
/// - It passes the event through `tui-scrollbar`’s hit testing and drag state (`interaction`).
|
||||
///
|
||||
/// `clamp_to_track` exists for `App`’s "pointer capture" behavior: once the user starts a drag on
|
||||
/// the scrollbar, we keep treating the gesture as a scrollbar drag even if the pointer moves
|
||||
/// outside the 1-column track. Without this clamp, the drag could stop producing offsets, and the
|
||||
/// same mouse gesture could then be interpreted as transcript selection.
|
||||
///
|
||||
/// Returns `None` when:
|
||||
/// - the scrollbar area is empty,
|
||||
/// - the transcript does not scroll (`total_lines <= viewport_lines`),
|
||||
/// - or the event is not a left-button down/drag/up.
|
||||
pub(crate) fn transcript_scrollbar_offset_for_mouse_event(
|
||||
scrollbar_area: Rect,
|
||||
total_lines: usize,
|
||||
viewport_lines: usize,
|
||||
top_offset: usize,
|
||||
mut event: crossterm::event::MouseEvent,
|
||||
interaction: &mut ScrollBarInteraction,
|
||||
clamp_to_track: bool,
|
||||
) -> Option<usize> {
|
||||
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if total_lines <= viewport_lines {
|
||||
return None;
|
||||
}
|
||||
|
||||
if clamp_to_track {
|
||||
let max_x = scrollbar_area.right().saturating_sub(1);
|
||||
let max_y = scrollbar_area.bottom().saturating_sub(1);
|
||||
event.column = event.column.clamp(scrollbar_area.x, max_x);
|
||||
event.row = event.row.clamp(scrollbar_area.y, max_y);
|
||||
}
|
||||
|
||||
let lengths = ScrollLengths {
|
||||
content_len: total_lines,
|
||||
viewport_len: viewport_lines,
|
||||
};
|
||||
let scrollbar = ScrollBar::vertical(lengths)
|
||||
.offset(top_offset)
|
||||
.track_click_behavior(TrackClickBehavior::JumpToClick);
|
||||
|
||||
let core_bar_area = CoreRect {
|
||||
x: scrollbar_area.x,
|
||||
y: scrollbar_area.y,
|
||||
width: scrollbar_area.width,
|
||||
height: scrollbar_area.height,
|
||||
};
|
||||
let scroll_event = match event.kind {
|
||||
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
|
||||
Some(ScrollEvent::Pointer(PointerEvent {
|
||||
column: event.column,
|
||||
row: event.row,
|
||||
kind: PointerEventKind::Down,
|
||||
button: PointerButton::Primary,
|
||||
}))
|
||||
}
|
||||
crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
|
||||
Some(ScrollEvent::Pointer(PointerEvent {
|
||||
column: event.column,
|
||||
row: event.row,
|
||||
kind: PointerEventKind::Up,
|
||||
button: PointerButton::Primary,
|
||||
}))
|
||||
}
|
||||
crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
|
||||
Some(ScrollEvent::Pointer(PointerEvent {
|
||||
column: event.column,
|
||||
row: event.row,
|
||||
kind: PointerEventKind::Drag,
|
||||
button: PointerButton::Primary,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
scroll_event
|
||||
.and_then(|scroll_event| scrollbar.handle_event(core_bar_area, scroll_event, interaction))
|
||||
.map(|command| match command {
|
||||
ScrollCommand::SetOffset(offset) => offset,
|
||||
})
|
||||
}
|
||||
|
||||
/// Derive track/thumb styles for the scrollbar from terminal defaults.
|
||||
///
|
||||
/// We prefer using the terminal’s default background/foreground so the scrollbar feels like a
|
||||
/// native part of the theme (and stays readable across 16-color / 256-color / truecolor
|
||||
/// backends).
|
||||
///
|
||||
/// When terminal defaults are unavailable (tests / unsupported terminals), we fall back to fixed
|
||||
/// ANSI colors that are likely to be visible.
|
||||
fn scrollbar_styles() -> (Style, Style) {
|
||||
let Some(terminal_bg) = crate::terminal_palette::default_bg() else {
|
||||
let track_style = Style::new().bg(Color::DarkGray);
|
||||
let thumb_style = Style::new().fg(Color::Gray).bg(Color::DarkGray);
|
||||
return (track_style, thumb_style);
|
||||
};
|
||||
|
||||
let terminal_fg = crate::terminal_palette::default_fg();
|
||||
|
||||
let (track_rgb, thumb_rgb) = scrollbar_colors(terminal_bg, terminal_fg);
|
||||
let track_bg = crate::terminal_palette::best_color(track_rgb);
|
||||
let thumb_fg = crate::terminal_palette::best_color(thumb_rgb);
|
||||
|
||||
let track_style = Style::new().bg(track_bg);
|
||||
let thumb_style = Style::new().fg(thumb_fg).bg(track_bg);
|
||||
(track_style, thumb_style)
|
||||
}
|
||||
|
||||
/// Compute `(track_bg_rgb, thumb_fg_rgb)` for the transcript scrollbar.
|
||||
///
|
||||
/// The scrollbar is styled to feel consistent with the user prompt background (see
|
||||
/// `style::user_message_bg`), but is tuned separately so the thumb reads as an inset control:
|
||||
///
|
||||
/// - Dark themes: track is a subtle brightening of the background; thumb is brighter than the track
|
||||
/// (but not pure white).
|
||||
/// - Light themes: track is a subtle darkening of the background; thumb is darker than the track.
|
||||
fn scrollbar_colors(
|
||||
terminal_bg: (u8, u8, u8),
|
||||
terminal_fg: Option<(u8, u8, u8)>,
|
||||
) -> ((u8, u8, u8), (u8, u8, u8)) {
|
||||
let is_light = crate::color::is_light(terminal_bg);
|
||||
let fallback_fg = if is_light { (0, 0, 0) } else { (255, 255, 255) };
|
||||
let terminal_fg = terminal_fg.unwrap_or(fallback_fg);
|
||||
|
||||
// We want the scrollbar to feel visually related to the user message block background
|
||||
// (`style::user_message_bg` uses 0.1), but slightly more subtle:
|
||||
//
|
||||
// - Light mode: keep both colors closer to the background (alpha < 0.1), with the thumb darker
|
||||
// than the track.
|
||||
// - Dark mode: keep the track slightly darker than the prompt block, but make the thumb
|
||||
// brighter so it's easy to pick out without becoming "white".
|
||||
let (track_alpha, thumb_alpha) = if is_light { (0.04, 0.08) } else { (0.08, 0.18) };
|
||||
|
||||
let track_rgb = crate::color::blend(terminal_fg, terminal_bg, track_alpha);
|
||||
let thumb_rgb = crate::color::blend(terminal_fg, terminal_bg, thumb_alpha);
|
||||
(track_rgb, thumb_rgb)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn scrollbar_bg(buf: &Buffer, scrollbar_area: Rect) -> Vec<ratatui::style::Color> {
|
||||
use ratatui::style::Color;
|
||||
|
||||
let x = scrollbar_area.x;
|
||||
(0..scrollbar_area.height)
|
||||
.map(|row| {
|
||||
buf[(x, scrollbar_area.y + row)]
|
||||
.style()
|
||||
.bg
|
||||
.unwrap_or(Color::Reset)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_render_when_pinned_to_bottom() {
|
||||
let full_area = Rect::new(0, 0, 10, 6);
|
||||
let (_, scrollbar_area) = split_transcript_area(full_area);
|
||||
let mut buf = Buffer::empty(full_area);
|
||||
|
||||
render_transcript_scrollbar_if_active(&mut buf, scrollbar_area, 100, 6, 94);
|
||||
|
||||
assert_eq!(
|
||||
scrollbar_bg(&buf, scrollbar_area.expect("scrollbar area")),
|
||||
vec![
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset,
|
||||
ratatui::style::Color::Reset
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_when_scrolled_away_from_bottom() {
|
||||
let full_area = Rect::new(0, 0, 10, 6);
|
||||
let (_, scrollbar_area) = split_transcript_area(full_area);
|
||||
let mut buf = Buffer::empty(full_area);
|
||||
|
||||
render_transcript_scrollbar_if_active(&mut buf, scrollbar_area, 100, 6, 80);
|
||||
|
||||
assert_eq!(
|
||||
scrollbar_bg(&buf, scrollbar_area.expect("scrollbar area")),
|
||||
vec![
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray,
|
||||
ratatui::style::Color::DarkGray
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_leaves_gap_before_scrollbar() {
|
||||
let full_area = Rect::new(0, 0, 10, 6);
|
||||
let (content, scrollbar) = split_transcript_area(full_area);
|
||||
|
||||
assert_eq!(content.width, 8);
|
||||
assert_eq!(scrollbar.expect("scrollbar").x, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_mouse_drag_moves_offset_downward() {
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
|
||||
let scrollbar_area = Rect::new(9, 0, 1, 10);
|
||||
let mut interaction = ScrollBarInteraction::new();
|
||||
|
||||
let down = MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 9,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
};
|
||||
let mut offset = 0;
|
||||
if let Some(next) = transcript_scrollbar_offset_for_mouse_event(
|
||||
scrollbar_area,
|
||||
100,
|
||||
10,
|
||||
offset,
|
||||
down,
|
||||
&mut interaction,
|
||||
true,
|
||||
) {
|
||||
offset = next;
|
||||
}
|
||||
|
||||
let drag = MouseEvent {
|
||||
kind: MouseEventKind::Drag(MouseButton::Left),
|
||||
column: 9,
|
||||
row: 9,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
};
|
||||
let dragged = transcript_scrollbar_offset_for_mouse_event(
|
||||
scrollbar_area,
|
||||
100,
|
||||
10,
|
||||
offset,
|
||||
drag,
|
||||
&mut interaction,
|
||||
true,
|
||||
)
|
||||
.expect("drag should set an offset");
|
||||
|
||||
assert!(dragged > offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_mode_thumb_is_darker_than_track() {
|
||||
let bg = (255, 255, 255);
|
||||
let fg = Some((0, 0, 0));
|
||||
let (track, thumb) = scrollbar_colors(bg, fg);
|
||||
|
||||
assert!(thumb.0 < track.0);
|
||||
assert!(thumb.1 < track.1);
|
||||
assert!(thumb.2 < track.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_mode_thumb_is_brighter_than_track() {
|
||||
let bg = (0, 0, 0);
|
||||
let fg = Some((255, 255, 255));
|
||||
let (track, thumb) = scrollbar_colors(bg, fg);
|
||||
|
||||
assert!(thumb.0 > track.0);
|
||||
assert!(thumb.1 > track.1);
|
||||
assert!(thumb.2 > track.2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Transcript scrollbar mouse interaction.
|
||||
//!
|
||||
//! This module handles pointer interaction (click/drag) for the transcript scrollbar rendered by
|
||||
//! [`crate::transcript_scrollbar`]. It exists to keep `app.rs` from growing further: the transcript
|
||||
//! is a particularly stateful part of the UI (selection, wrapping, scroll anchoring, copy, etc.),
|
||||
//! and scrollbar interaction needs to coordinate with several of those subsystems.
|
||||
//!
|
||||
//! # Responsibilities
|
||||
//!
|
||||
//! - Translate `crossterm` mouse events into `tui-scrollbar` interaction events (backend-agnostic
|
||||
//! [`tui_scrollbar::ScrollEvent`]).
|
||||
//! - Maintain `tui-scrollbar`’s drag interaction state (`ScrollBarInteraction`) across frames so
|
||||
//! the thumb "grab offset" behaves naturally.
|
||||
//! - Maintain a tiny "pointer capture" flag so a drag that reaches the bottom doesn't fall through
|
||||
//! into transcript selection once the scrollbar becomes visually hidden (because the view is now
|
||||
//! pinned to bottom).
|
||||
//!
|
||||
//! This module does *not* render anything. Rendering lives in `transcript_scrollbar.rs`.
|
||||
//!
|
||||
//! # Interaction model and transcript anchors
|
||||
//!
|
||||
//! `tui-scrollbar` reports requested scroll positions as a raw `offset` (a top-row index). The
|
||||
//! transcript scroll state in `codex-tui2` is represented as a stable anchor
|
||||
//! ([`crate::tui::scrolling::TranscriptScroll`]) so it survives transcript growth and re-wrapping.
|
||||
//!
|
||||
//! The conversion happens here:
|
||||
//! - Ask `tui-scrollbar` for a `next_offset`.
|
||||
//! - Convert that concrete offset back into a stable anchor using
|
||||
//! [`crate::tui::scrolling::TranscriptScroll::anchor_for`].
|
||||
//! - If the requested offset is the bottom-most valid position, use `ToBottom` rather than a fixed
|
||||
//! anchor, restoring auto-follow behavior.
|
||||
//!
|
||||
//! This keeps scrollbar interaction consistent with other scroll mechanisms (wheel, PgUp/PgDn),
|
||||
//! which also operate in terms of the `TranscriptScroll` state machine.
|
||||
//!
|
||||
//! # Upgrade note: Ratatui 0.30+
|
||||
//!
|
||||
//! This module intentionally uses `tui-scrollbar`’s backend-agnostic event types instead of its
|
||||
//! optional `crossterm` adapter. The workspace uses a patched `crossterm`, and enabling the adapter
|
||||
//! would pull in a second `crossterm` version, making `MouseEvent` types incompatible.
|
||||
//!
|
||||
//! Once `codex-tui2` upgrades to Ratatui 0.30 (and the workspace converges on a single `crossterm`
|
||||
//! version), we should revisit whether we can remove this translation layer.
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::transcript_scrollbar::is_transcript_scrollbar_active;
|
||||
use crate::transcript_scrollbar::transcript_scrollbar_offset_for_mouse_event;
|
||||
use crate::transcript_view_cache::TranscriptViewCache;
|
||||
use crate::tui;
|
||||
use crate::tui::scrolling::MouseScrollState;
|
||||
use crate::tui::scrolling::TranscriptScroll;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use ratatui::layout::Rect;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum TranscriptScrollbarMouseHandling {
|
||||
/// The event is unrelated to the scrollbar; callers may handle it normally (e.g. selection).
|
||||
NotHandled,
|
||||
/// The event was handled by the scrollbar logic and should not be interpreted as selection.
|
||||
Handled,
|
||||
}
|
||||
|
||||
/// Persistent UI state for transcript scrollbar pointer interaction.
|
||||
///
|
||||
/// This stores `tui-scrollbar`’s drag state (`ScrollBarInteraction`) plus a small "pointer capture"
|
||||
/// flag used by `codex-tui2`:
|
||||
///
|
||||
/// - When the user clicks the scrollbar thumb/track, we enter pointer capture.
|
||||
/// - While capture is active, subsequent drag events are treated as scrollbar drags even if the
|
||||
/// pointer leaves the 1-column track.
|
||||
/// - Capture is released on `MouseUp`.
|
||||
///
|
||||
/// The capture flag is important because the transcript scrollbar is hidden while pinned to
|
||||
/// bottom; without capture, a drag that reaches the bottom could stop producing offsets and fall
|
||||
/// through into transcript selection mid-gesture.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TranscriptScrollbarUi {
|
||||
interaction: tui_scrollbar::ScrollBarInteraction,
|
||||
pointer_capture: bool,
|
||||
}
|
||||
|
||||
/// Bundles the arguments needed to handle a transcript scrollbar mouse event.
|
||||
///
|
||||
/// This is intentionally a struct (rather than a long argument list) because scrollbar interaction
|
||||
/// touches several pieces of transcript state at once: wrapping cache, scroll anchor state, the
|
||||
/// concrete top-row offset, and the wheel-scroll stream state machine. Grouping them makes call
|
||||
/// sites easier to scan and helps keep `app.rs` glue minimal.
|
||||
pub(crate) struct TranscriptScrollbarMouseEvent<'a> {
|
||||
pub(crate) tui: &'a mut tui::Tui,
|
||||
pub(crate) mouse_event: MouseEvent,
|
||||
pub(crate) transcript_area: Rect,
|
||||
pub(crate) scrollbar_area: Option<Rect>,
|
||||
pub(crate) transcript_cells: &'a [Arc<dyn HistoryCell>],
|
||||
pub(crate) transcript_view_cache: &'a mut TranscriptViewCache,
|
||||
pub(crate) transcript_scroll: &'a mut TranscriptScroll,
|
||||
pub(crate) transcript_view_top: &'a mut usize,
|
||||
pub(crate) transcript_total_lines: &'a mut usize,
|
||||
pub(crate) mouse_scroll_state: &'a mut MouseScrollState,
|
||||
}
|
||||
|
||||
impl TranscriptScrollbarUi {
|
||||
pub(crate) fn pointer_capture_active(&self) -> bool {
|
||||
self.pointer_capture
|
||||
}
|
||||
|
||||
/// Handle click/drag events for the transcript scrollbar.
|
||||
///
|
||||
/// The caller is expected to provide the transcript layout for the current terminal size:
|
||||
/// `transcript_area` for content and `scrollbar_area` for the 1-column scrollbar track. See
|
||||
/// [`crate::transcript_scrollbar::split_transcript_area`].
|
||||
///
|
||||
/// Returns [`TranscriptScrollbarMouseHandling::Handled`] when the event should not be
|
||||
/// interpreted as transcript selection (either because it updated the scroll position or
|
||||
/// because an in-progress scrollbar drag is being captured).
|
||||
pub(crate) fn handle_mouse_event(
|
||||
&mut self,
|
||||
event: TranscriptScrollbarMouseEvent<'_>,
|
||||
) -> TranscriptScrollbarMouseHandling {
|
||||
let TranscriptScrollbarMouseEvent {
|
||||
tui,
|
||||
mouse_event,
|
||||
transcript_area,
|
||||
scrollbar_area,
|
||||
transcript_cells,
|
||||
transcript_view_cache,
|
||||
transcript_scroll,
|
||||
transcript_view_top,
|
||||
transcript_total_lines,
|
||||
mouse_scroll_state,
|
||||
} = event;
|
||||
let is_scrollbar_event = matches!(
|
||||
mouse_event.kind,
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
| MouseEventKind::Drag(MouseButton::Left)
|
||||
| MouseEventKind::Up(MouseButton::Left)
|
||||
);
|
||||
if !is_scrollbar_event {
|
||||
return TranscriptScrollbarMouseHandling::NotHandled;
|
||||
}
|
||||
|
||||
let Some(scrollbar_area) = scrollbar_area else {
|
||||
if matches!(mouse_event.kind, MouseEventKind::Up(MouseButton::Left)) {
|
||||
self.pointer_capture = false;
|
||||
}
|
||||
return if self.pointer_capture {
|
||||
TranscriptScrollbarMouseHandling::Handled
|
||||
} else {
|
||||
TranscriptScrollbarMouseHandling::NotHandled
|
||||
};
|
||||
};
|
||||
|
||||
let is_over_scrollbar = mouse_event.column >= scrollbar_area.x
|
||||
&& mouse_event.column < scrollbar_area.right()
|
||||
&& mouse_event.row >= scrollbar_area.y
|
||||
&& mouse_event.row < scrollbar_area.bottom();
|
||||
|
||||
if !self.pointer_capture && !is_over_scrollbar {
|
||||
return TranscriptScrollbarMouseHandling::NotHandled;
|
||||
}
|
||||
|
||||
let viewport_lines = transcript_area.height as usize;
|
||||
let scrollbar_is_visible = if viewport_lines > 0 && !transcript_cells.is_empty() {
|
||||
transcript_view_cache.ensure_wrapped(transcript_cells, transcript_area.width);
|
||||
let total_lines = transcript_view_cache.lines().len();
|
||||
let max_visible = std::cmp::min(total_lines, viewport_lines);
|
||||
is_transcript_scrollbar_active(total_lines, max_visible, *transcript_view_top)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// When the transcript is pinned to bottom, we intentionally hide the scrollbar (but still
|
||||
// reserve its column). In that state, we avoid hit-testing the scrollbar track so the
|
||||
// reserved column doesn't become an invisible interactive region. Pointer capture remains
|
||||
// active for an in-progress drag so a gesture that reaches the bottom doesn't fall through
|
||||
// into transcript selection mid-drag.
|
||||
if !self.pointer_capture && !scrollbar_is_visible {
|
||||
return TranscriptScrollbarMouseHandling::NotHandled;
|
||||
}
|
||||
|
||||
if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) && is_over_scrollbar
|
||||
{
|
||||
self.pointer_capture = true;
|
||||
}
|
||||
|
||||
if viewport_lines > 0 && !transcript_cells.is_empty() {
|
||||
// `ensure_wrapped` was already called above when checking visibility.
|
||||
let total_lines = transcript_view_cache.lines().len();
|
||||
let max_visible = std::cmp::min(total_lines, viewport_lines);
|
||||
let max_offset = total_lines.saturating_sub(max_visible);
|
||||
|
||||
if let Some(next_offset) = transcript_scrollbar_offset_for_mouse_event(
|
||||
scrollbar_area,
|
||||
total_lines,
|
||||
max_visible,
|
||||
*transcript_view_top,
|
||||
mouse_event,
|
||||
&mut self.interaction,
|
||||
self.pointer_capture,
|
||||
) {
|
||||
let next_offset = next_offset.min(max_offset);
|
||||
let line_meta = transcript_view_cache.line_meta();
|
||||
|
||||
*transcript_scroll = if next_offset >= max_offset {
|
||||
TranscriptScroll::ToBottom
|
||||
} else {
|
||||
TranscriptScroll::anchor_for(line_meta, next_offset)
|
||||
.unwrap_or(TranscriptScroll::ToBottom)
|
||||
};
|
||||
*transcript_view_top = next_offset.min(max_offset);
|
||||
*transcript_total_lines = total_lines;
|
||||
*mouse_scroll_state = MouseScrollState::default();
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(mouse_event.kind, MouseEventKind::Up(MouseButton::Left)) {
|
||||
self.pointer_capture = false;
|
||||
}
|
||||
|
||||
TranscriptScrollbarMouseHandling::Handled
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@
|
||||
//! the newly flattened line list on the next frame.
|
||||
//!
|
||||
//! Spacer rows between non-continuation cells are represented as `TranscriptLineMeta::Spacer`.
|
||||
//! They are not valid anchors; `anchor_for` will pick the nearest non-spacer line when needed.
|
||||
//! They are valid scroll anchors so 1-line scrolling does not "stick" at cell boundaries.
|
||||
|
||||
pub(crate) mod mouse;
|
||||
pub(crate) use mouse::MouseScrollState;
|
||||
@@ -78,6 +78,13 @@ pub(crate) enum TranscriptScroll {
|
||||
cell_index: usize,
|
||||
line_in_cell: usize,
|
||||
},
|
||||
/// Anchor the viewport to the spacer row immediately before a cell.
|
||||
///
|
||||
/// This exists because spacer rows are real, visible transcript rows, and users may scroll
|
||||
/// through them one line at a time (especially with trackpads). Without a dedicated spacer
|
||||
/// anchor, a 1-line scroll that lands on a spacer would snap back to the adjacent cell line
|
||||
/// and appear to "stick" at boundaries.
|
||||
ScrolledSpacerBeforeCell { cell_index: usize },
|
||||
}
|
||||
|
||||
impl TranscriptScroll {
|
||||
@@ -108,6 +115,13 @@ impl TranscriptScroll {
|
||||
None => (Self::ToBottom, max_start),
|
||||
}
|
||||
}
|
||||
Self::ScrolledSpacerBeforeCell { cell_index } => {
|
||||
let anchor = spacer_before_cell_index(line_meta, cell_index);
|
||||
match anchor {
|
||||
Some(idx) => (self, idx.min(max_start)),
|
||||
None => (Self::ToBottom, max_start),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +156,11 @@ impl TranscriptScroll {
|
||||
} => anchor_index(line_meta, cell_index, line_in_cell)
|
||||
.unwrap_or(max_start)
|
||||
.min(max_start),
|
||||
Self::ScrolledSpacerBeforeCell { cell_index } => {
|
||||
spacer_before_cell_index(line_meta, cell_index)
|
||||
.unwrap_or(max_start)
|
||||
.min(max_start)
|
||||
}
|
||||
};
|
||||
|
||||
let new_top = if delta_lines < 0 {
|
||||
@@ -164,15 +183,35 @@ impl TranscriptScroll {
|
||||
/// This is the inverse of "resolving a scroll state to a top-row offset":
|
||||
/// given a concrete flattened line index, pick a stable `(cell_index, line_in_cell)` anchor.
|
||||
///
|
||||
/// See `resolve_top` for `line_meta` semantics. This prefers the nearest line at or after `start`
|
||||
/// (skipping spacer rows), falling back to the nearest line before it when needed.
|
||||
/// See `resolve_top` for `line_meta` semantics. This prefers the line at `start` (including
|
||||
/// spacer rows), falling back to the nearest non-spacer line after or before it when needed.
|
||||
pub(crate) fn anchor_for(line_meta: &[TranscriptLineMeta], start: usize) -> Option<Self> {
|
||||
let anchor =
|
||||
anchor_at_or_after(line_meta, start).or_else(|| anchor_at_or_before(line_meta, start));
|
||||
anchor.map(|(cell_index, line_in_cell)| Self::Scrolled {
|
||||
cell_index,
|
||||
line_in_cell,
|
||||
})
|
||||
if line_meta.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start = start.min(line_meta.len().saturating_sub(1));
|
||||
match line_meta[start] {
|
||||
TranscriptLineMeta::CellLine {
|
||||
cell_index,
|
||||
line_in_cell,
|
||||
} => Some(Self::Scrolled {
|
||||
cell_index,
|
||||
line_in_cell,
|
||||
}),
|
||||
TranscriptLineMeta::Spacer => {
|
||||
if let Some((cell_index, _)) = anchor_at_or_after(line_meta, start) {
|
||||
Some(Self::ScrolledSpacerBeforeCell { cell_index })
|
||||
} else {
|
||||
anchor_at_or_before(line_meta, start).map(|(cell_index, line_in_cell)| {
|
||||
Self::Scrolled {
|
||||
cell_index,
|
||||
line_in_cell,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +237,26 @@ fn anchor_index(
|
||||
})
|
||||
}
|
||||
|
||||
/// Locate the flattened line index for the spacer row immediately before `cell_index`.
|
||||
///
|
||||
/// The spacer itself is not uniquely tagged in `TranscriptLineMeta`, so we locate the first
|
||||
/// visual line of the cell (`line_in_cell == 0`) and, if it is preceded by a spacer row, return
|
||||
/// that spacer's index. If the spacer is missing (for example when the cell is a stream
|
||||
/// continuation), we fall back to the cell's first line index so scrolling remains usable.
|
||||
fn spacer_before_cell_index(line_meta: &[TranscriptLineMeta], cell_index: usize) -> Option<usize> {
|
||||
let cell_first = anchor_index(line_meta, cell_index, 0)?;
|
||||
if cell_first > 0
|
||||
&& matches!(
|
||||
line_meta.get(cell_first.saturating_sub(1)),
|
||||
Some(TranscriptLineMeta::Spacer)
|
||||
)
|
||||
{
|
||||
Some(cell_first.saturating_sub(1))
|
||||
} else {
|
||||
Some(cell_first)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first transcript line at or after the given flattened index.
|
||||
fn anchor_at_or_after(line_meta: &[TranscriptLineMeta], start: usize) -> Option<(usize, usize)> {
|
||||
if line_meta.is_empty() {
|
||||
@@ -272,6 +331,33 @@ mod tests {
|
||||
assert_eq!(top, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolled_by_can_land_on_spacer_rows() {
|
||||
let meta = meta(&[
|
||||
cell_line(0, 0),
|
||||
TranscriptLineMeta::Spacer,
|
||||
cell_line(1, 0),
|
||||
cell_line(1, 1),
|
||||
]);
|
||||
|
||||
let scroll = TranscriptScroll::Scrolled {
|
||||
cell_index: 1,
|
||||
line_in_cell: 0,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
scroll.scrolled_by(-1, &meta, 2),
|
||||
TranscriptScroll::ScrolledSpacerBeforeCell { cell_index: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
TranscriptScroll::ScrolledSpacerBeforeCell { cell_index: 1 }.scrolled_by(-1, &meta, 2),
|
||||
TranscriptScroll::Scrolled {
|
||||
cell_index: 0,
|
||||
line_in_cell: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_top_scrolled_falls_back_when_anchor_missing() {
|
||||
let meta = meta(&[cell_line(0, 0), TranscriptLineMeta::Spacer, cell_line(1, 0)]);
|
||||
@@ -350,17 +436,11 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
TranscriptScroll::anchor_for(&meta, 0),
|
||||
Some(TranscriptScroll::Scrolled {
|
||||
cell_index: 0,
|
||||
line_in_cell: 0
|
||||
})
|
||||
Some(TranscriptScroll::ScrolledSpacerBeforeCell { cell_index: 0 })
|
||||
);
|
||||
assert_eq!(
|
||||
TranscriptScroll::anchor_for(&meta, 2),
|
||||
Some(TranscriptScroll::Scrolled {
|
||||
cell_index: 1,
|
||||
line_in_cell: 0
|
||||
})
|
||||
Some(TranscriptScroll::ScrolledSpacerBeforeCell { cell_index: 1 })
|
||||
);
|
||||
assert_eq!(
|
||||
TranscriptScroll::anchor_for(&meta, 3),
|
||||
|
||||
@@ -30,7 +30,7 @@ const SKIP_DIR_SUFFIXES: &[&str] = &[
|
||||
"/programdata",
|
||||
];
|
||||
|
||||
fn normalize_path_key(p: &Path) -> String {
|
||||
pub(crate) fn normalize_path_key(p: &Path) -> String {
|
||||
let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
n.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#![cfg(target_os = "windows")]
|
||||
|
||||
use anyhow::Result;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
use windows::core::Interface;
|
||||
use windows::core::BSTR;
|
||||
use windows::Win32::Foundation::VARIANT_TRUE;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::INetFwPolicy2;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRule3;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NetFwPolicy2;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NetFwRule;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_ACTION_BLOCK;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_ANY;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_PROFILE2_ALL;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_RULE_DIR_OUT;
|
||||
use windows::Win32::System::Com::CoCreateInstance;
|
||||
use windows::Win32::System::Com::CoInitializeEx;
|
||||
use windows::Win32::System::Com::CoUninitialize;
|
||||
use windows::Win32::System::Com::CLSCTX_INPROC_SERVER;
|
||||
use windows::Win32::System::Com::COINIT_APARTMENTTHREADED;
|
||||
|
||||
// This is the stable identifier we use to find/update the rule idempotently.
|
||||
// It intentionally does not change between installs.
|
||||
const OFFLINE_BLOCK_RULE_NAME: &str = "codex_sandbox_offline_block_outbound";
|
||||
|
||||
// Friendly text shown in the firewall UI.
|
||||
const OFFLINE_BLOCK_RULE_FRIENDLY: &str = "Codex Sandbox Offline - Block Outbound";
|
||||
|
||||
pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Result<()> {
|
||||
let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})");
|
||||
|
||||
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
|
||||
if hr.is_err() {
|
||||
return Err(anyhow::anyhow!("CoInitializeEx failed: {hr:?}"));
|
||||
}
|
||||
|
||||
let result = unsafe {
|
||||
(|| -> Result<()> {
|
||||
let policy: INetFwPolicy2 = CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(|e| anyhow::anyhow!("CoCreateInstance NetFwPolicy2: {e:?}"))?;
|
||||
let rules = policy
|
||||
.Rules()
|
||||
.map_err(|e| anyhow::anyhow!("INetFwPolicy2::Rules: {e:?}"))?;
|
||||
|
||||
// Block all outbound IP protocols for this user.
|
||||
ensure_block_rule(
|
||||
&rules,
|
||||
OFFLINE_BLOCK_RULE_NAME,
|
||||
OFFLINE_BLOCK_RULE_FRIENDLY,
|
||||
NET_FW_IP_PROTOCOL_ANY.0,
|
||||
&local_user_spec,
|
||||
offline_sid,
|
||||
log,
|
||||
)?;
|
||||
Ok(())
|
||||
})()
|
||||
};
|
||||
|
||||
unsafe {
|
||||
CoUninitialize();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn ensure_block_rule(
|
||||
rules: &windows::Win32::NetworkManagement::WindowsFirewall::INetFwRules,
|
||||
internal_name: &str,
|
||||
friendly_desc: &str,
|
||||
protocol: i32,
|
||||
local_user_spec: &str,
|
||||
offline_sid: &str,
|
||||
log: &mut File,
|
||||
) -> Result<()> {
|
||||
let name = BSTR::from(internal_name);
|
||||
let rule: INetFwRule3 = match unsafe { rules.Item(&name) } {
|
||||
Ok(existing) => existing
|
||||
.cast()
|
||||
.map_err(|e| anyhow::anyhow!("cast existing firewall rule to INetFwRule3: {e:?}"))?,
|
||||
Err(_) => {
|
||||
let new_rule: INetFwRule3 =
|
||||
unsafe { CoCreateInstance(&NetFwRule, None, CLSCTX_INPROC_SERVER) }
|
||||
.map_err(|e| anyhow::anyhow!("CoCreateInstance NetFwRule: {e:?}"))?;
|
||||
unsafe { new_rule.SetName(&name) }.map_err(|e| anyhow::anyhow!("SetName: {e:?}"))?;
|
||||
// Set all properties before adding the rule so we don't leave half-configured rules.
|
||||
configure_rule(
|
||||
&new_rule,
|
||||
friendly_desc,
|
||||
protocol,
|
||||
local_user_spec,
|
||||
offline_sid,
|
||||
)?;
|
||||
unsafe { rules.Add(&new_rule) }.map_err(|e| anyhow::anyhow!("Rules::Add: {e:?}"))?;
|
||||
new_rule
|
||||
}
|
||||
};
|
||||
|
||||
// Always re-apply fields to keep the setup idempotent.
|
||||
configure_rule(&rule, friendly_desc, protocol, local_user_spec, offline_sid)?;
|
||||
|
||||
log_line(
|
||||
log,
|
||||
&format!(
|
||||
"firewall rule configured name={internal_name} protocol={protocol} LocalUserAuthorizedList={local_user_spec}"
|
||||
),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_rule(
|
||||
rule: &INetFwRule3,
|
||||
friendly_desc: &str,
|
||||
protocol: i32,
|
||||
local_user_spec: &str,
|
||||
offline_sid: &str,
|
||||
) -> Result<()> {
|
||||
unsafe {
|
||||
rule.SetDescription(&BSTR::from(friendly_desc))
|
||||
.map_err(|e| anyhow::anyhow!("SetDescription: {e:?}"))?;
|
||||
rule.SetDirection(NET_FW_RULE_DIR_OUT)
|
||||
.map_err(|e| anyhow::anyhow!("SetDirection: {e:?}"))?;
|
||||
rule.SetAction(NET_FW_ACTION_BLOCK)
|
||||
.map_err(|e| anyhow::anyhow!("SetAction: {e:?}"))?;
|
||||
rule.SetEnabled(VARIANT_TRUE)
|
||||
.map_err(|e| anyhow::anyhow!("SetEnabled: {e:?}"))?;
|
||||
rule.SetProfiles(NET_FW_PROFILE2_ALL.0)
|
||||
.map_err(|e| anyhow::anyhow!("SetProfiles: {e:?}"))?;
|
||||
rule.SetProtocol(protocol)
|
||||
.map_err(|e| anyhow::anyhow!("SetProtocol: {e:?}"))?;
|
||||
rule.SetLocalUserAuthorizedList(&BSTR::from(local_user_spec))
|
||||
.map_err(|e| anyhow::anyhow!("SetLocalUserAuthorizedList: {e:?}"))?;
|
||||
}
|
||||
|
||||
// Read-back verification: ensure we actually wrote the expected SID scope.
|
||||
let actual = unsafe { rule.LocalUserAuthorizedList() }
|
||||
.map_err(|e| anyhow::anyhow!("LocalUserAuthorizedList (read-back): {e:?}"))?;
|
||||
let actual_str = actual.to_string();
|
||||
if !actual_str.contains(offline_sid) {
|
||||
anyhow::bail!(
|
||||
"offline firewall rule user scope mismatch: expected SID {offline_sid}, got {actual_str}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_line(log: &mut File, msg: &str) -> Result<()> {
|
||||
let ts = chrono::Utc::now().to_rfc3339();
|
||||
writeln!(log, "[{ts}] {msg}")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -119,9 +119,10 @@ pub fn require_logon_sandbox_creds(
|
||||
) -> Result<SandboxCreds> {
|
||||
let sandbox_dir = crate::setup::sandbox_dir(codex_home);
|
||||
let needed_read = gather_read_roots(command_cwd, policy);
|
||||
let mut needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map);
|
||||
// Ensure the sandbox directory itself is writable by sandbox users.
|
||||
needed_write.push(sandbox_dir.clone());
|
||||
let needed_write = gather_write_roots(policy, policy_cwd, command_cwd, env_map);
|
||||
// NOTE: Do not add CODEX_HOME/.sandbox to `needed_write`; it must remain non-writable by the
|
||||
// restricted capability token. The setup helper's `lock_sandbox_dir` is responsible for
|
||||
// granting the sandbox group access to this directory without granting the capability SID.
|
||||
let mut setup_reason: Option<String> = None;
|
||||
let mut _existing_marker: Option<SetupMarker> = None;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#![cfg(target_os = "windows")]
|
||||
|
||||
mod firewall;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
@@ -28,22 +30,6 @@ use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::sync::mpsc;
|
||||
use windows::core::Interface;
|
||||
use windows::core::BSTR;
|
||||
use windows::Win32::Foundation::VARIANT_TRUE;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::INetFwPolicy2;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRule3;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NetFwPolicy2;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NetFwRule;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_ACTION_BLOCK;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_ANY;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_PROFILE2_ALL;
|
||||
use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_RULE_DIR_OUT;
|
||||
use windows::Win32::System::Com::CoCreateInstance;
|
||||
use windows::Win32::System::Com::CoInitializeEx;
|
||||
use windows::Win32::System::Com::CoUninitialize;
|
||||
use windows::Win32::System::Com::CLSCTX_INPROC_SERVER;
|
||||
use windows::Win32::System::Com::COINIT_APARTMENTTHREADED;
|
||||
use windows_sys::Win32::Foundation::GetLastError;
|
||||
use windows_sys::Win32::Foundation::LocalFree;
|
||||
use windows_sys::Win32::Foundation::HLOCAL;
|
||||
@@ -239,79 +225,6 @@ fn read_mask_allows_or_log(
|
||||
}
|
||||
}
|
||||
|
||||
fn run_netsh_firewall(sid: &str, log: &mut File) -> Result<()> {
|
||||
let local_user_spec = format!("O:LSD:(A;;CC;;;{sid})");
|
||||
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
|
||||
if hr.is_err() {
|
||||
return Err(anyhow::anyhow!("CoInitializeEx failed: {hr:?}"));
|
||||
}
|
||||
let result = unsafe {
|
||||
(|| -> Result<()> {
|
||||
let policy: INetFwPolicy2 = CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(|e| anyhow::anyhow!("CoCreateInstance NetFwPolicy2: {e:?}"))?;
|
||||
let rules = policy
|
||||
.Rules()
|
||||
.map_err(|e| anyhow::anyhow!("INetFwPolicy2::Rules: {e:?}"))?;
|
||||
let name = BSTR::from("Codex Sandbox Offline - Block Outbound");
|
||||
let rule: INetFwRule3 = match rules.Item(&name) {
|
||||
Ok(existing) => existing.cast().map_err(|e| {
|
||||
anyhow::anyhow!("cast existing firewall rule to INetFwRule3: {e:?}")
|
||||
})?,
|
||||
Err(_) => {
|
||||
let new_rule: INetFwRule3 =
|
||||
CoCreateInstance(&NetFwRule, None, CLSCTX_INPROC_SERVER)
|
||||
.map_err(|e| anyhow::anyhow!("CoCreateInstance NetFwRule: {e:?}"))?;
|
||||
new_rule
|
||||
.SetName(&name)
|
||||
.map_err(|e| anyhow::anyhow!("SetName: {e:?}"))?;
|
||||
new_rule
|
||||
.SetDirection(NET_FW_RULE_DIR_OUT)
|
||||
.map_err(|e| anyhow::anyhow!("SetDirection: {e:?}"))?;
|
||||
new_rule
|
||||
.SetAction(NET_FW_ACTION_BLOCK)
|
||||
.map_err(|e| anyhow::anyhow!("SetAction: {e:?}"))?;
|
||||
new_rule
|
||||
.SetEnabled(VARIANT_TRUE)
|
||||
.map_err(|e| anyhow::anyhow!("SetEnabled: {e:?}"))?;
|
||||
new_rule
|
||||
.SetProfiles(NET_FW_PROFILE2_ALL.0)
|
||||
.map_err(|e| anyhow::anyhow!("SetProfiles: {e:?}"))?;
|
||||
new_rule
|
||||
.SetProtocol(NET_FW_IP_PROTOCOL_ANY.0)
|
||||
.map_err(|e| anyhow::anyhow!("SetProtocol: {e:?}"))?;
|
||||
rules
|
||||
.Add(&new_rule)
|
||||
.map_err(|e| anyhow::anyhow!("Rules::Add: {e:?}"))?;
|
||||
new_rule
|
||||
}
|
||||
};
|
||||
rule.SetLocalUserAuthorizedList(&BSTR::from(local_user_spec.as_str()))
|
||||
.map_err(|e| anyhow::anyhow!("SetLocalUserAuthorizedList: {e:?}"))?;
|
||||
rule.SetEnabled(VARIANT_TRUE)
|
||||
.map_err(|e| anyhow::anyhow!("SetEnabled: {e:?}"))?;
|
||||
rule.SetProfiles(NET_FW_PROFILE2_ALL.0)
|
||||
.map_err(|e| anyhow::anyhow!("SetProfiles: {e:?}"))?;
|
||||
rule.SetAction(NET_FW_ACTION_BLOCK)
|
||||
.map_err(|e| anyhow::anyhow!("SetAction: {e:?}"))?;
|
||||
rule.SetDirection(NET_FW_RULE_DIR_OUT)
|
||||
.map_err(|e| anyhow::anyhow!("SetDirection: {e:?}"))?;
|
||||
rule.SetProtocol(NET_FW_IP_PROTOCOL_ANY.0)
|
||||
.map_err(|e| anyhow::anyhow!("SetProtocol: {e:?}"))?;
|
||||
log_line(
|
||||
log,
|
||||
&format!(
|
||||
"firewall rule configured via COM with LocalUserAuthorizedList={local_user_spec}"
|
||||
),
|
||||
)?;
|
||||
Ok(())
|
||||
})()
|
||||
};
|
||||
unsafe {
|
||||
CoUninitialize();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn lock_sandbox_dir(
|
||||
dir: &Path,
|
||||
real_user: &str,
|
||||
@@ -549,7 +462,7 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<(
|
||||
};
|
||||
let mut refresh_errors: Vec<String> = Vec::new();
|
||||
if !refresh_only {
|
||||
run_netsh_firewall(&offline_sid_str, log)?;
|
||||
firewall::ensure_offline_outbound_block(&offline_sid_str, log)?;
|
||||
}
|
||||
|
||||
if payload.read_roots.is_empty() {
|
||||
|
||||
@@ -408,15 +408,12 @@ fn build_payload_roots(
|
||||
read_roots_override: Option<Vec<PathBuf>>,
|
||||
write_roots_override: Option<Vec<PathBuf>>,
|
||||
) -> (Vec<PathBuf>, Vec<PathBuf>) {
|
||||
let sbx_dir = sandbox_dir(codex_home);
|
||||
let mut write_roots = if let Some(roots) = write_roots_override {
|
||||
let write_roots = if let Some(roots) = write_roots_override {
|
||||
canonical_existing(&roots)
|
||||
} else {
|
||||
gather_write_roots(policy, policy_cwd, command_cwd, env_map)
|
||||
};
|
||||
if !write_roots.contains(&sbx_dir) {
|
||||
write_roots.push(sbx_dir.clone());
|
||||
}
|
||||
let write_roots = filter_sensitive_write_roots(write_roots, codex_home);
|
||||
let mut read_roots = if let Some(roots) = read_roots_override {
|
||||
canonical_existing(&roots)
|
||||
} else {
|
||||
@@ -426,3 +423,17 @@ fn build_payload_roots(
|
||||
read_roots.retain(|root| !write_root_set.contains(root));
|
||||
(read_roots, write_roots)
|
||||
}
|
||||
|
||||
fn filter_sensitive_write_roots(mut roots: Vec<PathBuf>, codex_home: &Path) -> Vec<PathBuf> {
|
||||
// Never grant capability write access to CODEX_HOME or anything under CODEX_HOME/.sandbox.
|
||||
// These locations contain sandbox control/state and must remain tamper-resistant.
|
||||
let codex_home_key = crate::audit::normalize_path_key(codex_home);
|
||||
let sbx_dir_key = crate::audit::normalize_path_key(&sandbox_dir(codex_home));
|
||||
let sbx_dir_prefix = format!("{}/", sbx_dir_key.trim_end_matches('/'));
|
||||
|
||||
roots.retain(|root| {
|
||||
let key = crate::audit::normalize_path_key(root);
|
||||
key != codex_home_key && key != sbx_dir_key && !key.starts_with(&sbx_dir_prefix)
|
||||
});
|
||||
roots
|
||||
}
|
||||
|
||||
@@ -5,3 +5,15 @@ For basic configuration instructions, see [this documentation](https://developer
|
||||
For advanced configuration instructions, see [this documentation](https://developers.openai.com/codex/config-advanced).
|
||||
|
||||
For a full configuration reference, see [this documentation](https://developers.openai.com/codex/config-reference).
|
||||
|
||||
## Connecting to MCP servers
|
||||
|
||||
Codex can connect to MCP servers configured in `~/.codex/config.toml`. See the configuration reference for the latest MCP server options:
|
||||
|
||||
- https://developers.openai.com/codex/config-reference
|
||||
|
||||
## Notify
|
||||
|
||||
Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings:
|
||||
|
||||
- https://developers.openai.com/codex/config-reference
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ rustup component add rustfmt
|
||||
rustup component add clippy
|
||||
# Install helper tools used by the workspace justfile:
|
||||
cargo install just
|
||||
# Optional: install nextest for the `just test` helper (or use `cargo test --all-features` as a fallback)
|
||||
# Optional: install nextest for the `just test` helper
|
||||
cargo install cargo-nextest
|
||||
|
||||
# Build Codex.
|
||||
@@ -41,9 +41,9 @@ just fix -p <crate-you-touched>
|
||||
|
||||
# Run the relevant tests (project-specific is fastest), for example:
|
||||
cargo test -p codex-tui
|
||||
# If you have cargo-nextest installed, `just test` runs the full suite:
|
||||
# If you have cargo-nextest installed, `just test` runs the test suite via nextest:
|
||||
just test
|
||||
# Otherwise, fall back to:
|
||||
# If you specifically want the full `--all-features` matrix, use:
|
||||
cargo test --all-features
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Performance testing (`codex-tui2`)
|
||||
|
||||
This doc captures a repeatable workflow for investigating `codex-tui2` performance issues
|
||||
(especially high idle CPU and high CPU while streaming) and validating optimizations to the draw
|
||||
hot path.
|
||||
|
||||
## Scope (this round)
|
||||
|
||||
The current focus is the transcript draw hot path, specifically the cost of repeatedly rendering
|
||||
the same visible transcript lines via Ratatui’s `Line::render_ref` (notably grapheme segmentation
|
||||
and span layout).
|
||||
|
||||
The intended mitigation is a **rasterization cache**: render a wrapped transcript `Line` into a
|
||||
row of `Cell`s once, cache it, and on subsequent redraws copy cached cells into the frame buffer.
|
||||
|
||||
Key invariants:
|
||||
|
||||
- The cache is width-scoped (invalidate on terminal width changes).
|
||||
- The cache stores **base content** only; selection highlight and copy affordances are applied
|
||||
after rendering, so they don’t pollute cached rows.
|
||||
|
||||
## Roles
|
||||
|
||||
- Human: runs `codex-tui2` in an interactive terminal (e.g. Ghostty), triggers “idle” and
|
||||
“streaming” scenarios, and captures profiles.
|
||||
- Assistant (or a script): reads profile output and extracts hotspots and deltas.
|
||||
|
||||
## Baseline setup
|
||||
|
||||
Build from a clean checkout:
|
||||
|
||||
```sh
|
||||
cd codex-rs
|
||||
cargo build -p codex-tui2
|
||||
```
|
||||
|
||||
Run `codex-tui2` in a terminal and get a PID (macOS):
|
||||
|
||||
```sh
|
||||
pgrep -n codex-tui2
|
||||
```
|
||||
|
||||
Track CPU quickly while reproducing:
|
||||
|
||||
```sh
|
||||
top -pid "$(pgrep -n codex-tui2)"
|
||||
```
|
||||
|
||||
## Capture profiles (macOS)
|
||||
|
||||
Capture both an “idle” and a “streaming” profile so hotspots are not conflated:
|
||||
|
||||
```sh
|
||||
sample "$(pgrep -n codex-tui2)" 1 -file /tmp/tui2.idle.sample.txt
|
||||
sample "$(pgrep -n codex-tui2)" 1 -file /tmp/tui2.streaming.sample.txt
|
||||
```
|
||||
|
||||
For the streaming sample, trigger a response that emits many deltas (e.g. “Tell me a story”) so
|
||||
the stream runs long enough to sample.
|
||||
|
||||
## Quick hotspot extraction
|
||||
|
||||
These `rg` patterns keep the investigation grounded in the data:
|
||||
|
||||
```sh
|
||||
# Buffer diff hot path (idle)
|
||||
rg -n "custom_terminal::diff_buffers|diff_buffers" /tmp/tui2.*.sample.txt | head -n 80
|
||||
|
||||
# Transcript rendering hot path (streaming)
|
||||
rg -n "App::render_transcript_cells|Line::render|render_spans|styled_graphemes|GraphemeCursor::next_boundary" /tmp/tui2.*.sample.txt | head -n 120
|
||||
```
|
||||
|
||||
## Rasterization-cache validation checklist
|
||||
|
||||
After implementing a transcript rasterization cache, re-run the same scenarios and confirm:
|
||||
|
||||
- Streaming sample shifts away from `unicode_segmentation::grapheme::GraphemeCursor::next_boundary`
|
||||
stacks dominating the main thread.
|
||||
- CPU during streaming drops materially vs baseline for the same streaming load.
|
||||
- Idle CPU does not regress (redraw gating changes can mask rendering improvements; always measure
|
||||
both idle and streaming).
|
||||
|
||||
## Notes to record per run
|
||||
|
||||
- Terminal size: width × height
|
||||
- Scenario: idle vs streaming (prompt + approximate response length)
|
||||
- CPU snapshot: `top` (directional)
|
||||
- Profile excerpt: 20–50 relevant lines for the dominant stacks
|
||||
|
||||
## Code pointers
|
||||
|
||||
- `codex-rs/tui2/src/transcript_view_cache.rs`: wrapped transcript memoization + per-line
|
||||
rasterization cache (cached `Cell` rows).
|
||||
- `codex-rs/tui2/src/transcript_render.rs`: incremental helper used by the wrapped-line cache
|
||||
(`append_wrapped_transcript_cell`).
|
||||
- `codex-rs/tui2/src/app.rs`: wiring in `App::render_transcript_cells` (uses cached rows instead of
|
||||
calling `Line::render_ref` every frame).
|
||||
Reference in New Issue
Block a user