mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
Merge branch 'main' into worktree-core
This commit is contained in:
@@ -27,6 +27,34 @@
|
||||
"path": "codex.exe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codex-responses-api-proxy": {
|
||||
"platforms": {
|
||||
"macos-aarch64": {
|
||||
"regex": "^codex-responses-api-proxy-aarch64-apple-darwin\\.zst$",
|
||||
"path": "codex-responses-api-proxy"
|
||||
},
|
||||
"macos-x86_64": {
|
||||
"regex": "^codex-responses-api-proxy-x86_64-apple-darwin\\.zst$",
|
||||
"path": "codex-responses-api-proxy"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"regex": "^codex-responses-api-proxy-x86_64-unknown-linux-musl\\.zst$",
|
||||
"path": "codex-responses-api-proxy"
|
||||
},
|
||||
"linux-aarch64": {
|
||||
"regex": "^codex-responses-api-proxy-aarch64-unknown-linux-musl\\.zst$",
|
||||
"path": "codex-responses-api-proxy"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"regex": "^codex-responses-api-proxy-x86_64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-responses-api-proxy.exe"
|
||||
},
|
||||
"windows-aarch64": {
|
||||
"regex": "^codex-responses-api-proxy-aarch64-pc-windows-msvc\\.exe\\.zst$",
|
||||
"path": "codex-responses-api-proxy.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request: { branches: [main] }
|
||||
pull_request: {}
|
||||
push: { branches: [main] }
|
||||
|
||||
jobs:
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
- uses: facebook/install-dotslash@v2
|
||||
|
||||
- name: Stage npm package
|
||||
id: stage_npm_package
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -40,13 +41,13 @@ jobs:
|
||||
python3 ./codex-cli/scripts/build_npm_package.py \
|
||||
--release-version "$CODEX_VERSION" \
|
||||
--pack-output "$PACK_OUTPUT"
|
||||
echo "PACK_OUTPUT=$PACK_OUTPUT" >> "$GITHUB_ENV"
|
||||
echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload staged npm package artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: codex-npm-staging
|
||||
path: ${{ env.PACK_OUTPUT }}
|
||||
path: ${{ steps.stage_npm_package.outputs.pack_output }}
|
||||
|
||||
- name: Ensure root README.md contains only ASCII and certain Unicode code points
|
||||
run: ./scripts/asciicheck.py README.md
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
sudo apt install -y musl-tools pkg-config
|
||||
|
||||
- name: Cargo build
|
||||
run: cargo build --target ${{ matrix.target }} --release --bin codex
|
||||
run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy
|
||||
|
||||
- name: Stage artifacts
|
||||
shell: bash
|
||||
@@ -107,8 +107,10 @@ jobs:
|
||||
|
||||
if [[ "${{ matrix.runner }}" == windows* ]]; then
|
||||
cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe"
|
||||
cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe"
|
||||
else
|
||||
cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}"
|
||||
cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- if: ${{ matrix.runner == 'windows-11-arm' }}
|
||||
@@ -216,17 +218,30 @@ jobs:
|
||||
|
||||
# build_npm_package.py requires DotSlash when staging releases.
|
||||
- uses: facebook/install-dotslash@v2
|
||||
- name: Stage npm package
|
||||
- name: Stage codex CLI npm package
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TMP_DIR="${RUNNER_TEMP}/npm-stage"
|
||||
./codex-cli/scripts/build_npm_package.py \
|
||||
--package codex \
|
||||
--release-version "${{ steps.release_name.outputs.name }}" \
|
||||
--staging-dir "${TMP_DIR}" \
|
||||
--pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz"
|
||||
|
||||
- name: Stage responses API proxy npm package
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TMP_DIR="${RUNNER_TEMP}/npm-stage-responses"
|
||||
./codex-cli/scripts/build_npm_package.py \
|
||||
--package codex-responses-api-proxy \
|
||||
--release-version "${{ steps.release_name.outputs.name }}" \
|
||||
--staging-dir "${TMP_DIR}" \
|
||||
--pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-responses-api-proxy-npm-${{ steps.release_name.outputs.name }}.tgz"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
@@ -269,7 +284,7 @@ jobs:
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Download npm tarball from release
|
||||
- name: Download npm tarballs from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -281,6 +296,10 @@ jobs:
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern "codex-npm-${version}.tgz" \
|
||||
--dir dist/npm
|
||||
gh release download "$tag" \
|
||||
--repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern "codex-responses-api-proxy-npm-${version}.tgz" \
|
||||
--dir dist/npm
|
||||
|
||||
# No NODE_AUTH_TOKEN needed because we use OIDC.
|
||||
- name: Publish to npm
|
||||
@@ -294,7 +313,14 @@ jobs:
|
||||
tag_args+=(--tag "${NPM_TAG}")
|
||||
fi
|
||||
|
||||
npm publish "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${VERSION}.tgz" "${tag_args[@]}"
|
||||
tarballs=(
|
||||
"codex-npm-${VERSION}.tgz"
|
||||
"codex-responses-api-proxy-npm-${VERSION}.tgz"
|
||||
)
|
||||
|
||||
for tarball in "${tarballs[@]}"; do
|
||||
npm publish "${GITHUB_WORKSPACE}/dist/npm/${tarball}" "${tag_args[@]}"
|
||||
done
|
||||
|
||||
update-branch:
|
||||
name: Update latest-alpha-cli branch
|
||||
|
||||
+2
-2
@@ -208,7 +208,7 @@ The hardening mechanism Codex uses depends on your OS:
|
||||
| Requirement | Details |
|
||||
| --------------------------- | --------------------------------------------------------------- |
|
||||
| Operating systems | macOS 12+, Ubuntu 20.04+/Debian 10+, or Windows 11 **via WSL2** |
|
||||
| Node.js | **22 or newer** (LTS recommended) |
|
||||
| Node.js | **16 or newer** (Node 20 LTS recommended) |
|
||||
| Git (optional, recommended) | 2.23+ for built-in PR helpers |
|
||||
| RAM | 4-GB minimum (8-GB recommended) |
|
||||
|
||||
@@ -513,7 +513,7 @@ Codex runs model-generated commands in a sandbox. If a proposed command or file
|
||||
<details>
|
||||
<summary>Does it work on Windows?</summary>
|
||||
|
||||
Not directly. It requires [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install) - Codex has been tested on macOS and Linux with Node 22.
|
||||
Not directly. It requires [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install) - Codex is regularly tested on macOS and Linux with Node 20+, and also supports Node 16.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Unified entry point for the Codex CLI.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
@@ -68,7 +69,6 @@ const binaryPath = path.join(archRoot, "codex", codexBinaryName);
|
||||
// executing. This allows us to forward those signals to the child process
|
||||
// and guarantees that when either the child terminates or the parent
|
||||
// receives a fatal signal, both processes exit in a predictable manner.
|
||||
const { spawn } = await import("child_process");
|
||||
|
||||
function getUpdatedPath(newDirs) {
|
||||
const pathSep = process.platform === "win32" ? ";" : ":";
|
||||
|
||||
Generated
+1
-1
@@ -11,7 +11,7 @@
|
||||
"codex": "bin/codex.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=16"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=16"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
|
||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CODEX_CLI_ROOT = SCRIPT_DIR.parent
|
||||
REPO_ROOT = CODEX_CLI_ROOT.parent
|
||||
RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" / "npm"
|
||||
GITHUB_REPO = "openai/codex"
|
||||
|
||||
# The docs are not clear on what the expected value/format of
|
||||
@@ -23,6 +24,12 @@ WORKFLOW_NAME = ".github/workflows/rust-release.yml"
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.")
|
||||
parser.add_argument(
|
||||
"--package",
|
||||
choices=("codex", "codex-responses-api-proxy"),
|
||||
default="codex",
|
||||
help="Which npm package to stage (default: codex).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
help="Version number to write to package.json inside the staged package.",
|
||||
@@ -63,6 +70,7 @@ def parse_args() -> argparse.Namespace:
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
package = args.package
|
||||
version = args.version
|
||||
release_version = args.release_version
|
||||
if release_version:
|
||||
@@ -76,7 +84,7 @@ def main() -> int:
|
||||
staging_dir, created_temp = prepare_staging_dir(args.staging_dir)
|
||||
|
||||
try:
|
||||
stage_sources(staging_dir, version)
|
||||
stage_sources(staging_dir, version, package)
|
||||
|
||||
workflow_url = args.workflow_url
|
||||
resolved_head_sha: str | None = None
|
||||
@@ -100,16 +108,23 @@ def main() -> int:
|
||||
if not workflow_url:
|
||||
raise RuntimeError("Unable to determine workflow URL for native binaries.")
|
||||
|
||||
install_native_binaries(staging_dir, workflow_url)
|
||||
install_native_binaries(staging_dir, workflow_url, package)
|
||||
|
||||
if release_version:
|
||||
staging_dir_str = str(staging_dir)
|
||||
print(
|
||||
f"Staged version {version} for release in {staging_dir_str}\n\n"
|
||||
"Verify the CLI:\n"
|
||||
f" node {staging_dir_str}/bin/codex.js --version\n"
|
||||
f" node {staging_dir_str}/bin/codex.js --help\n\n"
|
||||
)
|
||||
if package == "codex":
|
||||
print(
|
||||
f"Staged version {version} for release in {staging_dir_str}\n\n"
|
||||
"Verify the CLI:\n"
|
||||
f" node {staging_dir_str}/bin/codex.js --version\n"
|
||||
f" node {staging_dir_str}/bin/codex.js --help\n\n"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Staged version {version} for release in {staging_dir_str}\n\n"
|
||||
"Verify the responses API proxy:\n"
|
||||
f" node {staging_dir_str}/bin/codex-responses-api-proxy.js --help\n\n"
|
||||
)
|
||||
else:
|
||||
print(f"Staged package in {staging_dir}")
|
||||
|
||||
@@ -136,20 +151,34 @@ def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]:
|
||||
return temp_dir, True
|
||||
|
||||
|
||||
def stage_sources(staging_dir: Path, version: str) -> None:
|
||||
def stage_sources(staging_dir: Path, version: str, package: str) -> None:
|
||||
bin_dir = staging_dir / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js")
|
||||
rg_manifest = CODEX_CLI_ROOT / "bin" / "rg"
|
||||
if rg_manifest.exists():
|
||||
shutil.copy2(rg_manifest, bin_dir / "rg")
|
||||
if package == "codex":
|
||||
shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js")
|
||||
rg_manifest = CODEX_CLI_ROOT / "bin" / "rg"
|
||||
if rg_manifest.exists():
|
||||
shutil.copy2(rg_manifest, bin_dir / "rg")
|
||||
|
||||
readme_src = REPO_ROOT / "README.md"
|
||||
if readme_src.exists():
|
||||
shutil.copy2(readme_src, staging_dir / "README.md")
|
||||
readme_src = REPO_ROOT / "README.md"
|
||||
if readme_src.exists():
|
||||
shutil.copy2(readme_src, staging_dir / "README.md")
|
||||
|
||||
with open(CODEX_CLI_ROOT / "package.json", "r", encoding="utf-8") as fh:
|
||||
package_json_path = CODEX_CLI_ROOT / "package.json"
|
||||
elif package == "codex-responses-api-proxy":
|
||||
launcher_src = RESPONSES_API_PROXY_NPM_ROOT / "bin" / "codex-responses-api-proxy.js"
|
||||
shutil.copy2(launcher_src, bin_dir / "codex-responses-api-proxy.js")
|
||||
|
||||
readme_src = RESPONSES_API_PROXY_NPM_ROOT / "README.md"
|
||||
if readme_src.exists():
|
||||
shutil.copy2(readme_src, staging_dir / "README.md")
|
||||
|
||||
package_json_path = RESPONSES_API_PROXY_NPM_ROOT / "package.json"
|
||||
else:
|
||||
raise RuntimeError(f"Unknown package '{package}'.")
|
||||
|
||||
with open(package_json_path, "r", encoding="utf-8") as fh:
|
||||
package_json = json.load(fh)
|
||||
package_json["version"] = version
|
||||
|
||||
@@ -158,10 +187,19 @@ def stage_sources(staging_dir: Path, version: str) -> None:
|
||||
out.write("\n")
|
||||
|
||||
|
||||
def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None:
|
||||
cmd = ["./scripts/install_native_deps.py"]
|
||||
if workflow_url:
|
||||
cmd.extend(["--workflow-url", workflow_url])
|
||||
def install_native_binaries(staging_dir: Path, workflow_url: str, package: str) -> None:
|
||||
package_components = {
|
||||
"codex": ["codex", "rg"],
|
||||
"codex-responses-api-proxy": ["codex-responses-api-proxy"],
|
||||
}
|
||||
|
||||
components = package_components.get(package)
|
||||
if components is None:
|
||||
raise RuntimeError(f"Unknown package '{package}'.")
|
||||
|
||||
cmd = ["./scripts/install_native_deps.py", "--workflow-url", workflow_url]
|
||||
for component in components:
|
||||
cmd.extend(["--component", component])
|
||||
cmd.append(str(staging_dir))
|
||||
subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
@@ -20,7 +21,7 @@ CODEX_CLI_ROOT = SCRIPT_DIR.parent
|
||||
DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0
|
||||
VENDOR_DIR_NAME = "vendor"
|
||||
RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg"
|
||||
CODEX_TARGETS = (
|
||||
BINARY_TARGETS = (
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-unknown-linux-musl",
|
||||
"x86_64-apple-darwin",
|
||||
@@ -29,6 +30,27 @@ CODEX_TARGETS = (
|
||||
"aarch64-pc-windows-msvc",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BinaryComponent:
|
||||
artifact_prefix: str # matches the artifact filename prefix (e.g. codex-<target>.zst)
|
||||
dest_dir: str # directory under vendor/<target>/ where the binary is installed
|
||||
binary_basename: str # executable name inside dest_dir (before optional .exe)
|
||||
|
||||
|
||||
BINARY_COMPONENTS = {
|
||||
"codex": BinaryComponent(
|
||||
artifact_prefix="codex",
|
||||
dest_dir="codex",
|
||||
binary_basename="codex",
|
||||
),
|
||||
"codex-responses-api-proxy": BinaryComponent(
|
||||
artifact_prefix="codex-responses-api-proxy",
|
||||
dest_dir="codex-responses-api-proxy",
|
||||
binary_basename="codex-responses-api-proxy",
|
||||
),
|
||||
}
|
||||
|
||||
RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
|
||||
("x86_64-unknown-linux-musl", "linux-x86_64"),
|
||||
("aarch64-unknown-linux-musl", "linux-aarch64"),
|
||||
@@ -50,6 +72,16 @@ def parse_args() -> argparse.Namespace:
|
||||
"known good run when omitted."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--component",
|
||||
dest="components",
|
||||
action="append",
|
||||
choices=tuple(list(BINARY_COMPONENTS) + ["rg"]),
|
||||
help=(
|
||||
"Limit installation to the specified components."
|
||||
" May be repeated. Defaults to 'codex' and 'rg'."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"root",
|
||||
nargs="?",
|
||||
@@ -69,18 +101,28 @@ def main() -> int:
|
||||
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
|
||||
vendor_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
components = args.components or ["codex", "rg"]
|
||||
|
||||
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
|
||||
if not workflow_url:
|
||||
workflow_url = DEFAULT_WORKFLOW_URL
|
||||
|
||||
workflow_id = workflow_url.rstrip("/").split("/")[-1]
|
||||
print(f"Downloading native artifacts from workflow {workflow_id}...")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str:
|
||||
artifacts_dir = Path(artifacts_dir_str)
|
||||
_download_artifacts(workflow_id, artifacts_dir)
|
||||
install_codex_binaries(artifacts_dir, vendor_dir, CODEX_TARGETS)
|
||||
install_binary_components(
|
||||
artifacts_dir,
|
||||
vendor_dir,
|
||||
BINARY_TARGETS,
|
||||
[name for name in components if name in BINARY_COMPONENTS],
|
||||
)
|
||||
|
||||
fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST)
|
||||
if "rg" in components:
|
||||
print("Fetching ripgrep binaries...")
|
||||
fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST)
|
||||
|
||||
print(f"Installed native dependencies into {vendor_dir}")
|
||||
return 0
|
||||
@@ -124,6 +166,8 @@ def fetch_rg(
|
||||
results: dict[str, Path] = {}
|
||||
max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1)))
|
||||
|
||||
print("Installing ripgrep binaries for targets: " + ", ".join(targets))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_map = {
|
||||
executor.submit(
|
||||
@@ -140,6 +184,7 @@ def fetch_rg(
|
||||
for future in as_completed(future_map):
|
||||
target = future_map[future]
|
||||
results[target] = future.result()
|
||||
print(f" installed ripgrep for {target}")
|
||||
|
||||
return [results[target] for target in targets]
|
||||
|
||||
@@ -158,40 +203,60 @@ def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
|
||||
subprocess.check_call(cmd)
|
||||
|
||||
|
||||
def install_codex_binaries(
|
||||
artifacts_dir: Path, vendor_dir: Path, targets: Iterable[str]
|
||||
) -> list[Path]:
|
||||
def install_binary_components(
|
||||
artifacts_dir: Path,
|
||||
vendor_dir: Path,
|
||||
targets: Iterable[str],
|
||||
component_names: Sequence[str],
|
||||
) -> None:
|
||||
selected_components = [BINARY_COMPONENTS[name] for name in component_names if name in BINARY_COMPONENTS]
|
||||
if not selected_components:
|
||||
return
|
||||
|
||||
targets = list(targets)
|
||||
if not targets:
|
||||
return []
|
||||
return
|
||||
|
||||
results: dict[str, Path] = {}
|
||||
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_map = {
|
||||
executor.submit(_install_single_codex_binary, artifacts_dir, vendor_dir, target): target
|
||||
for target in targets
|
||||
}
|
||||
|
||||
for future in as_completed(future_map):
|
||||
target = future_map[future]
|
||||
results[target] = future.result()
|
||||
|
||||
return [results[target] for target in targets]
|
||||
for component in selected_components:
|
||||
print(
|
||||
f"Installing {component.binary_basename} binaries for targets: "
|
||||
+ ", ".join(targets)
|
||||
)
|
||||
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
_install_single_binary,
|
||||
artifacts_dir,
|
||||
vendor_dir,
|
||||
target,
|
||||
component,
|
||||
): target
|
||||
for target in targets
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
installed_path = future.result()
|
||||
print(f" installed {installed_path}")
|
||||
|
||||
|
||||
def _install_single_codex_binary(artifacts_dir: Path, vendor_dir: Path, target: str) -> Path:
|
||||
def _install_single_binary(
|
||||
artifacts_dir: Path,
|
||||
vendor_dir: Path,
|
||||
target: str,
|
||||
component: BinaryComponent,
|
||||
) -> Path:
|
||||
artifact_subdir = artifacts_dir / target
|
||||
archive_name = _archive_name_for_target(target)
|
||||
archive_name = _archive_name_for_target(component.artifact_prefix, target)
|
||||
archive_path = artifact_subdir / archive_name
|
||||
if not archive_path.exists():
|
||||
raise FileNotFoundError(f"Expected artifact not found: {archive_path}")
|
||||
|
||||
dest_dir = vendor_dir / target / "codex"
|
||||
dest_dir = vendor_dir / target / component.dest_dir
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
binary_name = "codex.exe" if "windows" in target else "codex"
|
||||
binary_name = (
|
||||
f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename
|
||||
)
|
||||
dest = dest_dir / binary_name
|
||||
dest.unlink(missing_ok=True)
|
||||
extract_archive(archive_path, "zst", None, dest)
|
||||
@@ -200,10 +265,10 @@ def _install_single_codex_binary(artifacts_dir: Path, vendor_dir: Path, target:
|
||||
return dest
|
||||
|
||||
|
||||
def _archive_name_for_target(target: str) -> str:
|
||||
def _archive_name_for_target(artifact_prefix: str, target: str) -> str:
|
||||
if "windows" in target:
|
||||
return f"codex-{target}.exe.zst"
|
||||
return f"codex-{target}.zst"
|
||||
return f"{artifact_prefix}-{target}.exe.zst"
|
||||
return f"{artifact_prefix}-{target}.zst"
|
||||
|
||||
|
||||
def _fetch_single_rg(
|
||||
|
||||
Generated
+10
-4
@@ -692,12 +692,11 @@ dependencies = [
|
||||
"codex-exec",
|
||||
"codex-login",
|
||||
"codex-mcp-server",
|
||||
"codex-process-hardening",
|
||||
"codex-protocol",
|
||||
"codex-protocol-ts",
|
||||
"codex-responses-api-proxy",
|
||||
"codex-tui",
|
||||
"ctor 0.5.0",
|
||||
"libc",
|
||||
"owo-colors",
|
||||
"predicates",
|
||||
"pretty_assertions",
|
||||
@@ -949,6 +948,13 @@ dependencies = [
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-process-hardening"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-protocol"
|
||||
version = "0.0.0"
|
||||
@@ -989,13 +995,13 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-arg0",
|
||||
"codex-process-hardening",
|
||||
"ctor 0.5.0",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
||||
+4
-3
@@ -16,6 +16,7 @@ members = [
|
||||
"mcp-server",
|
||||
"mcp-types",
|
||||
"ollama",
|
||||
"process-hardening",
|
||||
"protocol",
|
||||
"protocol-ts",
|
||||
"rmcp-client",
|
||||
@@ -49,10 +50,10 @@ codex-login = { path = "login" }
|
||||
codex-mcp-client = { path = "mcp-client" }
|
||||
codex-mcp-server = { path = "mcp-server" }
|
||||
codex-ollama = { path = "ollama" }
|
||||
codex-process-hardening = { path = "process-hardening" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
codex-rmcp-client = { path = "rmcp-client" }
|
||||
codex-protocol-ts = { path = "protocol-ts" }
|
||||
codex-responses-api-proxy = { path = "responses-api-proxy" }
|
||||
codex-rmcp-client = { path = "rmcp-client" }
|
||||
codex-tui = { path = "tui" }
|
||||
codex-utils-readiness = { path = "utils/readiness" }
|
||||
core_test_support = { path = "core/tests/common" }
|
||||
@@ -83,8 +84,8 @@ dirs = "6"
|
||||
dotenvy = "0.15.7"
|
||||
env-flags = "0.1.1"
|
||||
env_logger = "0.11.5"
|
||||
eventsource-stream = "0.2.3"
|
||||
escargot = "0.5"
|
||||
eventsource-stream = "0.2.3"
|
||||
futures = "0.3"
|
||||
icu_decimal = "2.0.0"
|
||||
icu_locale_core = "2.0.0"
|
||||
|
||||
+4
-4
@@ -4,18 +4,18 @@ We provide Codex CLI as a standalone, native executable to ensure a zero-depende
|
||||
|
||||
## Installing Codex
|
||||
|
||||
Today, the easiest way to install Codex is via `npm`, though we plan to publish Codex to other package managers soon.
|
||||
Today, the easiest way to install Codex is via `npm`:
|
||||
|
||||
```shell
|
||||
npm i -g @openai/codex@native
|
||||
npm i -g @openai/codex
|
||||
codex
|
||||
```
|
||||
|
||||
You can also download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases).
|
||||
You can also install via Homebrew (`brew install codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases).
|
||||
|
||||
## What's new in the Rust CLI
|
||||
|
||||
While we are [working to close the gap between the TypeScript and Rust implementations of Codex CLI](https://github.com/openai/codex/issues/1262), note that the Rust CLI has a number of features that the TypeScript CLI does not!
|
||||
The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported.
|
||||
|
||||
### Config
|
||||
|
||||
|
||||
+1
-10
@@ -25,9 +25,9 @@ codex-core = { workspace = true }
|
||||
codex-exec = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-mcp-server = { workspace = true }
|
||||
codex-process-hardening = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-protocol-ts = { workspace = true }
|
||||
codex-responses-api-proxy = { workspace = true }
|
||||
codex-tui = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
@@ -43,15 +43,6 @@ tokio = { workspace = true, features = [
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = { workspace = true }
|
||||
predicates = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use anyhow::Context;
|
||||
use clap::CommandFactory;
|
||||
use clap::Parser;
|
||||
use clap_complete::Shell;
|
||||
@@ -15,7 +14,6 @@ use codex_cli::login::run_logout;
|
||||
use codex_cli::proto;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_exec::Cli as ExecCli;
|
||||
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
|
||||
use codex_tui::AppExitInfo;
|
||||
use codex_tui::Cli as TuiCli;
|
||||
use owo_colors::OwoColorize;
|
||||
@@ -23,7 +21,6 @@ use std::path::PathBuf;
|
||||
use supports_color::Stream;
|
||||
|
||||
mod mcp_cmd;
|
||||
mod pre_main_hardening;
|
||||
|
||||
use crate::mcp_cmd::McpCli;
|
||||
use crate::proto::ProtoCli;
|
||||
@@ -88,10 +85,6 @@ enum Subcommand {
|
||||
/// Internal: generate TypeScript protocol bindings.
|
||||
#[clap(hide = true)]
|
||||
GenerateTs(GenerateTsCommand),
|
||||
|
||||
/// Internal: run the responses API proxy.
|
||||
#[clap(hide = true)]
|
||||
ResponsesApiProxy(ResponsesApiProxyArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -188,7 +181,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
|
||||
} else {
|
||||
resume_cmd
|
||||
};
|
||||
lines.push(format!("To continue this session, run {command}."));
|
||||
lines.push(format!("To continue this session, run {command}"));
|
||||
}
|
||||
|
||||
lines
|
||||
@@ -213,14 +206,7 @@ fn pre_main_hardening() {
|
||||
};
|
||||
|
||||
if secure_mode == "1" {
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
crate::pre_main_hardening::pre_main_hardening_linux();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::pre_main_hardening::pre_main_hardening_macos();
|
||||
|
||||
#[cfg(windows)]
|
||||
crate::pre_main_hardening::pre_main_hardening_windows();
|
||||
codex_process_hardening::pre_main_hardening();
|
||||
}
|
||||
|
||||
// Always clear this env var so child processes don't inherit it.
|
||||
@@ -347,11 +333,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
Some(Subcommand::GenerateTs(gen_cli)) => {
|
||||
codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?;
|
||||
}
|
||||
Some(Subcommand::ResponsesApiProxy(args)) => {
|
||||
tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args))
|
||||
.await
|
||||
.context("responses-api-proxy blocking task panicked")??;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -500,7 +481,7 @@ mod tests {
|
||||
lines,
|
||||
vec![
|
||||
"Token usage: total=2 input=0 output=2".to_string(),
|
||||
"To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000."
|
||||
"To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000"
|
||||
.to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ const PRESETS: &[ModelPreset] = &[
|
||||
label: "gpt-5-codex medium",
|
||||
description: "",
|
||||
model: "gpt-5-codex",
|
||||
effort: None,
|
||||
effort: Some(ReasoningEffort::Medium),
|
||||
},
|
||||
ModelPreset {
|
||||
id: "gpt-5-codex-high",
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) const INTERNAL_STORAGE_FILE: &str = "internal_storage.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InternalStorage {
|
||||
#[serde(skip)]
|
||||
storage_path: PathBuf,
|
||||
#[serde(default = "default_gpt_5_codex_model_prompt_seen")]
|
||||
pub gpt_5_codex_model_prompt_seen: bool,
|
||||
}
|
||||
|
||||
const fn default_gpt_5_codex_model_prompt_seen() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for InternalStorage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
storage_path: PathBuf::new(),
|
||||
gpt_5_codex_model_prompt_seen: default_gpt_5_codex_model_prompt_seen(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(jif) generalise all the file writers and build proper async channel inserters.
|
||||
impl InternalStorage {
|
||||
pub fn load(codex_home: &Path) -> Self {
|
||||
let storage_path = codex_home.join(INTERNAL_STORAGE_FILE);
|
||||
|
||||
match std::fs::read_to_string(&storage_path) {
|
||||
Ok(serialized) => match serde_json::from_str::<Self>(&serialized) {
|
||||
Ok(mut storage) => {
|
||||
storage.storage_path = storage_path;
|
||||
storage
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("failed to parse internal storage: {error:?}");
|
||||
Self::empty(storage_path)
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
if error.kind() == ErrorKind::NotFound {
|
||||
tracing::debug!(
|
||||
"internal storage not found at {}; initializing defaults",
|
||||
storage_path.display()
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("failed to read internal storage: {error:?}");
|
||||
}
|
||||
Self::empty(storage_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn empty(storage_path: PathBuf) -> Self {
|
||||
Self {
|
||||
storage_path,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn persist(&self) -> anyhow::Result<()> {
|
||||
let serialized = serde_json::to_string_pretty(self)?;
|
||||
|
||||
if let Some(parent) = self.storage_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!(
|
||||
"failed to create internal storage directory at {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
tokio::fs::write(&self.storage_path, serialized)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to persist internal storage at {}",
|
||||
self.storage_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ impl McpClientAdapter {
|
||||
params: mcp_types::InitializeRequestParams,
|
||||
startup_timeout: Duration,
|
||||
) -> Result<Self> {
|
||||
tracing::error!(
|
||||
info!(
|
||||
"new_stdio_client use_rmcp_client: {use_rmcp_client} program: {program:?} args: {args:?} env: {env:?} params: {params:?} startup_timeout: {startup_timeout:?}"
|
||||
);
|
||||
if use_rmcp_client {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "codex-process-hardening"
|
||||
version = { workspace = true }
|
||||
|
||||
[lib]
|
||||
name = "codex_process_hardening"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = { workspace = true }
|
||||
@@ -0,0 +1,7 @@
|
||||
# codex-process-hardening
|
||||
|
||||
This crate provides `pre_main_hardening()`, which is designed to be called pre-`main()` (using `#[ctor::ctor]`) to perform various process hardening steps, such as
|
||||
|
||||
- disabling core dumps
|
||||
- disabling ptrace attach on Linux and macOS
|
||||
- removing dangerous environment variables such as `LD_PRELOAD` and `DYLD_*`
|
||||
@@ -1,3 +1,19 @@
|
||||
/// This is designed to be called pre-main() (using `#[ctor::ctor]`) to perform
|
||||
/// various process hardening steps, such as
|
||||
/// - disabling core dumps
|
||||
/// - disabling ptrace attach on Linux and macOS.
|
||||
/// - removing dangerous environment variables such as LD_PRELOAD and DYLD_*
|
||||
pub fn pre_main_hardening() {
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
pre_main_hardening_linux();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pre_main_hardening_macos();
|
||||
|
||||
#[cfg(windows)]
|
||||
pre_main_hardening_windows();
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
const PRCTL_FAILED_EXIT_CODE: i32 = 5;
|
||||
|
||||
@@ -8,7 +8,7 @@ name = "codex_responses_api_proxy"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "responses-api-proxy"
|
||||
name = "codex-responses-api-proxy"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
@@ -17,11 +17,11 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-process-hardening = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tiny_http = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
|
||||
@@ -4,12 +4,12 @@ A strict HTTP proxy that only forwards `POST` requests to `/v1/responses` to the
|
||||
|
||||
## Expected Usage
|
||||
|
||||
**IMPORTANT:** This is designed to be used with `CODEX_SECURE_MODE=1` so that an unprivileged user cannot inspect or tamper with this process. Though if `--http-shutdown` is specified, an unprivileged user _can_ shutdown the server.
|
||||
**IMPORTANT:** `codex-responses-api-proxy` is designed to be run by a privileged user with access to `OPENAI_API_KEY` so that an unprivileged user cannot inspect or tamper with the process. Though if `--http-shutdown` is specified, an unprivileged user _can_ make a `GET` request to `/shutdown` to shutdown the server, as an unprivileged could not send `SIGTERM` to kill the process.
|
||||
|
||||
A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server:
|
||||
A privileged user (i.e., `root` or a user with `sudo`) who has access to `OPENAI_API_KEY` would run the following to start the server, as `codex-responses-api-proxy` reads the auth token from `stdin`:
|
||||
|
||||
```shell
|
||||
printenv OPENAI_API_KEY | CODEX_SECURE_MODE=1 codex responses-api-proxy --http-shutdown --server-info /tmp/server-info.json
|
||||
printenv OPENAI_API_KEY | codex-responses-api-proxy --http-shutdown --server-info /tmp/server-info.json
|
||||
```
|
||||
|
||||
A non-privileged user would then run Codex as follows, specifying the `model_provider` dynamically:
|
||||
@@ -22,7 +22,7 @@ codex exec -c "model_providers.openai-proxy={ name = 'OpenAI Proxy', base_url =
|
||||
'Your prompt here'
|
||||
```
|
||||
|
||||
When the unprivileged user was finished, they could shutdown the server using `curl` (since `kill -9` is not an option):
|
||||
When the unprivileged user was finished, they could shutdown the server using `curl` (since `kill -SIGTERM` is not an option):
|
||||
|
||||
```shell
|
||||
curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown"
|
||||
@@ -30,17 +30,17 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown"
|
||||
|
||||
## Behavior
|
||||
|
||||
- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex responses-api-proxy`).
|
||||
- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex-responses-api-proxy`).
|
||||
- Formats the header value as `Bearer <key>` and attempts to `mlock(2)` the memory holding that header so it is not swapped to disk.
|
||||
- Listens on the provided port or an ephemeral port if `--port` is not specified.
|
||||
- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer <key>` set. All original request headers (except any incoming `Authorization`) are forwarded upstream. For other requests, it responds with `403`.
|
||||
- Optionally writes a single-line JSON file with server info, currently `{ "port": <u16> }`.
|
||||
- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., root) to start the proxy and another unprivileged user on the host to shut it down.
|
||||
- Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code 0. This allows one user (e.g., `root`) to start the proxy and another unprivileged user on the host to shut it down.
|
||||
|
||||
## CLI
|
||||
|
||||
```
|
||||
responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown]
|
||||
codex-responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown]
|
||||
```
|
||||
|
||||
- `--port <PORT>`: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen.
|
||||
@@ -51,3 +51,19 @@ responses-api-proxy [--port <PORT>] [--server-info <FILE>] [--http-shutdown]
|
||||
|
||||
- Only `POST /v1/responses` is permitted. No query strings are allowed.
|
||||
- All request headers are forwarded to the upstream call (aside from overriding `Authorization`). Response status and content-type are mirrored from upstream.
|
||||
|
||||
## Hardening Details
|
||||
|
||||
Care is taken to restrict access/copying to the value of `OPENAI_API_KEY` retained in memory:
|
||||
|
||||
- We leverage [`codex_process_hardening`](https://github.com/openai/codex/blob/main/codex-rs/process-hardening/README.md) so `codex-responses-api-proxy` is run with standard process-hardening techniques.
|
||||
- At startup, we allocate a `1024` byte buffer on the stack and write `"Bearer "` as the first `7` bytes.
|
||||
- We then read from `stdin`, copying the contents into the buffer after `"Bearer "`.
|
||||
- After verifying the key matches `/^[a-zA-Z0-9_-]+$/` (and does not exceed the buffer), we create a `String` from that buffer (so the data is now on the heap).
|
||||
- We zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler.
|
||||
- We invoke `.leak()` on the `String` so we can treat its contents as a `&'static str`, as it will live for the rest of the process.
|
||||
- On UNIX, we `mlock(2)` the memory backing the `&'static str`.
|
||||
- When using the `&'static str` when building an HTTP request, we use `HeaderValue::from_static()` to avoid copying the `&str`.
|
||||
- We also invoke `.set_sensitive(true)` on the `HeaderValue`, which in theory indicates to other parts of the HTTP stack that the header should be treated with "special care" to avoid leakage:
|
||||
|
||||
https://github.com/hyperium/http/blob/439d1c50d71e3be3204b6c4a1bf2255ed78e1f93/src/header/value.rs#L346-L376
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# @openai/codex-responses-api-proxy
|
||||
|
||||
<p align="center"><code>npm i -g @openai/codex-responses-api-proxy</code> to install <code>codex-responses-api-proxy</code></p>
|
||||
|
||||
This package distributes the prebuilt [Codex Responses API proxy binary](https://github.com/openai/codex/tree/main/codex-rs/responses-api-proxy) for macOS, Linux, and Windows.
|
||||
|
||||
To see available options, run:
|
||||
|
||||
```
|
||||
node ./bin/codex-responses-api-proxy.js --help
|
||||
```
|
||||
|
||||
Refer to [`codex-rs/responses-api-proxy/README.md`](https://github.com/openai/codex/blob/main/codex-rs/responses-api-proxy/README.md) for detailed documentation.
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
// Entry point for the Codex responses API proxy binary.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
function determineTargetTriple(platform, arch) {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
case "android":
|
||||
if (arch === "x64") {
|
||||
return "x86_64-unknown-linux-musl";
|
||||
}
|
||||
if (arch === "arm64") {
|
||||
return "aarch64-unknown-linux-musl";
|
||||
}
|
||||
break;
|
||||
case "darwin":
|
||||
if (arch === "x64") {
|
||||
return "x86_64-apple-darwin";
|
||||
}
|
||||
if (arch === "arm64") {
|
||||
return "aarch64-apple-darwin";
|
||||
}
|
||||
break;
|
||||
case "win32":
|
||||
if (arch === "x64") {
|
||||
return "x86_64-pc-windows-msvc";
|
||||
}
|
||||
if (arch === "arm64") {
|
||||
return "aarch64-pc-windows-msvc";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetTriple = determineTargetTriple(process.platform, process.arch);
|
||||
if (!targetTriple) {
|
||||
throw new Error(
|
||||
`Unsupported platform: ${process.platform} (${process.arch})`,
|
||||
);
|
||||
}
|
||||
|
||||
const vendorRoot = path.join(__dirname, "..", "vendor");
|
||||
const archRoot = path.join(vendorRoot, targetTriple);
|
||||
const binaryBaseName = "codex-responses-api-proxy";
|
||||
const binaryPath = path.join(
|
||||
archRoot,
|
||||
binaryBaseName,
|
||||
process.platform === "win32" ? `${binaryBaseName}.exe` : binaryBaseName,
|
||||
);
|
||||
|
||||
const child = spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const forwardSignal = (signal) => {
|
||||
if (!child.killed) {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
["SIGINT", "SIGTERM", "SIGHUP"].forEach((sig) => {
|
||||
process.on(sig, () => forwardSignal(sig));
|
||||
});
|
||||
|
||||
const childResult = await new Promise((resolve) => {
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
resolve({ type: "signal", signal });
|
||||
} else {
|
||||
resolve({ type: "code", exitCode: code ?? 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (childResult.type === "signal") {
|
||||
process.kill(process.pid, childResult.signal);
|
||||
} else {
|
||||
process.exit(childResult.exitCode);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@openai/codex-responses-api-proxy",
|
||||
"version": "0.0.0-dev",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex-responses-api-proxy": "bin/codex-responses-api-proxy.js"
|
||||
},
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"vendor"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openai/codex.git",
|
||||
"directory": "codex-rs/responses-api-proxy/npm"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use std::net::TcpListener;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
@@ -62,6 +63,8 @@ pub fn run_main(args: Args) -> Result<()> {
|
||||
.map_err(|err| anyhow!("creating HTTP server: {err}"))?;
|
||||
let client = Arc::new(
|
||||
Client::builder()
|
||||
// Disable reqwest's 30s default so long-lived response streams keep flowing.
|
||||
.timeout(None::<Duration>)
|
||||
.build()
|
||||
.context("building reqwest client")?,
|
||||
);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use codex_arg0::arg0_dispatch_or_else;
|
||||
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
|
||||
|
||||
pub fn main() -> anyhow::Result<()> {
|
||||
arg0_dispatch_or_else(|_codex_linux_sandbox_exe| async move {
|
||||
let args = ResponsesApiProxyArgs::parse();
|
||||
tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args))
|
||||
.await
|
||||
.context("responses-api-proxy blocking task panicked")??;
|
||||
Ok(())
|
||||
})
|
||||
#[ctor::ctor]
|
||||
fn pre_main() {
|
||||
codex_process_hardening::pre_main_hardening();
|
||||
}
|
||||
|
||||
pub fn main() -> anyhow::Result<()> {
|
||||
let args = ResponsesApiProxyArgs::parse();
|
||||
codex_responses_api_proxy::run_main(args)
|
||||
}
|
||||
|
||||
@@ -54,9 +54,16 @@ where
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = validate_auth_header_bytes(&buf[AUTH_HEADER_PREFIX.len()..total]) {
|
||||
buf.zeroize();
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let header_str = match std::str::from_utf8(&buf[..total]) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
// In theory, validate_auth_header_bytes() should have caught
|
||||
// any invalid UTF-8 sequences, but just in case...
|
||||
buf.zeroize();
|
||||
return Err(err).context("reading Authorization header from stdin as UTF-8");
|
||||
}
|
||||
@@ -113,6 +120,21 @@ fn mlock_str(value: &str) {
|
||||
#[cfg(not(unix))]
|
||||
fn mlock_str(_value: &str) {}
|
||||
|
||||
/// The key should match /^[A-Za-z0-9\-_]+$/. Ensure there is no funny business
|
||||
/// with NUL characters and whatnot.
|
||||
fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> {
|
||||
if key_bytes
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -158,7 +180,7 @@ mod tests {
|
||||
})
|
||||
.unwrap_err();
|
||||
let message = format!("{err:#}");
|
||||
assert!(message.contains("too large"));
|
||||
assert!(message.contains("OPENAI_API_KEY is too large to fit in the 512-byte buffer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -180,6 +202,23 @@ mod tests {
|
||||
.unwrap_err();
|
||||
|
||||
let message = format!("{err:#}");
|
||||
assert!(message.contains("UTF-8"));
|
||||
assert!(
|
||||
message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_on_invalid_characters() {
|
||||
let err = read_auth_header_with(|buf| {
|
||||
let data = b"sk-abc!23";
|
||||
buf[..data.len()].copy_from_slice(data);
|
||||
Ok(data.len())
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
let message = format!("{err:#}");
|
||||
assert!(
|
||||
message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ impl AsciiAnimation {
|
||||
true
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn request_frame(&self) {
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -18,14 +19,8 @@ use super::chat_composer_history::ChatComposerHistory;
|
||||
use super::command_popup::CommandItem;
|
||||
use super::command_popup::CommandPopup;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
use super::footer::FooterMode;
|
||||
use super::footer::FooterProps;
|
||||
use super::footer::esc_hint_mode;
|
||||
use super::footer::footer_height;
|
||||
use super::footer::prompt_mode;
|
||||
use super::footer::render_footer;
|
||||
use super::footer::reset_mode_after_activity;
|
||||
use super::footer::toggle_shortcut_mode;
|
||||
use super::paste_burst::CharDecision;
|
||||
use super::paste_burst::PasteBurst;
|
||||
use crate::bottom_pane::paste_burst::FlushResult;
|
||||
@@ -79,6 +74,7 @@ pub(crate) struct ChatComposer {
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
current_file_query: Option<String>,
|
||||
pending_pastes: Vec<(String, String)>,
|
||||
token_usage_info: Option<TokenUsageInfo>,
|
||||
has_focus: bool,
|
||||
attached_images: Vec<AttachedImage>,
|
||||
placeholder_text: String,
|
||||
@@ -88,7 +84,6 @@ pub(crate) struct ChatComposer {
|
||||
// When true, disables paste-burst logic and inserts characters immediately.
|
||||
disable_paste_burst: bool,
|
||||
custom_prompts: Vec<CustomPrompt>,
|
||||
footer_mode: FooterMode,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -98,7 +93,7 @@ enum ActivePopup {
|
||||
File(FileSearchPopup),
|
||||
}
|
||||
|
||||
const FOOTER_SPACING_HEIGHT: u16 = 1;
|
||||
const FOOTER_HINT_HEIGHT: u16 = 1;
|
||||
|
||||
impl ChatComposer {
|
||||
pub fn new(
|
||||
@@ -122,6 +117,7 @@ impl ChatComposer {
|
||||
dismissed_file_popup_token: None,
|
||||
current_file_query: None,
|
||||
pending_pastes: Vec::new(),
|
||||
token_usage_info: None,
|
||||
has_focus: has_input_focus,
|
||||
attached_images: Vec::new(),
|
||||
placeholder_text,
|
||||
@@ -129,7 +125,6 @@ impl ChatComposer {
|
||||
paste_burst: PasteBurst::default(),
|
||||
disable_paste_burst: false,
|
||||
custom_prompts: Vec::new(),
|
||||
footer_mode: FooterMode::ShortcutPrompt,
|
||||
};
|
||||
// Apply configuration via the setter to keep side-effects centralized.
|
||||
this.set_disable_paste_burst(disable_paste_burst);
|
||||
@@ -137,41 +132,26 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = footer_height(footer_props);
|
||||
let footer_spacing = if footer_hint_height > 0 {
|
||||
FOOTER_SPACING_HEIGHT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let footer_total_height = footer_hint_height + footer_spacing;
|
||||
self.textarea
|
||||
.desired_height(width.saturating_sub(LIVE_PREFIX_COLS))
|
||||
+ 2
|
||||
+ match &self.active_popup {
|
||||
ActivePopup::None => footer_total_height,
|
||||
ActivePopup::None => FOOTER_HINT_HEIGHT,
|
||||
ActivePopup::Command(c) => c.calculate_required_height(width),
|
||||
ActivePopup::File(c) => c.calculate_required_height(),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
|
||||
let footer_props = self.footer_props();
|
||||
let footer_hint_height = footer_height(footer_props);
|
||||
let footer_spacing = if footer_hint_height > 0 {
|
||||
FOOTER_SPACING_HEIGHT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let footer_total_height = footer_hint_height + footer_spacing;
|
||||
let popup_constraint = match &self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
Constraint::Max(popup.calculate_required_height(area.width))
|
||||
}
|
||||
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
|
||||
ActivePopup::None => Constraint::Max(footer_total_height),
|
||||
ActivePopup::None => Constraint::Max(FOOTER_HINT_HEIGHT),
|
||||
};
|
||||
let mut area = area;
|
||||
// Leave an empty row at the top, unless there isn't room.
|
||||
if area.height > 1 {
|
||||
area.height -= 1;
|
||||
area.y += 1;
|
||||
@@ -195,6 +175,13 @@ impl ChatComposer {
|
||||
self.textarea.is_empty()
|
||||
}
|
||||
|
||||
/// Update the cached *context-left* percentage and refresh the placeholder
|
||||
/// text. The UI relies on the placeholder to convey the remaining
|
||||
/// context when the composer is empty.
|
||||
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
|
||||
self.token_usage_info = token_info;
|
||||
}
|
||||
|
||||
/// Record the history metadata advertised by `SessionConfiguredEvent` so
|
||||
/// that the composer can navigate cross-session history.
|
||||
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
||||
@@ -332,11 +319,6 @@ impl ChatComposer {
|
||||
|
||||
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
|
||||
self.ctrl_c_quit_hint = show;
|
||||
if show {
|
||||
self.footer_mode = prompt_mode();
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
self.set_has_focus(has_focus);
|
||||
}
|
||||
|
||||
@@ -372,18 +354,6 @@ impl ChatComposer {
|
||||
|
||||
/// Handle key event when the slash-command popup is visible.
|
||||
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
if self.handle_shortcut_overlay_key(&key_event) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if key_event.code == KeyCode::Esc {
|
||||
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
|
||||
if next_mode != self.footer_mode {
|
||||
self.footer_mode = next_mode;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
let ActivePopup::Command(popup) = &mut self.active_popup else {
|
||||
unreachable!();
|
||||
};
|
||||
@@ -508,18 +478,6 @@ impl ChatComposer {
|
||||
|
||||
/// Handle key events when file search popup is visible.
|
||||
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
if self.handle_shortcut_overlay_key(&key_event) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if key_event.code == KeyCode::Esc {
|
||||
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
|
||||
if next_mode != self.footer_mode {
|
||||
self.footer_mode = next_mode;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
let ActivePopup::File(popup) = &mut self.active_popup else {
|
||||
unreachable!();
|
||||
};
|
||||
@@ -771,18 +729,6 @@ impl ChatComposer {
|
||||
|
||||
/// Handle key event when no popup is visible.
|
||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
if self.handle_shortcut_overlay_key(&key_event) {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
if key_event.code == KeyCode::Esc {
|
||||
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
|
||||
if next_mode != self.footer_mode {
|
||||
self.footer_mode = next_mode;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
@@ -926,10 +872,6 @@ impl ChatComposer {
|
||||
let now = Instant::now();
|
||||
self.handle_paste_burst_flush(now);
|
||||
|
||||
if !matches!(input.code, KeyCode::Esc) {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
|
||||
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
|
||||
if matches!(input.code, KeyCode::Enter)
|
||||
&& self.paste_burst.is_active()
|
||||
@@ -1203,39 +1145,6 @@ impl ChatComposer {
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
|
||||
if key_event.kind == KeyEventKind::Press
|
||||
&& key_event.modifiers.is_empty()
|
||||
&& matches!(key_event.code, KeyCode::Char('?'))
|
||||
{
|
||||
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
|
||||
let changed = next != self.footer_mode;
|
||||
self.footer_mode = next;
|
||||
changed
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_props(&self) -> FooterProps {
|
||||
FooterProps {
|
||||
mode: self.footer_mode(),
|
||||
esc_backtrack_hint: self.esc_backtrack_hint,
|
||||
use_shift_enter_hint: self.use_shift_enter_hint,
|
||||
is_task_running: self.is_task_running,
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_mode(&self) -> FooterMode {
|
||||
if matches!(self.footer_mode, FooterMode::EscHint) {
|
||||
FooterMode::EscHint
|
||||
} else if self.ctrl_c_quit_hint {
|
||||
FooterMode::CtrlCReminder
|
||||
} else {
|
||||
self.footer_mode
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize `self.command_popup` with the current text in the
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
@@ -1319,18 +1228,10 @@ impl ChatComposer {
|
||||
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
self.is_task_running = running;
|
||||
if running {
|
||||
self.footer_mode = prompt_mode();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
|
||||
self.esc_backtrack_hint = show;
|
||||
if show {
|
||||
self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1351,26 +1252,20 @@ impl WidgetRef for ChatComposer {
|
||||
popup.render_ref(popup_rect, buf);
|
||||
}
|
||||
ActivePopup::None => {
|
||||
let footer_hint_height = footer_height(self.footer_props());
|
||||
let footer_spacing = if footer_hint_height > 0 {
|
||||
FOOTER_SPACING_HEIGHT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
|
||||
let [_, hint_rect] = Layout::vertical([
|
||||
Constraint::Length(footer_spacing),
|
||||
Constraint::Length(footer_hint_height),
|
||||
])
|
||||
.areas(popup_rect);
|
||||
hint_rect
|
||||
} else {
|
||||
popup_rect
|
||||
};
|
||||
let mut footer_rect = hint_rect;
|
||||
footer_rect.x = footer_rect.x.saturating_add(2);
|
||||
footer_rect.width = footer_rect.width.saturating_sub(2);
|
||||
render_footer(footer_rect, buf, self.footer_props());
|
||||
let mut hint_rect = popup_rect;
|
||||
hint_rect.x += 2;
|
||||
hint_rect.width = hint_rect.width.saturating_sub(2);
|
||||
render_footer(
|
||||
hint_rect,
|
||||
buf,
|
||||
FooterProps {
|
||||
ctrl_c_quit_hint: self.ctrl_c_quit_hint,
|
||||
is_task_running: self.is_task_running,
|
||||
esc_backtrack_hint: self.esc_backtrack_hint,
|
||||
use_shift_enter_hint: self.use_shift_enter_hint,
|
||||
token_usage_info: self.token_usage_info.as_ref(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let style = user_message_style(terminal_palette::default_bg());
|
||||
@@ -1409,7 +1304,6 @@ mod tests {
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::chat_composer::AttachedImage;
|
||||
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use crate::bottom_pane::footer::footer_height;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
@@ -1440,7 +1334,7 @@ mod tests {
|
||||
let mut hint_row: Option<(u16, String)> = None;
|
||||
for y in 0..area.height {
|
||||
let row = row_to_string(y);
|
||||
if row.contains("? for shortcuts") {
|
||||
if row.contains(" send") {
|
||||
hint_row = Some((y, row));
|
||||
break;
|
||||
}
|
||||
@@ -1467,81 +1361,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
|
||||
where
|
||||
F: FnOnce(&mut ChatComposer),
|
||||
{
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let width = 100;
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
enhanced_keys_supported,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
setup(&mut composer);
|
||||
let footer_lines = footer_height(composer.footer_props());
|
||||
let height = footer_lines + 8;
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(composer, f.area()))
|
||||
.unwrap();
|
||||
insta::assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_mode_snapshots() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| {
|
||||
composer.set_esc_backtrack_hint(true);
|
||||
let _ =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
|
||||
composer.set_ctrl_c_quit_hint(true, true);
|
||||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
|
||||
composer.set_task_running(true);
|
||||
composer.set_ctrl_c_quit_hint(true, true);
|
||||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
|
||||
composer.set_ctrl_c_quit_hint(true, true);
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| {
|
||||
let _ =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| {
|
||||
composer.set_esc_backtrack_hint(true);
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
});
|
||||
|
||||
snapshot_composer_state(
|
||||
"footer_mode_overlay_then_external_esc_hint",
|
||||
true,
|
||||
|composer| {
|
||||
let _ = composer
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
|
||||
composer.set_esc_backtrack_hint(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_at_token_basic_cases() {
|
||||
let test_cases = vec![
|
||||
|
||||
@@ -1,190 +1,149 @@
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::num_format::format_si_suffix;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::key_hint;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct FooterProps {
|
||||
pub(crate) mode: FooterMode,
|
||||
pub(crate) struct FooterProps<'a> {
|
||||
pub(crate) ctrl_c_quit_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
pub(crate) esc_backtrack_hint: bool,
|
||||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum FooterMode {
|
||||
CtrlCReminder,
|
||||
ShortcutPrompt,
|
||||
ShortcutOverlay,
|
||||
EscHint,
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
|
||||
if ctrl_c_hint {
|
||||
return current;
|
||||
}
|
||||
match current {
|
||||
FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt,
|
||||
_ => FooterMode::ShortcutOverlay,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode {
|
||||
if is_task_running {
|
||||
current
|
||||
} else {
|
||||
FooterMode::EscHint
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
|
||||
match current {
|
||||
FooterMode::EscHint | FooterMode::ShortcutOverlay => FooterMode::ShortcutPrompt,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_mode() -> FooterMode {
|
||||
FooterMode::ShortcutPrompt
|
||||
}
|
||||
|
||||
pub(crate) fn footer_height(props: FooterProps) -> u16 {
|
||||
footer_lines(props).len() as u16
|
||||
}
|
||||
|
||||
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
|
||||
let lines = footer_lines(props);
|
||||
for (idx, line) in lines.into_iter().enumerate() {
|
||||
let y = area.y + idx as u16;
|
||||
if y >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
let row = Rect::new(area.x, y, area.width, 1);
|
||||
line.render_ref(row, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
||||
match props.mode {
|
||||
FooterMode::CtrlCReminder => {
|
||||
vec![ctrl_c_reminder_line(CtrlCReminderState {
|
||||
is_task_running: props.is_task_running,
|
||||
})]
|
||||
}
|
||||
FooterMode::ShortcutPrompt => vec![Line::from(vec!["? for shortcuts".dim()])],
|
||||
FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState {
|
||||
use_shift_enter_hint: props.use_shift_enter_hint,
|
||||
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||
is_task_running: props.is_task_running,
|
||||
}),
|
||||
FooterMode::EscHint => {
|
||||
vec![esc_hint_line(ShortcutsState {
|
||||
use_shift_enter_hint: props.use_shift_enter_hint,
|
||||
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||
is_task_running: props.is_task_running,
|
||||
})]
|
||||
}
|
||||
}
|
||||
pub(crate) token_usage_info: Option<&'a TokenUsageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CtrlCReminderState {
|
||||
is_task_running: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ShortcutsState {
|
||||
use_shift_enter_hint: bool,
|
||||
esc_backtrack_hint: bool,
|
||||
is_task_running: bool,
|
||||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) esc_backtrack_hint: bool,
|
||||
}
|
||||
|
||||
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
|
||||
let action = if state.is_task_running {
|
||||
"interrupt"
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum FooterContent {
|
||||
Shortcuts(ShortcutsState),
|
||||
CtrlCReminder(CtrlCReminderState),
|
||||
}
|
||||
|
||||
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) {
|
||||
let content = if props.ctrl_c_quit_hint {
|
||||
FooterContent::CtrlCReminder(CtrlCReminderState {
|
||||
is_task_running: props.is_task_running,
|
||||
})
|
||||
} else {
|
||||
"quit"
|
||||
FooterContent::Shortcuts(ShortcutsState {
|
||||
use_shift_enter_hint: props.use_shift_enter_hint,
|
||||
esc_backtrack_hint: props.esc_backtrack_hint,
|
||||
})
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::from(format!(" ctrl + c again to {action}")).dim(),
|
||||
])
|
||||
|
||||
let mut spans = footer_spans(content);
|
||||
if let Some(token_usage_info) = props.token_usage_info {
|
||||
append_token_usage_spans(&mut spans, token_usage_info);
|
||||
}
|
||||
|
||||
let spans = spans
|
||||
.into_iter()
|
||||
.map(|span| span.patch_style(Style::default().dim()))
|
||||
.collect::<Vec<_>>();
|
||||
Line::from(spans).render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn esc_hint_line(state: ShortcutsState) -> Line<'static> {
|
||||
let text = if state.esc_backtrack_hint {
|
||||
" esc again to edit previous message"
|
||||
} else {
|
||||
" esc esc to edit previous message"
|
||||
};
|
||||
Line::from(vec![Span::from(text).dim()])
|
||||
fn footer_spans(content: FooterContent) -> Vec<Span<'static>> {
|
||||
match content {
|
||||
FooterContent::Shortcuts(state) => shortcuts_spans(state),
|
||||
FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state),
|
||||
}
|
||||
}
|
||||
|
||||
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
|
||||
let mut rendered = Vec::new();
|
||||
fn append_token_usage_spans(spans: &mut Vec<Span<'static>>, token_usage_info: &TokenUsageInfo) {
|
||||
let token_usage = &token_usage_info.total_token_usage;
|
||||
spans.push(" ".into());
|
||||
spans.push(
|
||||
Span::from(format!(
|
||||
"{} tokens used",
|
||||
format_si_suffix(token_usage.blended_total())
|
||||
))
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
);
|
||||
|
||||
let last_token_usage = &token_usage_info.last_token_usage;
|
||||
if let Some(context_window) = token_usage_info.model_context_window {
|
||||
let percent_remaining: u8 = if context_window > 0 {
|
||||
last_token_usage.percent_of_context_window_remaining(context_window)
|
||||
} else {
|
||||
100
|
||||
};
|
||||
|
||||
let context_style = if percent_remaining < 20 {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default().add_modifier(Modifier::DIM)
|
||||
};
|
||||
spans.push(" ".into());
|
||||
spans.push(Span::styled(
|
||||
format!("{percent_remaining}% context left"),
|
||||
context_style,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn shortcuts_spans(state: ShortcutsState) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
for descriptor in SHORTCUTS {
|
||||
if let Some(text) = descriptor.overlay_entry(state) {
|
||||
rendered.push(text);
|
||||
if let Some(segment) = descriptor.footer_segment(state) {
|
||||
if !segment.prefix.is_empty() {
|
||||
spans.push(segment.prefix.into());
|
||||
}
|
||||
spans.push(segment.binding.span());
|
||||
spans.push(segment.label.into());
|
||||
}
|
||||
}
|
||||
build_columns(rendered)
|
||||
spans
|
||||
}
|
||||
|
||||
fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
|
||||
if entries.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
const COLUMNS: usize = 3;
|
||||
const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28];
|
||||
|
||||
let rows = entries.len().div_ceil(COLUMNS);
|
||||
let mut column_widths = [0usize; COLUMNS];
|
||||
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
let column = idx % COLUMNS;
|
||||
column_widths[column] = column_widths[column].max(entry.len());
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for row in 0..rows {
|
||||
let mut line = String::from(" ");
|
||||
for col in 0..COLUMNS {
|
||||
let idx = row * COLUMNS + col;
|
||||
if idx >= entries.len() {
|
||||
continue;
|
||||
}
|
||||
let entry = &entries[idx];
|
||||
if col < COLUMNS - 1 {
|
||||
let max_width = MAX_PADDED_WIDTHS[col];
|
||||
let target_width = column_widths[col].min(max_width);
|
||||
let pad_width = target_width + 2;
|
||||
line.push_str(&format!("{entry:<pad_width$}"));
|
||||
} else {
|
||||
if col != 0 {
|
||||
line.push_str(" ");
|
||||
}
|
||||
line.push_str(entry);
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(vec![Span::from(line).dim()]));
|
||||
}
|
||||
|
||||
lines
|
||||
fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec<Span<'static>> {
|
||||
let followup = if state.is_task_running {
|
||||
" to interrupt"
|
||||
} else {
|
||||
" to quit"
|
||||
};
|
||||
vec![
|
||||
" ".into(),
|
||||
key_hint::ctrl('C'),
|
||||
" again".into(),
|
||||
followup.into(),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FooterSegment {
|
||||
prefix: &'static str,
|
||||
binding: ShortcutBinding,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
enum ShortcutId {
|
||||
Commands,
|
||||
FilePaths,
|
||||
Send,
|
||||
InsertNewline,
|
||||
PasteImage,
|
||||
Quit,
|
||||
ShowTranscript,
|
||||
ToggleOverlay,
|
||||
Quit,
|
||||
EditPrevious,
|
||||
}
|
||||
|
||||
@@ -192,13 +151,30 @@ enum ShortcutId {
|
||||
struct ShortcutBinding {
|
||||
code: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
overlay_text: &'static str,
|
||||
display: ShortcutDisplay,
|
||||
condition: DisplayCondition,
|
||||
}
|
||||
|
||||
impl ShortcutBinding {
|
||||
fn matches(&self, state: ShortcutsState) -> bool {
|
||||
self.condition.matches(state)
|
||||
fn span(&self) -> Span<'static> {
|
||||
self.display.into_span()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ShortcutDisplay {
|
||||
Plain(&'static str),
|
||||
Ctrl(char),
|
||||
Shift(char),
|
||||
}
|
||||
|
||||
impl ShortcutDisplay {
|
||||
fn into_span(self) -> Span<'static> {
|
||||
match self {
|
||||
ShortcutDisplay::Plain(text) => key_hint::plain(text),
|
||||
ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch),
|
||||
ShortcutDisplay::Shift(ch) => key_hint::shift(ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,66 +198,49 @@ impl DisplayCondition {
|
||||
struct ShortcutDescriptor {
|
||||
id: ShortcutId,
|
||||
bindings: &'static [ShortcutBinding],
|
||||
prefix: &'static str,
|
||||
label: &'static str,
|
||||
footer_label: &'static str,
|
||||
footer_prefix: &'static str,
|
||||
}
|
||||
|
||||
impl ShortcutDescriptor {
|
||||
fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> {
|
||||
self.bindings.iter().find(|binding| binding.matches(state))
|
||||
fn binding_for(&self, state: ShortcutsState) -> Option<ShortcutBinding> {
|
||||
self.bindings
|
||||
.iter()
|
||||
.find(|binding| binding.condition.matches(state))
|
||||
.copied()
|
||||
}
|
||||
|
||||
fn overlay_entry(&self, state: ShortcutsState) -> Option<String> {
|
||||
fn should_show(&self, state: ShortcutsState) -> bool {
|
||||
match self.id {
|
||||
ShortcutId::EditPrevious => state.esc_backtrack_hint,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_segment(&self, state: ShortcutsState) -> Option<FooterSegment> {
|
||||
if !self.should_show(state) {
|
||||
return None;
|
||||
}
|
||||
let binding = self.binding_for(state)?;
|
||||
let label = match self.id {
|
||||
ShortcutId::Quit => {
|
||||
if state.is_task_running {
|
||||
" to interrupt"
|
||||
} else {
|
||||
self.label
|
||||
}
|
||||
}
|
||||
ShortcutId::EditPrevious => {
|
||||
if state.esc_backtrack_hint {
|
||||
" again to edit previous message"
|
||||
} else {
|
||||
" esc to edit previous message"
|
||||
}
|
||||
}
|
||||
_ => self.label,
|
||||
};
|
||||
let text = match self.id {
|
||||
ShortcutId::Quit if state.is_task_running => {
|
||||
format!("{}{} to interrupt", self.prefix, binding.overlay_text)
|
||||
}
|
||||
_ => format!("{}{}{}", self.prefix, binding.overlay_text, label),
|
||||
};
|
||||
Some(text)
|
||||
Some(FooterSegment {
|
||||
prefix: self.footer_prefix,
|
||||
binding,
|
||||
label: self.footer_label,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::Commands,
|
||||
id: ShortcutId::Send,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Char('/'),
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
overlay_text: "/",
|
||||
display: ShortcutDisplay::Plain("⏎"),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " for commands",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::FilePaths,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Char('@'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
overlay_text: "@",
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " for file paths",
|
||||
footer_label: " send ",
|
||||
footer_prefix: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::InsertNewline,
|
||||
@@ -289,154 +248,138 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
|
||||
ShortcutBinding {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::SHIFT,
|
||||
overlay_text: "shift + enter",
|
||||
display: ShortcutDisplay::Shift('⏎'),
|
||||
condition: DisplayCondition::WhenShiftEnterHint,
|
||||
},
|
||||
ShortcutBinding {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
overlay_text: "ctrl + j",
|
||||
display: ShortcutDisplay::Ctrl('J'),
|
||||
condition: DisplayCondition::WhenNotShiftEnterHint,
|
||||
},
|
||||
],
|
||||
prefix: "",
|
||||
label: " for newline",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::PasteImage,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Char('v'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
overlay_text: "ctrl + v",
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to paste images",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::Quit,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
overlay_text: "ctrl + c",
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to exit",
|
||||
footer_label: " newline ",
|
||||
footer_prefix: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::ShowTranscript,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
overlay_text: "ctrl + t",
|
||||
display: ShortcutDisplay::Ctrl('T'),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to view transcript",
|
||||
footer_label: " transcript ",
|
||||
footer_prefix: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::ToggleOverlay,
|
||||
id: ShortcutId::Quit,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Char('?'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
overlay_text: "?",
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
display: ShortcutDisplay::Ctrl('C'),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: " to hide shortcuts",
|
||||
footer_label: " quit",
|
||||
footer_prefix: "",
|
||||
},
|
||||
ShortcutDescriptor {
|
||||
id: ShortcutId::EditPrevious,
|
||||
bindings: &[ShortcutBinding {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
overlay_text: "esc",
|
||||
display: ShortcutDisplay::Plain("Esc"),
|
||||
condition: DisplayCondition::Always,
|
||||
}],
|
||||
prefix: "",
|
||||
label: "",
|
||||
footer_label: " edit prev",
|
||||
footer_prefix: " ",
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
fn snapshot_footer(name: &str, props: FooterProps) {
|
||||
let height = footer_height(props).max(1);
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap();
|
||||
fn snapshot_footer(name: &str, props: FooterProps<'_>) {
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = Rect::new(0, 0, f.area().width, height);
|
||||
let area = Rect::new(0, 0, f.area().width, 1);
|
||||
render_footer(area, f.buffer_mut(), props);
|
||||
})
|
||||
.unwrap();
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
|
||||
fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: total_tokens,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens,
|
||||
};
|
||||
let last = TokenUsage {
|
||||
input_tokens: last_tokens,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: last_tokens,
|
||||
};
|
||||
TokenUsageInfo {
|
||||
total_token_usage: usage,
|
||||
last_token_usage: last,
|
||||
model_context_window: Some(context_window),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_snapshots() {
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_default",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutPrompt,
|
||||
ctrl_c_quit_hint: false,
|
||||
is_task_running: false,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
token_usage_info: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_shortcuts_shift_and_esc",
|
||||
FooterProps {
|
||||
mode: FooterMode::ShortcutOverlay,
|
||||
ctrl_c_quit_hint: false,
|
||||
is_task_running: false,
|
||||
esc_backtrack_hint: true,
|
||||
use_shift_enter_hint: true,
|
||||
is_task_running: false,
|
||||
token_usage_info: Some(&token_usage(4_200, 900, 8_000)),
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_idle",
|
||||
FooterProps {
|
||||
mode: FooterMode::CtrlCReminder,
|
||||
ctrl_c_quit_hint: true,
|
||||
is_task_running: false,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
token_usage_info: None,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_running",
|
||||
FooterProps {
|
||||
mode: FooterMode::CtrlCReminder,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
ctrl_c_quit_hint: true,
|
||||
is_task_running: true,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_esc_hint_idle",
|
||||
FooterProps {
|
||||
mode: FooterMode::EscHint,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
},
|
||||
);
|
||||
|
||||
snapshot_footer(
|
||||
"footer_esc_hint_primed",
|
||||
FooterProps {
|
||||
mode: FooterMode::EscHint,
|
||||
esc_backtrack_hint: true,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
token_usage_info: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::PathBuf;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::tui::FrameRequester;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -102,6 +103,10 @@ impl BottomPane {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
|
||||
self.status.as_ref()
|
||||
}
|
||||
|
||||
fn active_view(&self) -> Option<&dyn BottomPaneView> {
|
||||
self.view_stack.last().map(std::convert::AsRef::as_ref)
|
||||
}
|
||||
@@ -372,6 +377,13 @@ impl BottomPane {
|
||||
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
|
||||
}
|
||||
|
||||
/// Update the *context-window remaining* indicator in the composer. This
|
||||
/// is forwarded directly to the underlying `ChatComposer`.
|
||||
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
|
||||
self.composer.set_token_usage(token_info);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||
self.push_view(view);
|
||||
}
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ? for shortcuts "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ? for shortcuts "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ctrl + c again to interrupt "
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ctrl + c again to quit "
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc esc to edit previous message "
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc again to edit previous message "
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc esc to edit previous message "
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" esc again to edit previous message "
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" / for commands @ for file paths shift + enter for newline "
|
||||
" ctrl + v to paste images ctrl + c to exit ctrl + t to view transcript "
|
||||
" ? to hide shortcuts esc again to edit previous message "
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ? for shortcuts "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ? for shortcuts "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" ? for shortcuts "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
|
||||
+3
-1
@@ -2,4 +2,6 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" ctrl + c again to quit "
|
||||
" ⌃C again to quit "
|
||||
" "
|
||||
" "
|
||||
|
||||
+3
-1
@@ -2,4 +2,6 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" ctrl + c again to interrupt "
|
||||
" ⌃C again to interrupt "
|
||||
" "
|
||||
" "
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" esc esc to edit previous message "
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" esc again to edit previous message "
|
||||
+3
-1
@@ -2,4 +2,6 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"? for shortcuts "
|
||||
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
" "
|
||||
" "
|
||||
|
||||
+3
-3
@@ -2,6 +2,6 @@
|
||||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" / for commands @ for file paths shift + enter for ne"
|
||||
" ctrl + v to paste images ctrl + c to exit ctrl + t to view tra"
|
||||
" ? to hide shortcuts esc again to edit previous message "
|
||||
"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use"
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -255,6 +255,8 @@ pub(crate) struct ChatWidget {
|
||||
// List of ghost commits corresponding to each turn.
|
||||
ghost_snapshots: Vec<GhostCommit>,
|
||||
ghost_snapshots_disabled: bool,
|
||||
// Whether to add a final message separator after the last message
|
||||
needs_final_message_separator: bool,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -394,6 +396,7 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
|
||||
if info.is_some() {
|
||||
self.bottom_pane.set_token_usage(info.clone());
|
||||
self.token_info = info;
|
||||
}
|
||||
}
|
||||
@@ -655,6 +658,14 @@ impl ChatWidget {
|
||||
self.flush_active_cell();
|
||||
|
||||
if self.stream_controller.is_none() {
|
||||
if self.needs_final_message_separator {
|
||||
let elapsed_seconds = self
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds);
|
||||
self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds));
|
||||
self.needs_final_message_separator = false;
|
||||
}
|
||||
self.stream_controller = Some(StreamController::new(self.config.clone()));
|
||||
}
|
||||
if let Some(controller) = self.stream_controller.as_mut()
|
||||
@@ -908,6 +919,7 @@ impl ChatWidget {
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
needs_final_message_separator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,6 +981,7 @@ impl ChatWidget {
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: true,
|
||||
needs_final_message_separator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1195,6 +1208,7 @@ impl ChatWidget {
|
||||
|
||||
fn flush_active_cell(&mut self) {
|
||||
if let Some(active) = self.active_cell.take() {
|
||||
self.needs_final_message_separator = true;
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
|
||||
}
|
||||
}
|
||||
@@ -1207,6 +1221,7 @@ impl ChatWidget {
|
||||
if !cell.display_lines(u16::MAX).is_empty() {
|
||||
// Only break exec grouping if the cell renders visible lines.
|
||||
self.flush_active_cell();
|
||||
self.needs_final_message_separator = true;
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
|
||||
}
|
||||
@@ -1248,6 +1263,7 @@ impl ChatWidget {
|
||||
if !text.is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(text));
|
||||
}
|
||||
self.needs_final_message_separator = false;
|
||||
}
|
||||
|
||||
fn capture_ghost_snapshot(&mut self) {
|
||||
@@ -1967,6 +1983,7 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn clear_token_usage(&mut self) {
|
||||
self.token_info = None;
|
||||
self.bottom_pane.set_token_usage(None);
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
|
||||
+5
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1152
|
||||
expression: "lines[start_idx..].join(\"\\n\")"
|
||||
---
|
||||
• I need to check the codex-rs repository to explain why the project's binaries
|
||||
@@ -9,6 +10,8 @@ expression: "lines[start_idx..].join(\"\\n\")"
|
||||
is set up. I should look into the Cargo.toml file to confirm features and
|
||||
profiles without needing to edit any code. Let's get started on this!
|
||||
|
||||
─ Worked for 0s ────────────────────────────────────────────────────────────────
|
||||
|
||||
• I’m going to scan the workspace and Cargo manifests to see build profiles and
|
||||
dependencies that impact binary size. Then I’ll summarize the main causes.
|
||||
|
||||
@@ -110,6 +113,8 @@ expression: "lines[start_idx..].join(\"\\n\")"
|
||||
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
|
||||
reducing size, but I want to stay focused on answering the user's question.
|
||||
|
||||
─ Worked for 0s ────────────────────────────────────────────────────────────────
|
||||
|
||||
• Here’s what’s driving size in this workspace’s binaries.
|
||||
|
||||
Main Causes
|
||||
|
||||
+1
-2
@@ -14,5 +14,4 @@ expression: term.backend().vt100().screen().contents()
|
||||
|
||||
› Summarize recent commits
|
||||
|
||||
|
||||
? for shortcuts
|
||||
⏎ send ⌃J newline ⌃T transcript ⌃C quit
|
||||
|
||||
+1
-2
@@ -8,6 +8,5 @@ expression: terminal.backend()
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" "
|
||||
" ? for shortcuts "
|
||||
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
|
||||
" "
|
||||
|
||||
@@ -341,6 +341,7 @@ fn make_chatwidget_manual() -> (
|
||||
is_review_mode: false,
|
||||
ghost_snapshots: Vec::new(),
|
||||
ghost_snapshots_disabled: false,
|
||||
needs_final_message_separator: false,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
@@ -415,6 +415,11 @@ pub(crate) fn new_session_info(
|
||||
"/model".into(),
|
||||
" - choose what model and reasoning effort to use".dim(),
|
||||
]),
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
"/review".into(),
|
||||
" - review any changes and find issues".dim(),
|
||||
]),
|
||||
];
|
||||
|
||||
CompositeHistoryCell {
|
||||
@@ -1091,6 +1096,40 @@ pub(crate) fn new_reasoning_summary_block(
|
||||
Box::new(new_reasoning_block(full_reasoning_buffer, config))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FinalMessageSeparator {
|
||||
elapsed_seconds: Option<u64>,
|
||||
}
|
||||
impl FinalMessageSeparator {
|
||||
pub(crate) fn new(elapsed_seconds: Option<u64>) -> Self {
|
||||
Self { elapsed_seconds }
|
||||
}
|
||||
}
|
||||
impl HistoryCell for FinalMessageSeparator {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let elapsed_seconds = self
|
||||
.elapsed_seconds
|
||||
.map(super::status_indicator_widget::fmt_elapsed_compact);
|
||||
if let Some(elapsed_seconds) = elapsed_seconds {
|
||||
let worked_for = format!("─ Worked for {elapsed_seconds} ─");
|
||||
let worked_for_width = worked_for.width();
|
||||
vec![
|
||||
Line::from_iter([
|
||||
worked_for,
|
||||
"─".repeat((width as usize).saturating_sub(worked_for_width)),
|
||||
])
|
||||
.dim(),
|
||||
]
|
||||
} else {
|
||||
vec![Line::from_iter(["─".repeat(width as usize).dim()])]
|
||||
}
|
||||
}
|
||||
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
||||
let args_str = invocation
|
||||
.arguments
|
||||
|
||||
@@ -10,6 +10,20 @@ const ALT_PREFIX: &str = "⌥";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const ALT_PREFIX: &str = "Alt+";
|
||||
|
||||
#[cfg(test)]
|
||||
const CTRL_PREFIX: &str = "⌃";
|
||||
#[cfg(all(not(test), target_os = "macos"))]
|
||||
const CTRL_PREFIX: &str = "⌃";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const CTRL_PREFIX: &str = "Ctrl+";
|
||||
|
||||
#[cfg(test)]
|
||||
const SHIFT_PREFIX: &str = "⇧";
|
||||
#[cfg(all(not(test), target_os = "macos"))]
|
||||
const SHIFT_PREFIX: &str = "⇧";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const SHIFT_PREFIX: &str = "Shift+";
|
||||
|
||||
fn key_hint_style() -> Style {
|
||||
Style::default().bold()
|
||||
}
|
||||
@@ -18,6 +32,18 @@ fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
|
||||
Span::styled(format!("{prefix}{key}"), key_hint_style())
|
||||
}
|
||||
|
||||
pub(crate) fn ctrl(key: impl Display) -> Span<'static> {
|
||||
modifier_span(CTRL_PREFIX, key)
|
||||
}
|
||||
|
||||
pub(crate) fn alt(key: impl Display) -> Span<'static> {
|
||||
modifier_span(ALT_PREFIX, key)
|
||||
}
|
||||
|
||||
pub(crate) fn shift(key: impl Display) -> Span<'static> {
|
||||
modifier_span(SHIFT_PREFIX, key)
|
||||
}
|
||||
|
||||
pub(crate) fn plain(key: impl Display) -> Span<'static> {
|
||||
Span::styled(format!("{key}"), key_hint_style())
|
||||
}
|
||||
|
||||
+30
-196
@@ -12,10 +12,8 @@ use codex_core::RolloutRecorder;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::config::GPT_5_CODEX_MEDIUM_MODEL;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_config_as_toml_with_cli_overrides;
|
||||
use codex_core::config::persist_model_selection;
|
||||
use codex_core::find_conversation_path_by_id_str;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
@@ -54,7 +52,6 @@ pub mod live_wrap;
|
||||
mod markdown;
|
||||
mod markdown_render;
|
||||
mod markdown_stream;
|
||||
mod new_model_popup;
|
||||
pub mod onboarding;
|
||||
mod pager_overlay;
|
||||
mod render;
|
||||
@@ -79,14 +76,11 @@ pub mod test_backend;
|
||||
#[cfg(not(debug_assertions))]
|
||||
mod updates;
|
||||
|
||||
use crate::new_model_popup::ModelUpgradeDecision;
|
||||
use crate::new_model_popup::run_model_upgrade_popup;
|
||||
use crate::onboarding::TrustDirectorySelection;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
|
||||
use crate::onboarding::onboarding_screen::run_onboarding_app;
|
||||
use crate::tui::Tui;
|
||||
pub use cli::Cli;
|
||||
use codex_core::internal_storage::InternalStorage;
|
||||
|
||||
// (tests access modules directly within the crate)
|
||||
|
||||
@@ -205,8 +199,6 @@ pub async fn run_main(
|
||||
cli_profile_override,
|
||||
)?;
|
||||
|
||||
let internal_storage = InternalStorage::load(&config.codex_home);
|
||||
|
||||
let log_dir = codex_core::config::log_dir(&config)?;
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
// Open (or create) your log file, appending to it.
|
||||
@@ -250,21 +242,14 @@ pub async fn run_main(
|
||||
|
||||
let _ = tracing_subscriber::registry().with(file_layer).try_init();
|
||||
|
||||
run_ratatui_app(
|
||||
cli,
|
||||
config,
|
||||
internal_storage,
|
||||
active_profile,
|
||||
should_show_trust_screen,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
run_ratatui_app(cli, config, active_profile, should_show_trust_screen)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
}
|
||||
|
||||
async fn run_ratatui_app(
|
||||
cli: Cli,
|
||||
config: Config,
|
||||
mut internal_storage: InternalStorage,
|
||||
active_profile: Option<String>,
|
||||
should_show_trust_screen: bool,
|
||||
) -> color_eyre::Result<AppExitInfo> {
|
||||
@@ -289,6 +274,8 @@ async fn run_ratatui_app(
|
||||
// within the TUI scrollback. Building spans keeps styling consistent.
|
||||
#[cfg(not(debug_assertions))]
|
||||
if let Some(latest_version) = updates::get_upgrade_version(&config) {
|
||||
use crate::history_cell::padded_emoji;
|
||||
use crate::history_cell::with_border_with_inner_width;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
|
||||
@@ -296,16 +283,27 @@ async fn run_ratatui_app(
|
||||
let exe = std::env::current_exe()?;
|
||||
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from(vec![
|
||||
"✨⬆️ Update available!".bold().cyan(),
|
||||
" ".into(),
|
||||
format!("{current_version} -> {latest_version}.").into(),
|
||||
]));
|
||||
let mut content_lines: Vec<Line<'static>> = vec![
|
||||
Line::from(vec![
|
||||
padded_emoji("✨").bold().cyan(),
|
||||
"Update available!".bold().cyan(),
|
||||
" ".into(),
|
||||
format!("{current_version} -> {latest_version}.").bold(),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from("See full release notes:"),
|
||||
Line::from(""),
|
||||
Line::from(
|
||||
"https://github.com/openai/codex/releases/latest"
|
||||
.cyan()
|
||||
.underlined(),
|
||||
),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
if managed_by_npm {
|
||||
let npm_cmd = "npm install -g @openai/codex@latest";
|
||||
lines.push(Line::from(vec![
|
||||
content_lines.push(Line::from(vec![
|
||||
"Run ".into(),
|
||||
npm_cmd.cyan(),
|
||||
" to update.".into(),
|
||||
@@ -314,19 +312,22 @@ async fn run_ratatui_app(
|
||||
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
|
||||
{
|
||||
let brew_cmd = "brew upgrade codex";
|
||||
lines.push(Line::from(vec![
|
||||
content_lines.push(Line::from(vec![
|
||||
"Run ".into(),
|
||||
brew_cmd.cyan(),
|
||||
" to update.".into(),
|
||||
]));
|
||||
} else {
|
||||
lines.push(Line::from(vec![
|
||||
content_lines.push(Line::from(vec![
|
||||
"See ".into(),
|
||||
"https://github.com/openai/codex/releases/latest".cyan(),
|
||||
" for the latest releases and installation options.".into(),
|
||||
"https://github.com/openai/codex".cyan().underlined(),
|
||||
" for installation options.".into(),
|
||||
]));
|
||||
}
|
||||
|
||||
let viewport_width = tui.terminal.viewport_area.width as usize;
|
||||
let inner_width = viewport_width.saturating_sub(4).max(1);
|
||||
let mut lines = with_border_with_inner_width(content_lines, inner_width);
|
||||
lines.push("".into());
|
||||
tui.insert_history_lines(lines);
|
||||
}
|
||||
@@ -390,36 +391,6 @@ async fn run_ratatui_app(
|
||||
resume_picker::ResumeSelection::StartFresh
|
||||
};
|
||||
|
||||
if should_show_model_rollout_prompt(
|
||||
&cli,
|
||||
&config,
|
||||
active_profile.as_deref(),
|
||||
internal_storage.gpt_5_codex_model_prompt_seen,
|
||||
) {
|
||||
internal_storage.gpt_5_codex_model_prompt_seen = true;
|
||||
if let Err(e) = internal_storage.persist().await {
|
||||
error!("Failed to persist internal storage: {e:?}");
|
||||
}
|
||||
|
||||
let upgrade_decision = run_model_upgrade_popup(&mut tui).await?;
|
||||
let switch_to_new_model = upgrade_decision == ModelUpgradeDecision::Switch;
|
||||
|
||||
if switch_to_new_model {
|
||||
config.model = GPT_5_CODEX_MEDIUM_MODEL.to_owned();
|
||||
config.model_reasoning_effort = None;
|
||||
if let Err(e) = persist_model_selection(
|
||||
&config.codex_home,
|
||||
active_profile.as_deref(),
|
||||
&config.model,
|
||||
config.model_reasoning_effort,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to persist model selection: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Cli { prompt, images, .. } = cli;
|
||||
|
||||
let app_result = App::run(
|
||||
@@ -533,140 +504,3 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool
|
||||
|
||||
login_status == LoginStatus::NotAuthenticated
|
||||
}
|
||||
|
||||
fn should_show_model_rollout_prompt(
|
||||
cli: &Cli,
|
||||
config: &Config,
|
||||
active_profile: Option<&str>,
|
||||
gpt_5_codex_model_prompt_seen: bool,
|
||||
) -> bool {
|
||||
let login_status = get_login_status(config);
|
||||
|
||||
active_profile.is_none()
|
||||
&& cli.model.is_none()
|
||||
&& !gpt_5_codex_model_prompt_seen
|
||||
&& config.model_provider.requires_openai_auth
|
||||
&& matches!(login_status, LoginStatus::AuthMode(AuthMode::ChatGPT))
|
||||
&& !cli.oss
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
use codex_core::auth::AuthDotJson;
|
||||
use codex_core::auth::get_auth_file;
|
||||
use codex_core::auth::login_with_api_key;
|
||||
use codex_core::auth::write_auth_json;
|
||||
use codex_core::token_data::IdTokenInfo;
|
||||
use codex_core::token_data::TokenData;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn get_next_codex_home() -> PathBuf {
|
||||
static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let mut codex_home = std::env::temp_dir();
|
||||
let unique_suffix = format!(
|
||||
"codex_tui_test_{}_{}",
|
||||
std::process::id(),
|
||||
NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
codex_home.push(unique_suffix);
|
||||
codex_home
|
||||
}
|
||||
|
||||
fn make_config() -> Config {
|
||||
// Create a unique CODEX_HOME per test to isolate auth.json writes.
|
||||
let codex_home = get_next_codex_home();
|
||||
std::fs::create_dir_all(&codex_home).expect("create unique CODEX_HOME");
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
codex_home,
|
||||
)
|
||||
.expect("load default config")
|
||||
}
|
||||
|
||||
/// Test helper to write an `auth.json` with the requested auth mode into the
|
||||
/// provided CODEX_HOME directory. This ensures `get_login_status()` reads the
|
||||
/// intended mode deterministically.
|
||||
fn set_auth_method(codex_home: &std::path::Path, mode: AuthMode) {
|
||||
match mode {
|
||||
AuthMode::ApiKey => {
|
||||
login_with_api_key(codex_home, "sk-test-key").expect("write api key auth.json");
|
||||
}
|
||||
AuthMode::ChatGPT => {
|
||||
// Minimal valid JWT payload: header.payload.signature (all base64url, no padding)
|
||||
const FAKE_JWT: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.e30.c2ln"; // {"alg":"none","typ":"JWT"}.{}."sig"
|
||||
let mut id_info = IdTokenInfo::default();
|
||||
id_info.raw_jwt = FAKE_JWT.to_string();
|
||||
let auth = AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: id_info,
|
||||
access_token: "access-token".to_string(),
|
||||
refresh_token: "refresh-token".to_string(),
|
||||
account_id: None,
|
||||
}),
|
||||
last_refresh: None,
|
||||
};
|
||||
let file = get_auth_file(codex_home);
|
||||
write_auth_json(&file, &auth).expect("write chatgpt auth.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_login_when_not_authenticated() {
|
||||
let cfg = make_config();
|
||||
assert!(should_show_login_screen(
|
||||
LoginStatus::NotAuthenticated,
|
||||
&cfg
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_model_rollout_prompt_for_default_model() {
|
||||
let cli = Cli::parse_from(["codex"]);
|
||||
let cfg = make_config();
|
||||
set_auth_method(&cfg.codex_home, AuthMode::ChatGPT);
|
||||
assert!(should_show_model_rollout_prompt(&cli, &cfg, None, false,));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_model_rollout_prompt_when_api_auth_mode() {
|
||||
let cli = Cli::parse_from(["codex"]);
|
||||
let cfg = make_config();
|
||||
set_auth_method(&cfg.codex_home, AuthMode::ApiKey);
|
||||
assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, false,));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_model_rollout_prompt_when_marked_seen() {
|
||||
let cli = Cli::parse_from(["codex"]);
|
||||
let cfg = make_config();
|
||||
set_auth_method(&cfg.codex_home, AuthMode::ChatGPT);
|
||||
assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, true,));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_model_rollout_prompt_when_cli_overrides_model() {
|
||||
let cli = Cli::parse_from(["codex", "--model", "gpt-4.1"]);
|
||||
let cfg = make_config();
|
||||
set_auth_method(&cfg.codex_home, AuthMode::ChatGPT);
|
||||
assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, false,));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_model_rollout_prompt_when_profile_active() {
|
||||
let cli = Cli::parse_from(["codex"]);
|
||||
let cfg = make_config();
|
||||
set_auth_method(&cfg.codex_home, AuthMode::ChatGPT);
|
||||
assert!(!should_show_model_rollout_prompt(
|
||||
&cli,
|
||||
&cfg,
|
||||
Some("gpt5"),
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
use crate::ascii_animation::AsciiAnimation;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
const MIN_ANIMATION_HEIGHT: u16 = 24;
|
||||
const MIN_ANIMATION_WIDTH: u16 = 60;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ModelUpgradeDecision {
|
||||
Switch,
|
||||
KeepCurrent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ModelUpgradeOption {
|
||||
TryNewModel,
|
||||
KeepCurrent,
|
||||
}
|
||||
|
||||
struct ModelUpgradePopup {
|
||||
highlighted: ModelUpgradeOption,
|
||||
decision: Option<ModelUpgradeDecision>,
|
||||
animation: AsciiAnimation,
|
||||
}
|
||||
|
||||
impl ModelUpgradePopup {
|
||||
fn new(request_frame: FrameRequester) -> Self {
|
||||
Self {
|
||||
highlighted: ModelUpgradeOption::TryNewModel,
|
||||
decision: None,
|
||||
animation: AsciiAnimation::new(request_frame),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => self.highlight(ModelUpgradeOption::TryNewModel),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.highlight(ModelUpgradeOption::KeepCurrent),
|
||||
KeyCode::Char('1') => self.select(ModelUpgradeOption::TryNewModel),
|
||||
KeyCode::Char('2') => self.select(ModelUpgradeOption::KeepCurrent),
|
||||
KeyCode::Enter => self.select(self.highlighted),
|
||||
KeyCode::Esc => self.select(ModelUpgradeOption::KeepCurrent),
|
||||
KeyCode::Char('.') => {
|
||||
if key_event.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
let _ = self.animation.pick_random_variant();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight(&mut self, option: ModelUpgradeOption) {
|
||||
if self.highlighted != option {
|
||||
self.highlighted = option;
|
||||
self.animation.request_frame();
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, option: ModelUpgradeOption) {
|
||||
self.decision = Some(option.into());
|
||||
self.animation.request_frame();
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ModelUpgradeOption> for ModelUpgradeDecision {
|
||||
fn from(option: ModelUpgradeOption) -> Self {
|
||||
match option {
|
||||
ModelUpgradeOption::TryNewModel => ModelUpgradeDecision::Switch,
|
||||
ModelUpgradeOption::KeepCurrent => ModelUpgradeDecision::KeepCurrent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ModelUpgradePopup {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
self.animation.schedule_next_frame();
|
||||
|
||||
// Skip the animation entirely when the viewport is too small so we don't clip frames.
|
||||
let show_animation =
|
||||
area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH;
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if show_animation {
|
||||
let frame = self.animation.current_frame();
|
||||
lines.extend(frame.lines().map(Into::into));
|
||||
// Spacer between animation and text content.
|
||||
lines.push("".into());
|
||||
}
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
" ".into(),
|
||||
"Introducing GPT-5-Codex".bold(),
|
||||
]));
|
||||
lines.push("".into());
|
||||
lines.push(
|
||||
" GPT-5-Codex works faster through easy tasks and harder on complex tasks,".into(),
|
||||
);
|
||||
lines.push(" improves on code quality, and is more steerable with AGENTS.md.".into());
|
||||
lines.push("".into());
|
||||
|
||||
let create_option =
|
||||
|index: usize, option: ModelUpgradeOption, text: &str| -> Line<'static> {
|
||||
if self.highlighted == option {
|
||||
Line::from(vec![
|
||||
format!("> {}. ", index + 1).cyan(),
|
||||
text.to_owned().cyan(),
|
||||
])
|
||||
} else {
|
||||
format!(" {}. {text}", index + 1).into()
|
||||
}
|
||||
};
|
||||
|
||||
lines.push(create_option(
|
||||
0,
|
||||
ModelUpgradeOption::TryNewModel,
|
||||
"Try the new GPT-5-Codex model",
|
||||
));
|
||||
lines.push("".into());
|
||||
lines.push(create_option(
|
||||
1,
|
||||
ModelUpgradeOption::KeepCurrent,
|
||||
"Continue using current model",
|
||||
));
|
||||
lines.push("".into());
|
||||
lines.push(
|
||||
" Press Enter to confirm or Esc to keep your current model"
|
||||
.dim()
|
||||
.into(),
|
||||
);
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_model_upgrade_popup(tui: &mut Tui) -> Result<ModelUpgradeDecision> {
|
||||
let mut popup = ModelUpgradePopup::new(tui.frame_requester());
|
||||
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&popup, frame.area());
|
||||
})?;
|
||||
|
||||
let events = tui.event_stream();
|
||||
tokio::pin!(events);
|
||||
while popup.decision.is_none() {
|
||||
if let Some(event) = events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => popup.handle_key_event(key_event),
|
||||
TuiEvent::Draw => {
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&popup, frame.area());
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(popup.decision.unwrap_or(ModelUpgradeDecision::KeepCurrent))
|
||||
}
|
||||
@@ -34,7 +34,7 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
|
||||
// Format elapsed seconds into a compact human-friendly form used by the status line.
|
||||
// Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s
|
||||
fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
|
||||
pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
|
||||
if elapsed_secs < 60 {
|
||||
return format!("{elapsed_secs}s");
|
||||
}
|
||||
@@ -142,7 +142,7 @@ impl StatusIndicatorWidget {
|
||||
elapsed.as_secs()
|
||||
}
|
||||
|
||||
fn elapsed_seconds(&self) -> u64 {
|
||||
pub fn elapsed_seconds(&self) -> u64 {
|
||||
self.elapsed_seconds_at(Instant::now())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user