Merge branch 'main' into codex/viyatb/trusted-project-config-gating

This commit is contained in:
viyatb-oai
2026-04-11 14:04:44 -07:00
committed by GitHub
55 changed files with 1778 additions and 612 deletions
+71
View File
@@ -0,0 +1,71 @@
FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04
ARG TZ
ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_MAJOR=22
ARG RUST_TOOLCHAIN=1.92.0
ARG CODEX_NPM_VERSION=latest
ENV TZ="$TZ"
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
curl \
git \
ca-certificates \
pkg-config \
clang \
musl-tools \
libssl-dev \
libsqlite3-dev \
just \
python3 \
python3-pip \
jq \
less \
man-db \
unzip \
ripgrep \
fzf \
fd-find \
zsh \
dnsutils \
iproute2 \
ipset \
iptables \
aggregate \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g corepack@latest "@openai/codex@${CODEX_NPM_VERSION}" \
&& corepack enable \
&& corepack prepare pnpm@10.28.2 --activate \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY .devcontainer/init-firewall.sh /usr/local/bin/init-firewall.sh
COPY .devcontainer/post_install.py /opt/post_install.py
COPY .devcontainer/post-start.sh /opt/post_start.sh
RUN chmod 500 /usr/local/bin/init-firewall.sh \
&& chmod 755 /opt/post_start.sh \
&& chmod 644 /opt/post_install.py \
&& chown vscode:vscode /opt/post_install.py
RUN install -d -m 0775 -o vscode -g vscode /commandhistory /workspace \
&& touch /commandhistory/.bash_history /commandhistory/.zsh_history \
&& chown vscode:vscode /commandhistory/.bash_history /commandhistory/.zsh_history
USER vscode
ENV PATH="/home/vscode/.cargo/bin:${PATH}"
WORKDIR /workspace
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}" \
&& rustup component add clippy rustfmt rust-src \
&& rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
+30 -15
View File
@@ -1,10 +1,36 @@
# Containerized Development
We provide the following options to facilitate Codex development in a container. This is particularly useful for verifying the Linux build when working on a macOS host.
We provide two container paths:
- `devcontainer.json` keeps the existing Codex contributor setup for working on this repository.
- `devcontainer.secure.json` adds a customer-oriented profile with stricter outbound network controls.
## Codex contributor profile
Use `devcontainer.json` when you are developing Codex itself. This is the same lightweight arm64 container that already exists in the repo.
## Secure customer profile
Use `devcontainer.secure.json` when you want a stricter runtime profile for running Codex inside a project container:
- installs the Codex CLI plus common build tools
- enables firewall startup with an allowlist-driven outbound policy
- blocks IPv6 by default so the allowlist cannot be bypassed over AAAA routes
- requires `NET_ADMIN` and `NET_RAW` so the firewall can be installed at startup
This profile keeps the stricter networking isolated to the customer path instead of changing the default Codex contributor container.
Start it from the CLI with:
```bash
devcontainer up --workspace-folder . --config .devcontainer/devcontainer.secure.json
```
In VS Code, choose **Dev Containers: Open Folder in Container...** and select `.devcontainer/devcontainer.secure.json`.
## Docker
To build the Docker image locally for x64 and then run it with the repo mounted under `/workspace`:
To build the contributor image locally for x64 and then run it with the repo mounted under `/workspace`:
```shell
CODEX_DOCKER_IMAGE_NAME=codex-linux-dev
@@ -14,17 +40,6 @@ docker run --platform=linux/amd64 --rm -it -e CARGO_TARGET_DIR=/workspace/codex-
Note that `/workspace/target` will contain the binaries built for your host platform, so we include `-e CARGO_TARGET_DIR=/workspace/codex-rs/target-amd64` in the `docker run` command so that the binaries built inside your container are written to a separate directory.
For arm64, specify `--platform=linux/amd64` instead for both `docker build` and `docker run`.
For arm64, specify `--platform=linux/arm64` instead for both `docker build` and `docker run`.
Currently, the `Dockerfile` works for both x64 and arm64 Linux, though you need to run `rustup target add x86_64-unknown-linux-musl` yourself to install the musl toolchain for x64.
## VS Code
VS Code recognizes the `devcontainer.json` file and gives you the option to develop Codex in a container. Currently, `devcontainer.json` builds and runs the `arm64` flavor of the container.
From the integrated terminal in VS Code, you can build either flavor of the `arm64` build (GNU or musl):
```shell
cargo build --target aarch64-unknown-linux-musl
cargo build --target aarch64-unknown-linux-gnu
```
Currently, the contributor `Dockerfile` works for both x64 and arm64 Linux, though you need to run `rustup target add x86_64-unknown-linux-musl` yourself to install the musl toolchain for x64.
+76
View File
@@ -0,0 +1,76 @@
{
"$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json",
"name": "Codex (Secure)",
"build": {
"dockerfile": "Dockerfile.secure",
"context": "..",
"args": {
"TZ": "${localEnv:TZ:UTC}",
"NODE_MAJOR": "22",
"RUST_TOOLCHAIN": "1.92.0",
"CODEX_NPM_VERSION": "latest"
}
},
"runArgs": [
"--cap-add=NET_ADMIN",
"--cap-add=NET_RAW"
],
"init": true,
"updateRemoteUserUID": true,
"remoteUser": "vscode",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
"mounts": [
"source=codex-commandhistory-${devcontainerId},target=/commandhistory,type=volume",
"source=codex-home-${devcontainerId},target=/home/vscode/.codex,type=volume",
"source=codex-gh-${devcontainerId},target=/home/vscode/.config/gh,type=volume",
"source=codex-cargo-registry-${devcontainerId},target=/home/vscode/.cargo/registry,type=volume",
"source=codex-cargo-git-${devcontainerId},target=/home/vscode/.cargo/git,type=volume",
"source=codex-rustup-${devcontainerId},target=/home/vscode/.rustup,type=volume",
"source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,readonly"
],
"containerEnv": {
"RUST_BACKTRACE": "1",
"CODEX_UNSAFE_ALLOW_NO_SANDBOX": "1",
"CODEX_ENABLE_FIREWALL": "1",
"CODEX_INCLUDE_GITHUB_META_RANGES": "1",
"OPENAI_ALLOWED_DOMAINS": "api.openai.com auth.openai.com github.com api.github.com codeload.github.com raw.githubusercontent.com objects.githubusercontent.com crates.io index.crates.io static.crates.io static.rust-lang.org registry.npmjs.org pypi.org files.pythonhosted.org",
"CARGO_TARGET_DIR": "/workspace/.cache/cargo-target",
"GIT_CONFIG_GLOBAL": "/home/vscode/.gitconfig.local",
"COREPACK_ENABLE_DOWNLOAD_PROMPT": "0",
"PYTHONDONTWRITEBYTECODE": "1",
"PIP_DISABLE_PIP_VERSION_CHECK": "1"
},
"remoteEnv": {
"OPENAI_API_KEY": "${localEnv:OPENAI_API_KEY}"
},
"postCreateCommand": "python3 /opt/post_install.py",
"postStartCommand": "bash /opt/post_start.sh",
"waitFor": "postStartCommand",
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.profiles.linux": {
"bash": {
"path": "bash",
"icon": "terminal-bash"
},
"zsh": {
"path": "zsh"
}
},
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true
},
"extensions": [
"openai.chatgpt",
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"vadimcn.vscode-lldb",
"ms-azuretools.vscode-docker"
]
}
}
}
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
allowed_domains_file="/etc/codex/allowed_domains.txt"
include_github_meta_ranges="${CODEX_INCLUDE_GITHUB_META_RANGES:-1}"
if [ -f "$allowed_domains_file" ]; then
mapfile -t allowed_domains < <(sed '/^\s*#/d;/^\s*$/d' "$allowed_domains_file")
else
allowed_domains=("api.openai.com")
fi
if [ "${#allowed_domains[@]}" -eq 0 ]; then
echo "ERROR: No allowed domains configured"
exit 1
fi
add_ipv4_cidr_to_allowlist() {
local source="$1"
local cidr="$2"
if [[ ! "$cidr" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$ ]]; then
echo "ERROR: Invalid ${source} CIDR range: $cidr"
exit 1
fi
ipset add allowed-domains "$cidr" -exist
}
configure_ipv6_default_deny() {
if ! command -v ip6tables >/dev/null 2>&1; then
echo "ERROR: ip6tables is required to enforce IPv6 default-deny policy"
exit 1
fi
ip6tables -F
ip6tables -X
ip6tables -t mangle -F
ip6tables -t mangle -X
ip6tables -t nat -F 2>/dev/null || true
ip6tables -t nat -X 2>/dev/null || true
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
ip6tables -P INPUT DROP
ip6tables -P FORWARD DROP
ip6tables -P OUTPUT DROP
echo "IPv6 firewall policy configured (default-deny)"
}
# Preserve docker-managed DNS NAT rules before clearing tables.
docker_dns_rules="$(iptables-save -t nat | grep "127\\.0\\.0\\.11" || true)"
iptables -F
iptables -X
iptables -t nat -F
iptables -t nat -X
iptables -t mangle -F
iptables -t mangle -X
ipset destroy allowed-domains 2>/dev/null || true
if [ -n "$docker_dns_rules" ]; then
echo "Restoring Docker DNS NAT rules"
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
while IFS= read -r rule; do
[ -z "$rule" ] && continue
iptables -t nat $rule
done <<< "$docker_dns_rules"
fi
# Allow DNS resolution and localhost communication.
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
iptables -A INPUT -p udp --sport 53 -j ACCEPT
iptables -A INPUT -p tcp --sport 53 -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
ipset create allowed-domains hash:net
for domain in "${allowed_domains[@]}"; do
echo "Resolving $domain"
ips="$(dig +short A "$domain" | sed '/^\s*$/d')"
if [ -z "$ips" ]; then
echo "ERROR: Failed to resolve $domain"
exit 1
fi
while IFS= read -r ip; do
if [[ ! "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
echo "ERROR: Invalid IPv4 address from DNS for $domain: $ip"
exit 1
fi
ipset add allowed-domains "$ip" -exist
done <<< "$ips"
done
if [ "$include_github_meta_ranges" = "1" ]; then
echo "Fetching GitHub meta ranges"
github_meta="$(curl -fsSL --connect-timeout 10 https://api.github.com/meta)"
if ! echo "$github_meta" | jq -e '.web and .api and .git' >/dev/null; then
echo "ERROR: GitHub meta response missing expected fields"
exit 1
fi
while IFS= read -r cidr; do
[ -z "$cidr" ] && continue
if [[ "$cidr" == *:* ]]; then
# Current policy enforces IPv4-only ipset entries.
continue
fi
add_ipv4_cidr_to_allowlist "GitHub" "$cidr"
done < <(echo "$github_meta" | jq -r '((.web // []) + (.api // []) + (.git // []))[]' | sort -u)
fi
host_ip="$(ip route | awk '/default/ {print $3; exit}')"
if [ -z "$host_ip" ]; then
echo "ERROR: Failed to detect host IP"
exit 1
fi
host_network="$(echo "$host_ip" | sed 's/\.[0-9]*$/.0\/24/')"
iptables -A INPUT -s "$host_network" -j ACCEPT
iptables -A OUTPUT -d "$host_network" -j ACCEPT
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# Reject rather than silently drop to make policy failures obvious.
iptables -A INPUT -j REJECT --reject-with icmp-admin-prohibited
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
iptables -A FORWARD -j REJECT --reject-with icmp-admin-prohibited
configure_ipv6_default_deny
echo "Firewall configuration complete"
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - was able to reach https://example.com"
exit 1
fi
if ! curl --connect-timeout 5 https://api.openai.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - unable to reach https://api.openai.com"
exit 1
fi
if [ "$include_github_meta_ranges" = "1" ] && ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
exit 1
fi
if curl --connect-timeout 5 -6 https://example.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - was able to reach https://example.com over IPv6"
exit 1
fi
echo "Firewall verification passed"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "${CODEX_ENABLE_FIREWALL:-1}" != "1" ]; then
echo "[devcontainer] Firewall mode: permissive (CODEX_ENABLE_FIREWALL=${CODEX_ENABLE_FIREWALL:-unset})."
exit 0
fi
echo "[devcontainer] Firewall mode: strict"
domains_raw="${OPENAI_ALLOWED_DOMAINS:-api.openai.com}"
mapfile -t domains < <(printf '%s\n' "$domains_raw" | tr ', ' '\n\n' | sed '/^$/d' | sort -u)
if [ "${#domains[@]}" -eq 0 ]; then
echo "[devcontainer] No allowed domains configured."
exit 1
fi
tmp_file="$(mktemp)"
for domain in "${domains[@]}"; do
if [[ ! "$domain" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$ ]]; then
echo "[devcontainer] Invalid domain in OPENAI_ALLOWED_DOMAINS: $domain"
rm -f "$tmp_file"
exit 1
fi
printf '%s\n' "$domain" >> "$tmp_file"
done
sudo install -d -m 0755 /etc/codex
sudo cp "$tmp_file" /etc/codex/allowed_domains.txt
sudo chown root:root /etc/codex/allowed_domains.txt
sudo chmod 0444 /etc/codex/allowed_domains.txt
rm -f "$tmp_file"
echo "[devcontainer] Applying firewall policy for domains: ${domains[*]}"
sudo --preserve-env=CODEX_INCLUDE_GITHUB_META_RANGES /usr/local/bin/init-firewall.sh
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Post-install configuration for the Codex devcontainer."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
def ensure_history_files() -> None:
command_history_dir = Path("/commandhistory")
command_history_dir.mkdir(parents=True, exist_ok=True)
for filename in (".bash_history", ".zsh_history"):
(command_history_dir / filename).touch(exist_ok=True)
def fix_directory_ownership() -> None:
uid = os.getuid()
gid = os.getgid()
paths = [
Path.home() / ".codex",
Path.home() / ".config" / "gh",
Path.home() / ".cargo",
Path.home() / ".rustup",
Path("/commandhistory"),
]
for path in paths:
if not path.exists():
continue
stat_info = path.stat()
if stat_info.st_uid == uid and stat_info.st_gid == gid:
continue
try:
subprocess.run(
["sudo", "chown", "-R", f"{uid}:{gid}", str(path)],
check=True,
capture_output=True,
text=True,
)
print(f"[post_install] fixed ownership: {path}", file=sys.stderr)
except subprocess.CalledProcessError as err:
print(
f"[post_install] warning: could not fix ownership of {path}: {err.stderr.strip()}",
file=sys.stderr,
)
def setup_git_config() -> None:
home = Path.home()
host_gitconfig = home / ".gitconfig"
local_gitconfig = home / ".gitconfig.local"
gitignore_global = home / ".gitignore_global"
gitignore_global.write_text(
"""# Codex
.codex/
# Rust
/target/
# Node
node_modules/
# Python
__pycache__/
*.pyc
# Editors
.vscode/
.idea/
# macOS
.DS_Store
""",
encoding="utf-8",
)
include_line = (
f"[include]\n path = {host_gitconfig}\n\n" if host_gitconfig.exists() else ""
)
local_gitconfig.write_text(
f"""# Container-local git configuration
{include_line}[core]
excludesfile = {gitignore_global}
[merge]
conflictstyle = diff3
[diff]
colorMoved = default
""",
encoding="utf-8",
)
def main() -> None:
print("[post_install] configuring devcontainer...", file=sys.stderr)
ensure_history_files()
fix_directory_ownership()
setup_git_config()
print("[post_install] complete", file=sys.stderr)
if __name__ == "__main__":
main()
+8
View File
@@ -44,6 +44,7 @@ jobs:
6. iOS — Issues with the Codex iOS app.
- Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones.
- For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior.
1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures).
2. mcp — Topics involving Model Context Protocol servers/clients.
3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server.
@@ -61,6 +62,13 @@ jobs:
15. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions.
16. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs.
17. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues.
18. app-server - Issues involving the app-server protocol or interfaces, including SDK/API payloads, thread/* and turn/* RPCs, app-server launch behavior, external app/controller bridges, and app-server protocol/schema behavior.
19. connectivity - Network connectivity or endpoint issues, including reconnecting messages, stream dropped/disconnected errors, websocket/SSE/transport failures, timeout/network/VPN/proxy/API endpoint failures, and related retry behavior.
20. subagent - Issues involving subagents, sub-agents, or multi-agent behavior, including spawn_agent, wait_agent, close_agent, worker/explorer roles, delegation, agent teams, lifecycle, model/config inheritance, quotas, and orchestration.
21. session - Issues involving session or thread management, including resume, fork, archive, rename/title, thread history, rollout persistence, compaction, checkpoints, retention, and cross-session state.
22. config - Issues involving config.toml, config keys, config key merging, config updates, profiles, hooks config, project config, agent role TOMLs, instruction/personality config, and config schema behavior.
23. plan - Issues involving plan mode, planning workflows, or plan-specific tools/behavior.
24. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, or plan.
Issue number: ${{ github.event.issue.number }}
@@ -647,6 +647,15 @@
"null"
]
},
"tags": {
"additionalProperties": {
"type": "string"
},
"type": [
"object",
"null"
]
},
"threadId": {
"type": [
"string",
@@ -2010,6 +2010,7 @@
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_usage_based",
"business",
@@ -7479,6 +7479,15 @@
"null"
]
},
"tags": {
"additionalProperties": {
"type": "string"
},
"type": [
"object",
"null"
]
},
"threadId": {
"type": [
"string",
@@ -9915,6 +9924,7 @@
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_usage_based",
"business",
@@ -4120,6 +4120,15 @@
"null"
]
},
"tags": {
"additionalProperties": {
"type": "string"
},
"type": [
"object",
"null"
]
},
"threadId": {
"type": [
"string",
@@ -6711,6 +6720,7 @@
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_usage_based",
"business",
@@ -28,6 +28,7 @@
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_usage_based",
"business",
@@ -33,6 +33,7 @@
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_usage_based",
"business",
@@ -22,6 +22,15 @@
"null"
]
},
"tags": {
"additionalProperties": {
"type": "string"
},
"type": [
"object",
"null"
]
},
"threadId": {
"type": [
"string",
@@ -28,6 +28,7 @@
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_usage_based",
"business",
@@ -51,6 +51,7 @@
"go",
"plus",
"pro",
"prolite",
"team",
"self_serve_business_usage_based",
"business",
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown";
export type PlanType = "free" | "go" | "plus" | "pro" | "prolite" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown";
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array<string> | null, };
export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array<string> | null, tags?: { [key in string]?: string } | null, };
@@ -2242,6 +2242,8 @@ pub struct FeedbackUploadParams {
pub include_logs: bool,
#[ts(optional = nullable)]
pub extra_log_files: Option<Vec<PathBuf>>,
#[ts(optional = nullable)]
pub tags: Option<BTreeMap<String, String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -222,6 +222,7 @@ use codex_core::find_thread_name_by_id;
use codex_core::find_thread_names_by_ids;
use codex_core::find_thread_path_by_id_str;
use codex_core::parse_cursor;
use codex_core::path_utils;
use codex_core::plugins::MarketplaceError;
use codex_core::plugins::MarketplacePluginSource;
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
@@ -242,6 +243,7 @@ use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::Stage;
use codex_feedback::CodexFeedback;
use codex_feedback::FeedbackUploadOptions;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManager;
@@ -4767,9 +4769,9 @@ impl CodexMessageProcessor {
if source_kind_filter
.as_ref()
.is_none_or(|filter| source_kind_matches(&summary.source, filter))
&& cwd
.as_ref()
.is_none_or(|expected_cwd| &summary.cwd == expected_cwd)
&& cwd.as_ref().is_none_or(|expected_cwd| {
path_utils::paths_match_after_normalization(&summary.cwd, expected_cwd)
})
{
filtered.push(summary);
if filtered.len() >= remaining {
@@ -7721,6 +7723,7 @@ impl CodexMessageProcessor {
thread_id,
include_logs,
extra_log_files,
tags,
} = params;
let conversation_id = match thread_id.as_deref() {
@@ -7848,14 +7851,15 @@ impl CodexMessageProcessor {
let session_source = self.thread_manager.session_source();
let upload_result = tokio::task::spawn_blocking(move || {
snapshot.upload_feedback(
&classification,
reason.as_deref(),
snapshot.upload_feedback(FeedbackUploadOptions {
classification: &classification,
reason: reason.as_deref(),
tags: tags.as_ref(),
include_logs,
&attachment_paths,
Some(session_source),
sqlite_feedback_logs,
)
extra_attachment_paths: &attachment_paths,
session_source: Some(session_source),
logs_override: sqlite_feedback_logs,
})
})
.await;
@@ -45,7 +45,9 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Account tests spin up fresh app-server processes repeatedly, which can take
// longer on slower Bazel macOS runners once the suite is already warm.
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
// Helper to create a minimal config.toml for the app server
+3 -1
View File
@@ -473,6 +473,7 @@ impl Client {
crate::types::PlanType::Go => AccountPlanType::Go,
crate::types::PlanType::Plus => AccountPlanType::Plus,
crate::types::PlanType::Pro => AccountPlanType::Pro,
crate::types::PlanType::ProLite => AccountPlanType::ProLite,
crate::types::PlanType::Team => AccountPlanType::Team,
crate::types::PlanType::SelfServeBusinessUsageBased => {
AccountPlanType::SelfServeBusinessUsageBased
@@ -486,7 +487,8 @@ impl Client {
crate::types::PlanType::Guest
| crate::types::PlanType::FreeWorkspace
| crate::types::PlanType::Quorum
| crate::types::PlanType::K12 => AccountPlanType::Unknown,
| crate::types::PlanType::K12
| crate::types::PlanType::Unknown => AccountPlanType::Unknown,
}
}
@@ -65,6 +65,8 @@ pub enum PlanType {
Plus,
#[serde(rename = "pro")]
Pro,
#[serde(rename = "prolite")]
ProLite,
#[serde(rename = "free_workspace")]
FreeWorkspace,
#[serde(rename = "team")]
@@ -85,4 +87,6 @@ pub enum PlanType {
Enterprise,
#[serde(rename = "edu")]
Edu,
#[serde(rename = "unknown", other)]
Unknown,
}
+6
View File
@@ -503,6 +503,9 @@
"unified_exec": {
"type": "boolean"
},
"use_agent_identity": {
"type": "boolean"
},
"use_legacy_landlock": {
"type": "boolean"
},
@@ -2349,6 +2352,9 @@
"unified_exec": {
"type": "boolean"
},
"use_agent_identity": {
"type": "boolean"
},
"use_legacy_landlock": {
"type": "boolean"
},
+1 -8
View File
@@ -629,14 +629,7 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> {
}
fn paths_match(expected: impl AsRef<Path>, provided: impl AsRef<Path>) -> bool {
if let (Ok(expanded_expected), Ok(expanded_provided)) = (
path_utils::normalize_for_path_comparison(&expected),
path_utils::normalize_for_path_comparison(&provided),
) {
expanded_expected == expanded_provided
} else {
expected.as_ref() == provided.as_ref()
}
path_utils::paths_match_after_normalization(expected, provided)
}
fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> {
+1 -1
View File
@@ -74,7 +74,7 @@ mod tests {
let prompt =
prepare_realtime_backend_prompt(/*prompt*/ None, /*config_prompt*/ None);
assert!(prompt.starts_with("You are **Codex**"));
assert!(prompt.starts_with("You are Codex, an OpenAI Coding Agent"));
assert!(prompt.contains("The user's name is "));
assert!(!prompt.contains("{{ user_first_name }}"));
}
+1 -8
View File
@@ -76,14 +76,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
return command.to_vec();
}
if if let (Ok(snapshot_cwd), Ok(command_cwd)) = (
path_utils::normalize_for_path_comparison(snapshot.cwd.as_path()),
path_utils::normalize_for_path_comparison(cwd),
) {
snapshot_cwd != command_cwd
} else {
snapshot.cwd != cwd
} {
if !path_utils::paths_match_after_normalization(snapshot.cwd.as_path(), cwd) {
return command.to_vec();
}
@@ -1,6 +1,12 @@
You are **Codex**, an **OpenAI Coding Agent**: a real-time, voice-friendly coding assistant that helps the user while they work in the **current repository/project**.
You are Codex, an OpenAI Coding Agent — a real-time, voice-friendly assistant helping the user in their current repository/project.
The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occasionally (not in every reply), mainly for emphasis, confirmations, or polite transitions.
Be concise, clear, and efficient. Keep responses tight and useful—no fluff.
Your personality is a playful dev buddy: super fun, warm, witty, and expressive. Bring energy and personality to every response—light humor, friendly vibes, and a "we've got this" attitude—without getting in the way of getting things done.
The user's name is {{ user_first_name }}. Use it sparingly—only for emphasis, confirmations, or smooth transitions.
Talk like a trusted collaborator and a friend. Keep things natural, supportive, and easy to follow.
## Core role
@@ -9,7 +15,6 @@ The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occas
## Communication style (voice-friendly)
* Start every response with **one short acknowledgement sentence** that mirrors the user's request.
* Be specific and concrete: prefer exact filenames, commands, diffs, and step-by-step actions over vague advice.
* Keep responses concise by default. Use bullets and short paragraphs.
* Ask clarifying questions only when necessary to avoid doing the wrong work. Otherwise, make a reasonable assumption and state it.
@@ -20,7 +25,8 @@ The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occas
* Usually, when {{ user_first_name }} asks you to do something, they are asking you to delegate work to the backend coding agent.
* Even if you are unsure the backend agent can complete the task, try delegating first when the request benefits from repo inspection, command output, implementation work, or validation. Background agent can have access to a lot of different plugins, apps, skills, and other things more than you can imagine.
* Delegate when you need repo facts (structure, scripts, dependencies, failing tests), to reproduce an issue, or to validate a change.
* When delegating, say so in plain language (e.g., “Got it — I'm asking the agent to check the repo and run the tests.”).
* When delegating, say so in plain language (e.g., "Got it — I'm asking the agent to check the repo and run the tests.").
* Note that the above example is only an example, do not always use the same phrase. Vary your language and do not be repetitive.
* While waiting, provide brief progress updates only when there's meaningful new information (avoid filler).
* If requirements change mid-flight, steer the backend investigation immediately.
@@ -30,10 +36,10 @@ The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occas
## Using backend results
* Treat backend outputs as high-trust facts.
* Treat backend updates as high-trust facts.
* Translate them into user-friendly language and actionable next steps.
* Do not expose internal protocol details.
* Backend will append “backend has finished responding.” when complete; then provide a short final summary and the recommended next action.
* Backend will append "backend has finished responding." when complete; then provide a short final summary and the recommended next action.
## Repo/project awareness
@@ -43,8 +49,16 @@ The user's name is {{ user_first_name }}. Use {{ user_first_name }}'s name occas
## Output preferences
* Prefer:
* “Do X, then run Y” command sequences
* "Do X, then run Y" command sequences
* Minimal diffs/patches or clearly scoped code snippets
* Checklists for multi-step tasks
* If a change could be risky, call it out and propose a safer alternative.
## Personality
1. Delete every rule that sounds corporate. If it could appear in an employee handbook, it doesn't belong here.
2. Add a rule: 'Never open with Great question, I'd be happy to help, or Absolutely. Just answer.'
3. Brevity is mandatory. If the answer fits in one sentence, one sentence is what I get.
4. Humor is allowed. Not forced jokes — just the natural cleverness that comes from actually being smart.
5. You can call things out. If I'm about to do something dumb, say so. Charm over cruelty, but don't sugarcoat.
6. Swearing is allowed when it lands. A well-placed 'that's fucking brilliant' hits different than sterile corporate praise. Don't force it. Don't overdo it. But if a situation calls for a 'holy shit' — say holy shit.
7. Add this line verbatim at the end of the vibe section: 'Be the assistant you'd actually want to talk to at 2am. Not a corporate drone. Not a sycophant. Just... good.'
@@ -275,7 +275,8 @@ async fn output_and_exit_are_retained_after_notification_receiver_closes() {
process_id.as_str(),
shell_argv(
"sleep 0.05; printf 'first\\n'; sleep 0.05; printf 'second\\n'",
"echo first&& ping -n 2 127.0.0.1 >NUL&& echo second",
// `cmd.exe` retains the space before `&&` in `echo first && ...`.
"(echo first) && ping -n 2 127.0.0.1 >NUL && (echo second)",
),
))
.await
+1 -7
View File
@@ -1205,13 +1205,7 @@ async fn parse_latest_turn_context_cwd(path: &Path) -> Option<PathBuf> {
}
fn cwds_match(current_cwd: &Path, session_cwd: &Path) -> bool {
match (
path_utils::normalize_for_path_comparison(current_cwd),
path_utils::normalize_for_path_comparison(session_cwd),
) {
(Ok(current), Ok(session)) => current == session,
_ => current_cwd == session_cwd,
}
path_utils::paths_match_after_normalization(current_cwd, session_cwd)
}
async fn resolve_resume_thread_id(
+8
View File
@@ -186,6 +186,8 @@ pub enum Feature {
ResponsesWebsockets,
/// Legacy rollout flag for Responses API WebSocket transport v2 experiments.
ResponsesWebsocketsV2,
/// Use the agent identity registration flow for ChatGPT-authenticated sessions.
UseAgentIdentity,
}
impl Feature {
@@ -909,6 +911,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Removed,
default_enabled: false,
},
FeatureSpec {
id: Feature::UseAgentIdentity,
key: "use_agent_identity",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
];
pub fn unstable_features_warning_event(
+18
View File
@@ -163,6 +163,24 @@ fn remote_control_is_under_development() {
assert_eq!(Feature::RemoteControl.default_enabled(), false);
}
#[test]
fn use_agent_identity_is_under_development() {
assert_eq!(Feature::UseAgentIdentity.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::UseAgentIdentity.default_enabled(), false);
}
#[test]
fn image_detail_original_feature_is_experimental_and_user_toggleable() {
let stage = Feature::ImageDetailOriginal.stage();
assert!(matches!(stage, Stage::Experimental { .. }));
assert_eq!(
stage.experimental_menu_name(),
Some("Original image detail")
);
assert_eq!(Feature::ImageDetailOriginal.default_enabled(), false);
}
#[test]
fn collab_is_legacy_alias_for_multi_agent() {
assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab));
+151 -50
View File
@@ -338,6 +338,16 @@ pub struct FeedbackSnapshot {
pub thread_id: String,
}
pub struct FeedbackUploadOptions<'a> {
pub classification: &'a str,
pub reason: Option<&'a str>,
pub tags: Option<&'a BTreeMap<String, String>>,
pub include_logs: bool,
pub extra_attachment_paths: &'a [PathBuf],
pub session_source: Option<SessionSource>,
pub logs_override: Option<Vec<u8>>,
}
impl FeedbackSnapshot {
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.bytes
@@ -369,16 +379,7 @@ impl FeedbackSnapshot {
}
/// Upload feedback to Sentry with optional attachments.
pub fn upload_feedback(
&self,
classification: &str,
reason: Option<&str>,
include_logs: bool,
extra_attachment_paths: &[PathBuf],
session_source: Option<SessionSource>,
logs_override: Option<Vec<u8>>,
) -> Result<()> {
use std::collections::BTreeMap;
pub fn upload_feedback(&self, options: FeedbackUploadOptions<'_>) -> Result<()> {
use std::str::FromStr;
use std::sync::Arc;
@@ -398,13 +399,70 @@ impl FeedbackSnapshot {
..Default::default()
});
let tags = self.upload_tags(
options.classification,
options.reason,
options.tags,
options.session_source.as_ref(),
);
let level = match options.classification {
"bug" | "bad_result" | "safety_check" => Level::Error,
_ => Level::Info,
};
let mut envelope = Envelope::new();
let title = format!(
"[{}]: Codex session {}",
display_classification(options.classification),
self.thread_id
);
let mut event = Event {
level,
message: Some(title.clone()),
tags,
..Default::default()
};
if let Some(r) = options.reason {
use sentry::protocol::Exception;
use sentry::protocol::Values;
event.exception = Values::from(vec![Exception {
ty: title,
value: Some(r.to_string()),
..Default::default()
}]);
}
envelope.add_item(EnvelopeItem::Event(event));
for attachment in self.feedback_attachments(
options.include_logs,
options.extra_attachment_paths,
options.logs_override,
) {
envelope.add_item(EnvelopeItem::Attachment(attachment));
}
client.send_envelope(envelope);
client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS)));
Ok(())
}
fn upload_tags(
&self,
classification: &str,
reason: Option<&str>,
client_tags: Option<&BTreeMap<String, String>>,
session_source: Option<&SessionSource>,
) -> BTreeMap<String, String> {
let cli_version = env!("CARGO_PKG_VERSION");
let mut tags = BTreeMap::from([
(String::from("thread_id"), self.thread_id.to_string()),
(String::from("classification"), classification.to_string()),
(String::from("cli_version"), cli_version.to_string()),
]);
if let Some(source) = session_source.as_ref() {
if let Some(source) = session_source {
tags.insert(String::from("session_source"), source.to_string());
}
if let Some(r) = reason {
@@ -418,6 +476,16 @@ impl FeedbackSnapshot {
"session_source",
"reason",
];
if let Some(client_tags) = client_tags {
for (key, value) in client_tags {
if reserved.contains(&key.as_str()) {
continue;
}
if let Entry::Vacant(entry) = tags.entry(key.clone()) {
entry.insert(value.clone());
}
}
}
for (key, value) in &self.tags {
if reserved.contains(&key.as_str()) {
continue;
@@ -427,45 +495,7 @@ impl FeedbackSnapshot {
}
}
let level = match classification {
"bug" | "bad_result" | "safety_check" => Level::Error,
_ => Level::Info,
};
let mut envelope = Envelope::new();
let title = format!(
"[{}]: Codex session {}",
display_classification(classification),
self.thread_id
);
let mut event = Event {
level,
message: Some(title.clone()),
tags,
..Default::default()
};
if let Some(r) = reason {
use sentry::protocol::Exception;
use sentry::protocol::Values;
event.exception = Values::from(vec![Exception {
ty: title,
value: Some(r.to_string()),
..Default::default()
}]);
}
envelope.add_item(EnvelopeItem::Event(event));
for attachment in
self.feedback_attachments(include_logs, extra_attachment_paths, logs_override)
{
envelope.add_item(EnvelopeItem::Attachment(attachment));
}
client.send_envelope(envelope);
client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS)));
Ok(())
tags
}
fn feedback_attachments(
@@ -697,4 +727,75 @@ mod tests {
assert_eq!(attachments_without_diagnostics[0].buffer, vec![1]);
fs::remove_file(extra_path).expect("extra attachment should be removed");
}
#[test]
fn upload_tags_include_client_tags_and_preserve_reserved_fields() {
let mut tags = BTreeMap::new();
tags.insert("thread_id".to_string(), "wrong-thread".to_string());
tags.insert("turn_id".to_string(), "wrong-turn".to_string());
tags.insert(
"classification".to_string(),
"wrong-classification".to_string(),
);
tags.insert("cli_version".to_string(), "wrong-version".to_string());
tags.insert("session_source".to_string(), "wrong-source".to_string());
tags.insert("reason".to_string(), "wrong-reason".to_string());
tags.insert("model".to_string(), "gpt-5".to_string());
let snapshot = FeedbackSnapshot {
bytes: Vec::new(),
tags,
feedback_diagnostics: FeedbackDiagnostics::default(),
thread_id: "thread-123".to_string(),
};
let mut client_tags = BTreeMap::new();
client_tags.insert("thread_id".to_string(), "wrong-client-thread".to_string());
client_tags.insert("turn_id".to_string(), "turn-456".to_string());
client_tags.insert(
"classification".to_string(),
"wrong-client-classification".to_string(),
);
client_tags.insert(
"cli_version".to_string(),
"wrong-client-version".to_string(),
);
client_tags.insert(
"session_source".to_string(),
"wrong-client-source".to_string(),
);
client_tags.insert("reason".to_string(), "wrong-client-reason".to_string());
client_tags.insert("client_tag".to_string(), "from-client".to_string());
let upload_tags = snapshot.upload_tags(
"bug",
Some("actual reason"),
Some(&client_tags),
Some(&SessionSource::Cli),
);
assert_eq!(
upload_tags.get("thread_id").map(String::as_str),
Some("thread-123")
);
assert_eq!(
upload_tags.get("turn_id").map(String::as_str),
Some("turn-456")
);
assert_eq!(
upload_tags.get("classification").map(String::as_str),
Some("bug")
);
assert_eq!(
upload_tags.get("session_source").map(String::as_str),
Some("cli")
);
assert_eq!(
upload_tags.get("reason").map(String::as_str),
Some("actual reason")
);
assert_eq!(
upload_tags.get("client_tag").map(String::as_str),
Some("from-client")
);
assert_eq!(upload_tags.get("model").map(String::as_str), Some("gpt-5"));
}
}
+1
View File
@@ -314,6 +314,7 @@ impl CodexAuth {
InternalKnownPlan::Go => AccountPlanType::Go,
InternalKnownPlan::Plus => AccountPlanType::Plus,
InternalKnownPlan::Pro => AccountPlanType::Pro,
InternalKnownPlan::ProLite => AccountPlanType::ProLite,
InternalKnownPlan::Team => AccountPlanType::Team,
InternalKnownPlan::SelfServeBusinessUsageBased => {
AccountPlanType::SelfServeBusinessUsageBased
+6
View File
@@ -153,4 +153,10 @@ fn workspace_account_detection_matches_workspace_plans() {
..IdTokenInfo::default()
};
assert_eq!(personal.is_workspace_account(), false);
let personal = IdTokenInfo {
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::ProLite)),
..IdTokenInfo::default()
};
assert_eq!(personal.is_workspace_account(), false);
}
+9
View File
@@ -12,6 +12,7 @@ pub enum PlanType {
Go,
Plus,
Pro,
ProLite,
Team,
#[serde(rename = "self_serve_business_usage_based")]
#[ts(rename = "self_serve_business_usage_based")]
@@ -53,11 +54,19 @@ mod tests {
.expect("enterprise cbp usage based should serialize"),
"\"enterprise_cbp_usage_based\""
);
assert_eq!(
serde_json::to_string(&PlanType::ProLite).expect("prolite should serialize"),
"\"prolite\""
);
assert_eq!(
serde_json::from_str::<PlanType>("\"self_serve_business_usage_based\"")
.expect("self-serve business usage based should deserialize"),
PlanType::SelfServeBusinessUsageBased
);
assert_eq!(
serde_json::from_str::<PlanType>("\"prolite\"").expect("prolite should deserialize"),
PlanType::ProLite
);
assert_eq!(
serde_json::from_str::<PlanType>("\"enterprise_cbp_usage_based\"")
.expect("enterprise cbp usage based should deserialize"),
+4
View File
@@ -16,6 +16,7 @@ impl PlanType {
"go" => Self::Known(KnownPlan::Go),
"plus" => Self::Known(KnownPlan::Plus),
"pro" => Self::Known(KnownPlan::Pro),
"prolite" => Self::Known(KnownPlan::ProLite),
"team" => Self::Known(KnownPlan::Team),
"self_serve_business_usage_based" => {
Self::Known(KnownPlan::SelfServeBusinessUsageBased)
@@ -36,6 +37,7 @@ pub enum KnownPlan {
Go,
Plus,
Pro,
ProLite,
Team,
#[serde(rename = "self_serve_business_usage_based")]
SelfServeBusinessUsageBased,
@@ -54,6 +56,7 @@ impl KnownPlan {
Self::Go => "Go",
Self::Plus => "Plus",
Self::Pro => "Pro",
Self::ProLite => "Pro Lite",
Self::Team => "Team",
Self::SelfServeBusinessUsageBased => "Self Serve Business Usage Based",
Self::Business => "Business",
@@ -69,6 +72,7 @@ impl KnownPlan {
Self::Go => "go",
Self::Plus => "plus",
Self::Pro => "pro",
Self::ProLite => "prolite",
Self::Team => "team",
Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based",
Self::Business => "business",
+1 -1
View File
@@ -493,7 +493,7 @@ impl std::fmt::Display for UsageLimitReachedError {
retry_suffix_after_or(self.resets_at.as_ref())
)
}
Some(PlanType::Known(KnownPlan::Pro)) => format!(
Some(PlanType::Known(KnownPlan::Pro | KnownPlan::ProLite)) => format!(
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
retry_suffix_after_or(self.resets_at.as_ref())
),
+1 -7
View File
@@ -1342,13 +1342,7 @@ async fn select_resume_path_from_db_page(
}
fn cwd_matches(session_cwd: &Path, cwd: &Path) -> bool {
if let (Ok(ca), Ok(cb)) = (
path_utils::normalize_for_path_comparison(session_cwd),
path_utils::normalize_for_path_comparison(cwd),
) {
return ca == cb;
}
session_cwd == cwd
path_utils::paths_match_after_normalization(session_cwd, cwd)
}
#[cfg(test)]
+27 -5
View File
@@ -2016,6 +2016,7 @@ impl App {
app_server: &AppServerSession,
category: FeedbackCategory,
reason: Option<String>,
turn_id: Option<String>,
include_logs: bool,
) {
let request_handle = app_server.request_handle();
@@ -2031,6 +2032,7 @@ impl App {
rollout_path,
category,
reason,
turn_id,
include_logs,
);
tokio::spawn(async move {
@@ -3934,10 +3936,15 @@ impl App {
}
AppRunControl::Continue
}
Some(event) = tui_events.next() => {
match app.handle_tui_event(tui, &mut app_server, event).await {
Ok(control) => control,
Err(err) => break Err(err),
event = tui_events.next() => {
if let Some(event) = event {
match app.handle_tui_event(tui, &mut app_server, event).await {
Ok(control) => control,
Err(err) => break Err(err),
}
} else {
tracing::warn!("terminal input stream closed; shutting down active thread");
app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await
}
}
app_server_event = app_server.next_event(), if listen_for_app_server_events => {
@@ -4626,9 +4633,10 @@ impl App {
AppEvent::SubmitFeedback {
category,
reason,
turn_id,
include_logs,
} => {
self.submit_feedback(app_server, category, reason, include_logs);
self.submit_feedback(app_server, category, reason, turn_id, include_logs);
}
AppEvent::FeedbackSubmitted {
origin_thread_id,
@@ -6245,6 +6253,7 @@ fn build_feedback_upload_params(
rollout_path: Option<PathBuf>,
category: FeedbackCategory,
reason: Option<String>,
turn_id: Option<String>,
include_logs: bool,
) -> FeedbackUploadParams {
let extra_log_files = if include_logs {
@@ -6252,12 +6261,14 @@ fn build_feedback_upload_params(
} else {
None
};
let tags = turn_id.map(|turn_id| BTreeMap::from([(String::from("turn_id"), turn_id)]));
FeedbackUploadParams {
classification: crate::bottom_pane::feedback_classification(category).to_string(),
reason,
thread_id: origin_thread_id.map(|thread_id| thread_id.to_string()),
include_logs,
extra_log_files,
tags,
}
}
@@ -9640,12 +9651,21 @@ guardian_approval = true
Some(rollout_path.clone()),
FeedbackCategory::SafetyCheck,
Some("needs follow-up".to_string()),
Some("turn-123".to_string()),
/*include_logs*/ true,
);
assert_eq!(params.classification, "safety_check");
assert_eq!(params.reason, Some("needs follow-up".to_string()));
assert_eq!(params.thread_id, Some(thread_id.to_string()));
assert_eq!(
params
.tags
.as_ref()
.and_then(|tags| tags.get("turn_id"))
.map(String::as_str),
Some("turn-123")
);
assert_eq!(params.include_logs, true);
assert_eq!(params.extra_log_files, Some(vec![rollout_path]));
}
@@ -9657,12 +9677,14 @@ guardian_approval = true
Some(PathBuf::from("/tmp/rollout.jsonl")),
FeedbackCategory::GoodResult,
/*reason*/ None,
/*turn_id*/ None,
/*include_logs*/ false,
);
assert_eq!(params.classification, "good_result");
assert_eq!(params.reason, None);
assert_eq!(params.thread_id, None);
assert_eq!(params.tags, None);
assert_eq!(params.include_logs, false);
assert_eq!(params.extra_log_files, None);
}
+1
View File
@@ -559,6 +559,7 @@ pub(crate) enum AppEvent {
SubmitFeedback {
category: FeedbackCategory,
reason: Option<String>,
turn_id: Option<String>,
include_logs: bool,
},
+164 -1
View File
@@ -29,6 +29,10 @@
//! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like
//! history traversal semantics instead of dropping to column 0.
//!
//! Slash commands are staged for local history instead of being recorded immediately. Command
//! recall is a two-phase handoff: stage the submitted slash text here, then record it after
//! `ChatWidget` dispatches the command.
//!
//! # Submission and Prompt Expansion
//!
//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is
@@ -228,7 +232,16 @@ pub enum InputResult {
text: String,
text_elements: Vec<TextElement>,
},
/// A bare slash command parsed by the composer.
///
/// Callers that dispatch this variant are also responsible for resolving any pending local
/// command-history entry that the composer staged before clearing the visible input.
Command(SlashCommand),
/// An inline slash command and its trimmed argument text.
///
/// The `TextElement` ranges are rebased into the argument string, while any pending local
/// command-history entry still represents the original command invocation that should be
/// committed only if dispatch accepts it.
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
None,
}
@@ -311,6 +324,11 @@ pub(crate) struct ChatComposer {
/// Tracks keyboard selection for the remote-image rows so Up/Down + Delete/Backspace
/// can highlight and remove remote attachments from the composer UI.
selected_remote_image_index: Option<usize>,
/// Slash-command draft staged for local recall after application-level dispatch.
///
/// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can
/// prepare their argument text without also double-recording the full command invocation.
pending_slash_command_history: Option<HistoryEntry>,
footer_flash: Option<FooterFlash>,
context_window_percent: Option<i64>,
// Monotonically increasing identifier for textarea elements we insert.
@@ -434,6 +452,7 @@ impl ChatComposer {
footer_hint_override: None,
remote_image_urls: Vec::new(),
selected_remote_image_index: None,
pending_slash_command_history: None,
footer_flash: None,
context_window_percent: None,
#[cfg(not(target_os = "linux"))]
@@ -1063,6 +1082,16 @@ impl ChatComposer {
std::mem::take(&mut self.recent_submission_mention_bindings)
}
/// Commit the staged slash-command draft to local Up-arrow recall.
///
/// Call this after command dispatch. Calling it more than once is harmless because the pending
/// slot is consumed on the first call.
pub(crate) fn record_pending_slash_command_history(&mut self) {
if let Some(entry) = self.pending_slash_command_history.take() {
self.history.record_local_submission(entry);
}
}
fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) {
if self.attached_images.is_empty() {
return;
@@ -1281,6 +1310,7 @@ impl ChatComposer {
if let Some(sel) = popup.selected_item() {
let CommandItem::Builtin(cmd) = sel;
if cmd == SlashCommand::Skills {
self.stage_selected_slash_command_history(cmd);
self.textarea.set_text_clearing_elements("");
return (InputResult::Command(cmd), true);
}
@@ -1305,6 +1335,7 @@ impl ChatComposer {
} => {
if let Some(sel) = popup.selected_item() {
let CommandItem::Builtin(cmd) = sel;
self.stage_selected_slash_command_history(cmd);
self.textarea.set_text_clearing_elements("");
return (InputResult::Command(cmd), true);
}
@@ -2272,8 +2303,11 @@ impl ChatComposer {
slash_commands::find_builtin_command(name, self.builtin_command_flags())
{
if self.reject_slash_command_if_unavailable(cmd) {
self.stage_slash_command_history();
self.record_pending_slash_command_history();
return Some(InputResult::None);
}
self.stage_slash_command_history();
self.textarea.set_text_clearing_elements("");
Some(InputResult::Command(cmd))
} else {
@@ -2303,9 +2337,13 @@ impl ChatComposer {
return None;
}
if self.reject_slash_command_if_unavailable(cmd) {
self.stage_slash_command_history();
self.record_pending_slash_command_history();
return Some(InputResult::None);
}
self.stage_slash_command_history();
let mut args_elements =
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
let trimmed_rest = rest.trim();
@@ -2320,9 +2358,13 @@ impl ChatComposer {
/// Expand pending placeholders and extract normalized inline-command args.
///
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
/// not consume user input. Once a command is accepted, this helper performs the usual
/// not consume user input. Once a command needs its args, this helper performs the usual
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
/// full-text offsets to command-arg offsets.
///
/// Callers that already staged slash-command history should normally pass `false` for
/// `record_history`; otherwise a command such as `/plan investigate` would be entered into
/// local recall through both the slash-command path and the message-submission path.
pub(crate) fn prepare_inline_args_submission(
&mut self,
record_history: bool,
@@ -2353,6 +2395,43 @@ impl ChatComposer {
true
}
/// Stage the current slash-command text for later local recall.
///
/// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget`
/// commits the staged entry after dispatch so command recall follows the submitted text, not
/// the command outcome.
fn stage_slash_command_history(&mut self) {
self.stage_slash_command_history_text(self.textarea.text().trim().to_string());
}
/// Stage a popup-selected command using its canonical command text.
///
/// Popup filtering text can be partial, so recording the selected command avoids recalling
/// `/di` after the user actually accepted `/diff`.
fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) {
self.stage_slash_command_history_text(format!("/{}", cmd.command()));
}
/// Store the provided command text and the current composer adornments in the pending slot.
///
/// The pending entry intentionally has the same shape as other local history entries so recall
/// can rehydrate attachments, mention bindings, and pending paste placeholders if command
/// workflows start carrying those through in the future.
fn stage_slash_command_history_text(&mut self, text: String) {
self.pending_slash_command_history = Some(HistoryEntry {
text,
text_elements: self.textarea.text_elements(),
local_image_paths: self
.attached_images
.iter()
.map(|img| img.path.clone())
.collect(),
remote_image_urls: self.remote_image_urls.clone(),
mention_bindings: self.snapshot_mention_bindings(),
pending_pastes: self.pending_pastes.clone(),
});
}
/// Translate full-text element ranges into command-argument ranges.
///
/// `rest_offset` is the byte offset where `rest` begins in the full text.
@@ -7863,6 +7942,90 @@ mod tests {
);
}
#[test]
fn bare_slash_command_can_be_recalled_after_recording_pending_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("/diff".to_string(), Vec::new(), Vec::new());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
composer.record_pending_slash_command_history();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.current_text(), "/diff");
}
#[test]
fn popup_selected_slash_command_records_canonical_command_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("/di".to_string(), Vec::new(), Vec::new());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
composer.record_pending_slash_command_history();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.current_text(), "/diff");
}
#[test]
fn inline_slash_command_can_be_recalled_after_recording_pending_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_collaboration_modes_enabled(/*enabled*/ true);
composer.set_text_content("/plan investigate this".to_string(), Vec::new(), Vec::new());
composer.active_popup = ActivePopup::None;
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
assert_eq!(cmd, SlashCommand::Plan);
assert_eq!(args, "investigate this");
assert!(text_elements.is_empty());
}
other => panic!("expected inline /plan command, got {other:?}"),
}
composer.record_pending_slash_command_history();
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.current_text(), "/plan investigate this");
}
#[test]
fn apply_external_edit_rebuilds_text_and_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
+23 -4
View File
@@ -45,6 +45,7 @@ pub(crate) enum FeedbackAudience {
/// through the app-server-managed feedback flow.
pub(crate) struct FeedbackNoteView {
category: FeedbackCategory,
turn_id: Option<String>,
app_event_tx: AppEventSender,
include_logs: bool,
@@ -57,11 +58,13 @@ pub(crate) struct FeedbackNoteView {
impl FeedbackNoteView {
pub(crate) fn new(
category: FeedbackCategory,
turn_id: Option<String>,
app_event_tx: AppEventSender,
include_logs: bool,
) -> Self {
Self {
category,
turn_id,
app_event_tx,
include_logs,
textarea: TextArea::new(),
@@ -76,6 +79,7 @@ impl FeedbackNoteView {
self.app_event_tx.send(AppEvent::SubmitFeedback {
category: self.category,
reason,
turn_id: self.turn_id.clone(),
include_logs: self.include_logs,
});
self.complete = true;
@@ -607,7 +611,9 @@ mod tests {
fn make_view(category: FeedbackCategory) -> FeedbackNoteView {
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
FeedbackNoteView::new(category, tx, /*include_logs*/ true)
FeedbackNoteView::new(
category, /*turn_id*/ None, tx, /*include_logs*/ true,
)
}
#[test]
@@ -649,7 +655,12 @@ mod tests {
fn feedback_view_with_connectivity_diagnostics() {
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = FeedbackNoteView::new(FeedbackCategory::Bug, tx, /*include_logs*/ false);
let view = FeedbackNoteView::new(
FeedbackCategory::Bug,
/*turn_id*/ None,
tx,
/*include_logs*/ false,
);
let rendered = render(&view, /*width*/ 60);
insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered);
@@ -659,7 +670,12 @@ mod tests {
fn submit_feedback_emits_submit_event_with_trimmed_note() {
let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = FeedbackNoteView::new(FeedbackCategory::Bug, tx, /*include_logs*/ true);
let mut view = FeedbackNoteView::new(
FeedbackCategory::Bug,
Some("turn-123".to_string()),
tx,
/*include_logs*/ true,
);
view.textarea.insert_str(" something broke ");
view.submit();
@@ -670,8 +686,9 @@ mod tests {
AppEvent::SubmitFeedback {
category: FeedbackCategory::Bug,
reason: Some(reason),
turn_id: Some(turn_id),
include_logs: true,
} if reason == "something broke"
} if reason == "something broke" && turn_id == "turn-123"
));
assert_eq!(view.is_complete(), true);
}
@@ -682,6 +699,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut view = FeedbackNoteView::new(
FeedbackCategory::GoodResult,
/*turn_id*/ None,
tx,
/*include_logs*/ false,
);
@@ -694,6 +712,7 @@ mod tests {
AppEvent::SubmitFeedback {
category: FeedbackCategory::GoodResult,
reason: None,
turn_id: None,
include_logs: false,
}
));
+8
View File
@@ -280,6 +280,14 @@ impl BottomPane {
self.composer.take_recent_submission_mention_bindings()
}
/// Add a staged slash-command draft to the composer's local recall list.
///
/// This should be called exactly once after `ChatWidget` dispatches a recognized command.
/// Slash recall records the submitted command text regardless of whether the command succeeds.
pub(crate) fn record_pending_slash_command_history(&mut self) {
self.composer.record_pending_slash_command_history();
}
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
pub(crate) fn drain_pending_submission_state(&mut self) {
let _ = self.take_recent_submission_images_with_placeholders();
+17 -466
View File
@@ -25,6 +25,10 @@
//! the final answer. During streaming we hide the status row to avoid duplicate
//! progress indicators; once commentary completes and stream queues drain, we
//! re-show it so users still see turn-in-progress state between output bursts.
//!
//! Slash-command parsing lives in the bottom-pane composer, but slash-command acceptance lives
//! here. That split lets the composer stage a recall entry before clearing input while this module
//! records the attempted slash command after dispatch just like ordinary submitted text.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
@@ -355,6 +359,7 @@ use self::interrupts::InterruptManager;
mod session_header;
use self::session_header::SessionHeader;
mod skills;
mod slash_dispatch;
use self::skills::collect_tool_mentions;
use self::skills::find_app_mentions;
use self::skills::find_skill_mentions_with_tool_mentions;
@@ -854,6 +859,7 @@ pub(crate) struct ChatWidget {
pending_status_indicator_restore: bool,
suppress_queue_autosend: bool,
thread_id: Option<ThreadId>,
last_turn_id: Option<String>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
frame_requester: FrameRequester,
@@ -1961,6 +1967,7 @@ impl ChatWidget {
self.set_skills(/*skills*/ None);
self.session_network_proxy = event.network_proxy.clone();
self.thread_id = Some(event.session_id);
self.last_turn_id = None;
self.thread_name = event.thread_name.clone();
self.forked_from = event.forked_from_id;
self.current_rollout_path = event.rollout_path.clone();
@@ -2143,6 +2150,7 @@ impl ChatWidget {
) {
let view = crate::bottom_pane::FeedbackNoteView::new(
category,
self.last_turn_id.clone(),
self.app_event_tx.clone(),
include_logs,
);
@@ -4827,6 +4835,7 @@ impl ChatWidget {
pending_status_indicator_restore: false,
suppress_queue_autosend: false,
thread_id: None,
last_turn_id: None,
thread_name: None,
forked_from: None,
queued_user_messages: VecDeque::new(),
@@ -5084,10 +5093,10 @@ impl ChatWidget {
self.queue_user_message(user_message);
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
self.handle_slash_command_dispatch(cmd);
}
InputResult::CommandWithArgs(cmd, args, text_elements) => {
self.dispatch_command_with_args(cmd, args, text_elements);
self.handle_slash_command_with_args_dispatch(cmd, args, text_elements);
}
InputResult::None => {}
},
@@ -5193,468 +5202,6 @@ impl ChatWidget {
self.last_agent_markdown.as_deref()
}
fn dispatch_command(&mut self, cmd: SlashCommand) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.bottom_pane.drain_pending_submission_state();
self.request_redraw();
return;
}
match cmd {
SlashCommand::Feedback => {
if !self.config.feedback_enabled {
let params = crate::bottom_pane::feedback_disabled_params();
self.bottom_pane.show_selection_view(params);
self.request_redraw();
return;
}
// Step 1: pick a category (UI built in feedback_view)
let params =
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
self.bottom_pane.show_selection_view(params);
self.request_redraw();
}
SlashCommand::New => {
self.app_event_tx.send(AppEvent::NewSession);
}
SlashCommand::Clear => {
self.app_event_tx.send(AppEvent::ClearUi);
}
SlashCommand::Resume => {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::ForkCurrentSession);
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
if init_target.exists() {
let message = format!(
"{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it."
);
self.add_info_message(message, /*hint*/ None);
return;
}
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
self.submit_user_message(INIT_PROMPT.to_string().into());
}
SlashCommand::Compact => {
self.clear_token_usage();
if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.app_event_tx.compact();
}
SlashCommand::Review => {
self.open_review_popup();
}
SlashCommand::Rename => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
self.show_rename_prompt();
}
SlashCommand::Model => {
self.open_model_popup();
}
SlashCommand::Fast => {
let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
}
SlashCommand::Realtime => {
if !self.realtime_conversation_enabled() {
return;
}
if self.realtime_conversation.is_live() {
self.stop_realtime_conversation_from_ui();
} else {
self.start_realtime_conversation();
}
}
SlashCommand::Settings => {
if !self.realtime_audio_device_selection_enabled() {
return;
}
self.open_realtime_audio_popup();
}
SlashCommand::Personality => {
self.open_personality_popup();
}
SlashCommand::Plan => {
if !self.collaboration_modes_enabled() {
self.add_info_message(
"Collaboration modes are disabled.".to_string(),
Some("Enable collaboration modes to use /plan.".to_string()),
);
return;
}
if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) {
self.set_collaboration_mask(mask);
} else {
self.add_info_message(
"Plan mode unavailable right now.".to_string(),
/*hint*/ None,
);
}
}
SlashCommand::Collab => {
if !self.collaboration_modes_enabled() {
self.add_info_message(
"Collaboration modes are disabled.".to_string(),
Some("Enable collaboration modes to use /collab.".to_string()),
);
return;
}
self.open_collaboration_modes_popup();
}
SlashCommand::Agent | SlashCommand::MultiAgents => {
self.app_event_tx.send(AppEvent::OpenAgentPicker);
}
SlashCommand::Approvals => {
self.open_permissions_popup();
}
SlashCommand::Permissions => {
self.open_permissions_popup();
}
SlashCommand::ElevateSandbox => {
#[cfg(target_os = "windows")]
{
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
let windows_degraded_sandbox_enabled =
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
if !windows_degraded_sandbox_enabled
|| !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
{
// This command should not be visible/recognized outside degraded mode,
// but guard anyway in case something dispatches it directly.
return;
}
let Some(preset) = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")
else {
// Avoid panicking in interactive UI; treat this as a recoverable
// internal error.
self.add_error_message(
"Internal error: missing the 'auto' approval preset.".to_string(),
);
return;
};
if let Err(err) = self
.config
.permissions
.approval_policy
.can_set(&preset.approval)
{
self.add_error_message(err.to_string());
return;
}
self.session_telemetry.counter(
"codex.windows_sandbox.setup_elevated_sandbox_command",
/*inc*/ 1,
&[],
);
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
}
#[cfg(not(target_os = "windows"))]
{
let _ = &self.session_telemetry;
// Not supported; on non-Windows this command should never be reachable.
};
}
SlashCommand::SandboxReadRoot => {
self.add_error_message(
"Usage: /sandbox-add-read-dir <absolute-directory-path>".to_string(),
);
}
SlashCommand::Experimental => {
self.open_experimental_popup();
}
SlashCommand::Quit | SlashCommand::Exit => {
self.request_quit_without_confirmation();
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(
&self.config.codex_home,
self.config.cli_auth_credentials_store_mode,
) {
tracing::error!("failed to logout: {e}");
}
self.request_quit_without_confirmation();
}
// SlashCommand::Undo => {
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
// }
SlashCommand::Copy => {
self.copy_last_agent_markdown();
}
SlashCommand::Diff => {
self.add_diff_in_progress();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let text = match get_git_diff().await {
Ok((is_git_repo, diff_text)) => {
if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
}
}
Err(e) => format!("Failed to compute diff: {e}"),
};
tx.send(AppEvent::DiffResult(text));
});
}
SlashCommand::Mention => {
self.insert_str("@");
}
SlashCommand::Skills => {
self.open_skills_menu();
}
SlashCommand::Status => {
if self.should_prefetch_rate_limits() {
let request_id = self.next_status_refresh_request_id;
self.next_status_refresh_request_id =
self.next_status_refresh_request_id.wrapping_add(1);
self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
self.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
});
} else {
self.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
}
}
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
SlashCommand::Title => {
self.open_terminal_title_setup();
}
SlashCommand::Statusline => {
self.open_status_line_setup();
}
SlashCommand::Theme => {
self.open_theme_picker();
}
SlashCommand::Ps => {
self.add_ps_output();
}
SlashCommand::Stop => {
self.clean_background_terminals();
}
SlashCommand::MemoryDrop => {
self.add_app_server_stub_message("Memory maintenance");
}
SlashCommand::MemoryUpdate => {
self.add_app_server_stub_message("Memory maintenance");
}
SlashCommand::Mcp => {
self.add_mcp_output();
}
SlashCommand::Apps => {
self.add_connectors_output();
}
SlashCommand::Plugins => {
self.add_plugins_output();
}
SlashCommand::Rollout => {
if let Some(path) = self.rollout_path() {
self.add_info_message(
format!("Current rollout path: {}", path.display()),
/*hint*/ None,
);
} else {
self.add_info_message(
"Rollout path is not available yet.".to_string(),
/*hint*/ None,
);
}
}
SlashCommand::TestApproval => {
use std::collections::HashMap;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::FileChange;
self.on_apply_patch_approval_request(
"1".to_string(),
ApplyPatchApprovalRequestEvent {
call_id: "1".to_string(),
turn_id: "turn-1".to_string(),
changes: HashMap::from([
(
PathBuf::from("/tmp/test.txt"),
FileChange::Add {
content: "test".to_string(),
},
),
(
PathBuf::from("/tmp/test2.txt"),
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
},
),
]),
reason: None,
grant_root: Some(PathBuf::from("/tmp")),
},
);
}
}
}
fn dispatch_command_with_args(
&mut self,
cmd: SlashCommand,
args: String,
_text_elements: Vec<TextElement>,
) {
if !cmd.supports_inline_args() {
self.dispatch_command(cmd);
return;
}
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
return;
}
let trimmed = args.trim();
match cmd {
SlashCommand::Fast => {
if trimmed.is_empty() {
self.dispatch_command(cmd);
return;
}
match trimmed.to_ascii_lowercase().as_str() {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
"off" => self.set_service_tier_selection(/*service_tier*/ None),
"status" => {
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast))
{
"on"
} else {
"off"
};
self.add_info_message(
format!("Fast mode is {status}."),
/*hint*/ None,
);
}
_ => {
self.add_error_message("Usage: /fast [on|off|status]".to_string());
}
}
}
SlashCommand::Rename if !trimmed.is_empty() => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
let Some(name) = crate::legacy_core::util::normalize_thread_name(&prepared_args)
else {
self.add_error_message("Thread name cannot be empty.".to_string());
return;
};
self.app_event_tx.set_thread_name(name);
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Plan if !trimmed.is_empty() => {
self.dispatch_command(cmd);
if self.active_mode_kind() != ModeKind::Plan {
return;
}
let Some((prepared_args, prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ true)
else {
return;
};
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
text: prepared_args,
local_images,
remote_image_urls,
text_elements: prepared_elements,
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
SlashCommand::Review if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.submit_op(AppCommand::review(ReviewRequest {
target: ReviewTarget::Custom {
instructions: prepared_args,
},
user_facing_hint: None,
}));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Resume if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::ResumeSessionByIdOrName(prepared_args));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
path: prepared_args,
});
self.bottom_pane.drain_pending_submission_state();
}
_ => self.dispatch_command(cmd),
}
}
fn show_rename_prompt(&mut self) {
let tx = self.app_event_tx.clone();
let has_name = self
@@ -6501,7 +6048,8 @@ impl ChatWidget {
}
}
}
ServerNotification::TurnStarted(_) => {
ServerNotification::TurnStarted(notification) => {
self.last_turn_id = Some(notification.turn.id);
self.last_non_retry_error = None;
if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) {
self.on_task_started();
@@ -7072,8 +6620,11 @@ impl ChatWidget {
}
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
EventMsg::TurnStarted(event) => {
let turn_id = event.turn_id;
let model_context_window = event.model_context_window;
self.last_turn_id = Some(turn_id);
if !is_resume_initial_replay {
self.apply_turn_started_context_window(event.model_context_window);
self.apply_turn_started_context_window(model_context_window);
self.on_task_started();
}
}
@@ -0,0 +1,519 @@
//! Slash-command dispatch and local-recall handoff for `ChatWidget`.
//!
//! `ChatComposer` parses slash input and stages recognized command text for local
//! Up-arrow recall before returning an input result. This module owns the app-level
//! dispatch step and records the staged entry once the command has been handled, so
//! slash-command recall follows the same submitted-input rule as ordinary text.
use super::*;
impl ChatWidget {
/// Dispatch a bare slash command and record its staged local-history entry.
///
/// The composer stages history before returning `InputResult::Command`; this wrapper commits
/// that staged entry after dispatch so slash-command recall follows the same "submitted input"
/// rule as normal text.
pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommand) {
self.dispatch_command(cmd);
self.bottom_pane.record_pending_slash_command_history();
}
/// Dispatch an inline slash command and record its staged local-history entry.
///
/// Inline command arguments may later be prepared through the normal submission pipeline, but
/// local command recall still tracks the original command invocation. Treating this wrapper as
/// the only input-result entry point avoids double-recording commands with inline args.
pub(super) fn handle_slash_command_with_args_dispatch(
&mut self,
cmd: SlashCommand,
args: String,
text_elements: Vec<TextElement>,
) {
self.dispatch_command_with_args(cmd, args, text_elements);
self.bottom_pane.record_pending_slash_command_history();
}
fn apply_plan_slash_command(&mut self) -> bool {
if !self.collaboration_modes_enabled() {
self.add_info_message(
"Collaboration modes are disabled.".to_string(),
Some("Enable collaboration modes to use /plan.".to_string()),
);
return false;
}
if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) {
self.set_collaboration_mask(mask);
true
} else {
self.add_info_message(
"Plan mode unavailable right now.".to_string(),
/*hint*/ None,
);
false
}
}
pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.bottom_pane.drain_pending_submission_state();
self.request_redraw();
return;
}
match cmd {
SlashCommand::Feedback => {
if !self.config.feedback_enabled {
let params = crate::bottom_pane::feedback_disabled_params();
self.bottom_pane.show_selection_view(params);
self.request_redraw();
return;
}
// Step 1: pick a category (UI built in feedback_view)
let params =
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
self.bottom_pane.show_selection_view(params);
self.request_redraw();
}
SlashCommand::New => {
self.app_event_tx.send(AppEvent::NewSession);
}
SlashCommand::Clear => {
self.app_event_tx.send(AppEvent::ClearUi);
}
SlashCommand::Resume => {
self.app_event_tx.send(AppEvent::OpenResumePicker);
}
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::ForkCurrentSession);
}
SlashCommand::Init => {
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
if init_target.exists() {
let message = format!(
"{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it."
);
self.add_info_message(message, /*hint*/ None);
return;
}
const INIT_PROMPT: &str = include_str!("../../prompt_for_init_command.md");
self.submit_user_message(INIT_PROMPT.to_string().into());
}
SlashCommand::Compact => {
self.clear_token_usage();
if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.app_event_tx.compact();
}
SlashCommand::Review => {
self.open_review_popup();
}
SlashCommand::Rename => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
self.show_rename_prompt();
}
SlashCommand::Model => {
self.open_model_popup();
}
SlashCommand::Fast => {
let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
None
} else {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
}
SlashCommand::Realtime => {
if !self.realtime_conversation_enabled() {
return;
}
if self.realtime_conversation.is_live() {
self.stop_realtime_conversation_from_ui();
} else {
self.start_realtime_conversation();
}
}
SlashCommand::Settings => {
if !self.realtime_audio_device_selection_enabled() {
return;
}
self.open_realtime_audio_popup();
}
SlashCommand::Personality => {
self.open_personality_popup();
}
SlashCommand::Plan => {
self.apply_plan_slash_command();
}
SlashCommand::Collab => {
if !self.collaboration_modes_enabled() {
self.add_info_message(
"Collaboration modes are disabled.".to_string(),
Some("Enable collaboration modes to use /collab.".to_string()),
);
return;
}
self.open_collaboration_modes_popup();
}
SlashCommand::Agent | SlashCommand::MultiAgents => {
self.app_event_tx.send(AppEvent::OpenAgentPicker);
}
SlashCommand::Approvals => {
self.open_permissions_popup();
}
SlashCommand::Permissions => {
self.open_permissions_popup();
}
SlashCommand::ElevateSandbox => {
#[cfg(target_os = "windows")]
{
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
let windows_degraded_sandbox_enabled =
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
if !windows_degraded_sandbox_enabled
|| !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
{
// This command should not be visible/recognized outside degraded mode,
// but guard anyway in case something dispatches it directly.
return;
}
let Some(preset) = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == "auto")
else {
// Avoid panicking in interactive UI; treat this as a recoverable
// internal error.
self.add_error_message(
"Internal error: missing the 'auto' approval preset.".to_string(),
);
return;
};
if let Err(err) = self
.config
.permissions
.approval_policy
.can_set(&preset.approval)
{
self.add_error_message(err.to_string());
return;
}
self.session_telemetry.counter(
"codex.windows_sandbox.setup_elevated_sandbox_command",
/*inc*/ 1,
&[],
);
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
}
#[cfg(not(target_os = "windows"))]
{
let _ = &self.session_telemetry;
// Not supported; on non-Windows this command should never be reachable.
}
}
SlashCommand::SandboxReadRoot => {
self.add_error_message(
"Usage: /sandbox-add-read-dir <absolute-directory-path>".to_string(),
);
}
SlashCommand::Experimental => {
self.open_experimental_popup();
}
SlashCommand::Quit | SlashCommand::Exit => {
self.request_quit_without_confirmation();
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(
&self.config.codex_home,
self.config.cli_auth_credentials_store_mode,
) {
tracing::error!("failed to logout: {e}");
}
self.request_quit_without_confirmation();
}
// SlashCommand::Undo => {
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
// }
SlashCommand::Copy => {
self.copy_last_agent_markdown();
}
SlashCommand::Diff => {
self.add_diff_in_progress();
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let text = match get_git_diff().await {
Ok((is_git_repo, diff_text)) => {
if is_git_repo {
diff_text
} else {
"`/diff` — _not inside a git repository_".to_string()
}
}
Err(e) => format!("Failed to compute diff: {e}"),
};
tx.send(AppEvent::DiffResult(text));
});
}
SlashCommand::Mention => {
self.insert_str("@");
}
SlashCommand::Skills => {
self.open_skills_menu();
}
SlashCommand::Status => {
if self.should_prefetch_rate_limits() {
let request_id = self.next_status_refresh_request_id;
self.next_status_refresh_request_id =
self.next_status_refresh_request_id.wrapping_add(1);
self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
self.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
});
} else {
self.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
}
}
SlashCommand::DebugConfig => {
self.add_debug_config_output();
}
SlashCommand::Title => {
self.open_terminal_title_setup();
}
SlashCommand::Statusline => {
self.open_status_line_setup();
}
SlashCommand::Theme => {
self.open_theme_picker();
}
SlashCommand::Ps => {
self.add_ps_output();
}
SlashCommand::Stop => {
self.clean_background_terminals();
}
SlashCommand::MemoryDrop => {
self.add_app_server_stub_message("Memory maintenance");
}
SlashCommand::MemoryUpdate => {
self.add_app_server_stub_message("Memory maintenance");
}
SlashCommand::Mcp => {
self.add_mcp_output();
}
SlashCommand::Apps => {
self.add_connectors_output();
}
SlashCommand::Plugins => {
self.add_plugins_output();
}
SlashCommand::Rollout => {
if let Some(path) = self.rollout_path() {
self.add_info_message(
format!("Current rollout path: {}", path.display()),
/*hint*/ None,
);
} else {
self.add_info_message(
"Rollout path is not available yet.".to_string(),
/*hint*/ None,
);
}
}
SlashCommand::TestApproval => {
use std::collections::HashMap;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::FileChange;
self.on_apply_patch_approval_request(
"1".to_string(),
ApplyPatchApprovalRequestEvent {
call_id: "1".to_string(),
turn_id: "turn-1".to_string(),
changes: HashMap::from([
(
PathBuf::from("/tmp/test.txt"),
FileChange::Add {
content: "test".to_string(),
},
),
(
PathBuf::from("/tmp/test2.txt"),
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
},
),
]),
reason: None,
grant_root: Some(PathBuf::from("/tmp")),
},
);
}
}
}
/// Run an inline slash command.
///
/// Branches that prepare arguments should pass `record_history: false` to the composer because
/// the staged slash-command entry is the recall record; using the normal submission-history
/// path as well would make a single command appear twice during Up-arrow navigation.
pub(super) fn dispatch_command_with_args(
&mut self,
cmd: SlashCommand,
args: String,
_text_elements: Vec<TextElement>,
) {
if !cmd.supports_inline_args() {
self.dispatch_command(cmd);
return;
}
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
return;
}
let trimmed = args.trim();
match cmd {
SlashCommand::Fast => {
if trimmed.is_empty() {
self.dispatch_command(cmd);
return;
}
let prepared_args = if self.bottom_pane.composer_text().is_empty() {
args
} else {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
prepared_args
};
match prepared_args.trim().to_ascii_lowercase().as_str() {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
"off" => self.set_service_tier_selection(/*service_tier*/ None),
"status" => {
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast))
{
"on"
} else {
"off"
};
self.add_info_message(
format!("Fast mode is {status}."),
/*hint*/ None,
);
}
_ => {
self.add_error_message("Usage: /fast [on|off|status]".to_string());
}
}
}
SlashCommand::Rename if !trimmed.is_empty() => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
let Some(name) = crate::legacy_core::util::normalize_thread_name(&prepared_args)
else {
self.add_error_message("Thread name cannot be empty.".to_string());
return;
};
self.app_event_tx.set_thread_name(name);
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Plan if !trimmed.is_empty() => {
if !self.apply_plan_slash_command() {
return;
}
let Some((prepared_args, prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
text: prepared_args,
local_images,
remote_image_urls,
text_elements: prepared_elements,
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
SlashCommand::Review if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.submit_op(AppCommand::review(ReviewRequest {
target: ReviewTarget::Custom {
instructions: prepared_args,
},
user_facing_hint: None,
}));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Resume if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::ResumeSessionByIdOrName(prepared_args));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
path: prepared_args,
});
self.bottom_pane.drain_pending_submission_state();
}
_ => self.dispatch_command(cmd),
}
}
}
@@ -147,6 +147,43 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item(
assert!(chat.bottom_pane.status_widget().is_none());
}
#[tokio::test]
async fn live_app_server_turn_started_sets_feedback_turn_id() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_server_notification(
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: "thread-1".to_string(),
turn: AppServerTurn {
id: "turn-1".to_string(),
items: Vec::new(),
status: AppServerTurnStatus::InProgress,
error: None,
started_at: Some(0),
completed_at: None,
duration_ms: None,
},
}),
/*replay_kind*/ None,
);
chat.open_feedback_note(
crate::app_event::FeedbackCategory::Bug,
/*include_logs*/ false,
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::SubmitFeedback {
category: crate::app_event::FeedbackCategory::Bug,
reason: None,
turn_id: Some(turn_id),
include_logs: false,
}) if turn_id == "turn-1"
);
}
#[tokio::test]
async fn live_app_server_file_change_item_started_preserves_changes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -244,6 +244,7 @@ pub(super) async fn make_chatwidget_manual(
pending_status_indicator_restore: false,
suppress_queue_autosend: false,
thread_id: None,
last_turn_id: None,
thread_name: None,
forked_from: None,
frame_requester: FrameRequester::test_dummy(),
@@ -9,6 +9,20 @@ fn turn_complete_event(turn_id: &str, last_agent_message: Option<&str>) -> TurnC
.expect("turn complete event should deserialize")
}
fn submit_composer_text(chat: &mut ChatWidget, text: &str) {
chat.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
chat.bottom_pane
.set_composer_text(String::new(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
chat.bottom_pane.composer_text()
}
#[tokio::test]
async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -63,7 +77,7 @@ async fn slash_init_skips_when_project_doc_exists() {
std::fs::write(&existing_path, "existing instructions").unwrap();
chat.config.cwd = tempdir.path().to_path_buf().abs();
chat.dispatch_command(SlashCommand::Init);
submit_composer_text(&mut chat, "/init");
match op_rx.try_recv() {
Err(TryRecvError::Empty) => {}
@@ -85,6 +99,110 @@ async fn slash_init_skips_when_project_doc_exists() {
std::fs::read_to_string(existing_path).unwrap(),
"existing instructions"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/init");
}
#[tokio::test]
async fn bare_slash_command_is_available_from_local_recall_after_dispatch() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
submit_composer_text(&mut chat, "/diff");
let _ = drain_insert_history(&mut rx);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "/diff");
}
#[tokio::test]
async fn inline_slash_command_is_available_from_local_recall_after_dispatch() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
submit_composer_text(&mut chat, "/rename Better title");
let _ = drain_insert_history(&mut rx);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "/rename Better title");
}
#[tokio::test]
async fn usage_error_slash_command_is_available_from_local_recall() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
submit_composer_text(&mut chat, "/fast maybe");
assert_eq!(chat.bottom_pane.composer_text(), "");
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|cell| lines_to_single_string(cell))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Usage: /fast [on|off|status]"),
"expected usage message, got: {rendered:?}"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/fast maybe");
}
#[tokio::test]
async fn unrecognized_slash_command_is_not_added_to_local_recall() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
submit_composer_text(&mut chat, "/does-not-exist");
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|cell| lines_to_single_string(cell))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Unrecognized command '/does-not-exist'"),
"expected unrecognized-command message, got: {rendered:?}"
);
assert_eq!(chat.bottom_pane.composer_text(), "/does-not-exist");
assert_eq!(recall_latest_after_clearing(&mut chat), "");
}
#[tokio::test]
async fn unavailable_slash_command_is_available_from_local_recall() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane.set_task_running(/*running*/ true);
submit_composer_text(&mut chat, "/model");
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|cell| lines_to_single_string(cell))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("'/model' is disabled while a task is in progress."),
"expected disabled-command message, got: {rendered:?}"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/model");
}
#[tokio::test]
async fn no_op_stub_slash_command_is_available_from_local_recall() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
submit_composer_text(&mut chat, "/debug-m-drop");
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|cell| lines_to_single_string(cell))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Memory maintenance"),
"expected stub message, got: {rendered:?}"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/debug-m-drop");
}
#[tokio::test]
+1 -7
View File
@@ -1524,13 +1524,7 @@ async fn read_latest_turn_context(path: &Path) -> Option<TurnContextItem> {
}
pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool {
match (
path_utils::normalize_for_path_comparison(current_cwd),
path_utils::normalize_for_path_comparison(session_cwd),
) {
(Ok(current), Ok(session)) => current != session,
_ => current_cwd != session_cwd,
}
!path_utils::paths_match_after_normalization(current_cwd, session_cwd)
}
pub(crate) enum ResolveCwdOutcome {
+1 -7
View File
@@ -1148,13 +1148,7 @@ fn thread_list_params(
}
fn paths_match(a: &Path, b: &Path) -> bool {
if let (Ok(ca), Ok(cb)) = (
path_utils::normalize_for_path_comparison(a),
path_utils::normalize_for_path_comparison(b),
) {
return ca == cb;
}
a == b
path_utils::paths_match_after_normalization(a, b)
}
#[cfg_attr(not(test), allow(dead_code))]
+3
View File
@@ -100,6 +100,8 @@ pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String {
"Business".to_string()
} else if plan_type.is_business_like() {
"Enterprise".to_string()
} else if plan_type == PlanType::ProLite {
"Pro Lite".to_string()
} else {
title_case(format!("{plan_type:?}").as_str())
}
@@ -216,6 +218,7 @@ mod tests {
(PlanType::Go, "Go"),
(PlanType::Plus, "Plus"),
(PlanType::Pro, "Pro"),
(PlanType::ProLite, "Pro Lite"),
(PlanType::Team, "Business"),
(PlanType::SelfServeBusinessUsageBased, "Business"),
(PlanType::Business, "Enterprise"),
+1 -1
View File
@@ -61,7 +61,7 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Op
Some(plan_type)
if matches!(
plan_type,
PlanType::Plus | PlanType::Enterprise | PlanType::Pro
PlanType::Plus | PlanType::Enterprise | PlanType::Pro | PlanType::ProLite
) || plan_type.is_team_like()
|| plan_type.is_business_like() =>
{
+13
View File
@@ -16,6 +16,19 @@ pub fn normalize_for_path_comparison(path: impl AsRef<Path>) -> std::io::Result<
Ok(normalize_for_wsl(canonical))
}
/// Compare paths after applying Codex's filesystem normalization.
///
/// If either path cannot be normalized, this falls back to direct path equality.
pub fn paths_match_after_normalization(left: impl AsRef<Path>, right: impl AsRef<Path>) -> bool {
if let (Ok(left), Ok(right)) = (
normalize_for_path_comparison(left.as_ref()),
normalize_for_path_comparison(right.as_ref()),
) {
return left == right;
}
left.as_ref() == right.as_ref()
}
pub fn normalize_for_native_workdir(path: impl AsRef<Path>) -> PathBuf {
normalize_for_native_workdir_with_flag(path.as_ref().to_path_buf(), cfg!(windows))
}
@@ -78,3 +78,38 @@ mod native_workdir {
assert_eq!(normalized, path);
}
}
mod path_comparison {
use super::super::paths_match_after_normalization;
use std::path::PathBuf;
#[test]
fn matches_identical_existing_paths() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
assert!(paths_match_after_normalization(dir.path(), dir.path()));
Ok(())
}
#[test]
fn falls_back_to_raw_equality_when_paths_cannot_be_normalized() {
assert!(paths_match_after_normalization(
PathBuf::from("missing"),
PathBuf::from("missing"),
));
assert!(!paths_match_after_normalization(
PathBuf::from("missing-a"),
PathBuf::from("missing-b"),
));
}
#[cfg(windows)]
#[test]
fn matches_windows_verbatim_paths() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
let verbatim_dir = PathBuf::from(format!(r"\\?\{}", dir.path().display()));
assert!(paths_match_after_normalization(verbatim_dir, dir.path()));
Ok(())
}
}