diff --git a/.devcontainer/Dockerfile.secure b/.devcontainer/Dockerfile.secure new file mode 100644 index 0000000000..aaff9eb2d3 --- /dev/null +++ b/.devcontainer/Dockerfile.secure @@ -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 diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 58e4458a06..a5e114d37a 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -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. diff --git a/.devcontainer/devcontainer.secure.json b/.devcontainer/devcontainer.secure.json new file mode 100644 index 0000000000..a7f1f6c654 --- /dev/null +++ b/.devcontainer/devcontainer.secure.json @@ -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" + ] + } + } +} diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh new file mode 100644 index 0000000000..9275724473 --- /dev/null +++ b/.devcontainer/init-firewall.sh @@ -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" diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100644 index 0000000000..fcc42ad145 --- /dev/null +++ b/.devcontainer/post-start.sh @@ -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 diff --git a/.devcontainer/post_install.py b/.devcontainer/post_install.py new file mode 100644 index 0000000000..205e57ce4c --- /dev/null +++ b/.devcontainer/post_install.py @@ -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() diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 1173685890..80ef91ccb8 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -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 }} diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4a69b4d8cc..76f7abdfd2 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -647,6 +647,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5d7c1549ab..c3ab83766a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2010,6 +2010,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 95de3343a9..dd839cd586 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 88df6dce4a..66b91082e0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index d1812f069f..67a5f055b3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -28,6 +28,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index f2cf7cb3ab..64109ca2a0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -33,6 +33,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json index 47b752b868..07c2098606 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json @@ -22,6 +22,15 @@ "null" ] }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, "threadId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 23f7d3cfdc..2d34ee47d1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -28,6 +28,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index acaa779184..8534927157 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -51,6 +51,7 @@ "go", "plus", "pro", + "prolite", "team", "self_serve_business_usage_based", "business", diff --git a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts index ad5bcab380..44891467e9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/PlanType.ts +++ b/codex-rs/app-server-protocol/schema/typescript/PlanType.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index ebbe3b32d4..86d9de2f0d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -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 | null, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e562981f0a..4ce7e9c0bb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2242,6 +2242,8 @@ pub struct FeedbackUploadParams { pub include_logs: bool, #[ts(optional = nullable)] pub extra_log_files: Option>, + #[ts(optional = nullable)] + pub tags: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 5d38dcf928..b2df80eae9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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; diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 3c88bcb7a4..f798122635 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -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 diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 6049c1697b..20baa1cfea 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -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, } } diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs index 300674357a..4d9ec1e364 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -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, } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3fba1d7217..bd74c46595 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" }, diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 80d4b6bdd9..6c7f071f2b 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -629,14 +629,7 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { } fn paths_match(expected: impl AsRef, provided: impl AsRef) -> 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> { diff --git a/codex-rs/core/src/realtime_prompt.rs b/codex-rs/core/src/realtime_prompt.rs index d2b7570669..27005c630d 100644 --- a/codex-rs/core/src/realtime_prompt.rs +++ b/codex-rs/core/src/realtime_prompt.rs @@ -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 }}")); } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 5ea14b8a8f..795f0112d9 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -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(); } diff --git a/codex-rs/core/templates/realtime/backend_prompt.md b/codex-rs/core/templates/realtime/backend_prompt.md index c5a98b03c5..c45803ec7d 100644 --- a/codex-rs/core/templates/realtime/backend_prompt.md +++ b/codex-rs/core/templates/realtime/backend_prompt.md @@ -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.' \ No newline at end of file diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 17867bbb04..c0b20172ca 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -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 diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 81a0961fa4..31099e8d65 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1205,13 +1205,7 @@ async fn parse_latest_turn_context_cwd(path: &Path) -> Option { } 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( diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 590a1ff69f..10d5fdf884 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -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( diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 40009dd884..bc357a7fb4 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -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)); diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 712aeee219..ff6b796358 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -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>, + pub include_logs: bool, + pub extra_attachment_paths: &'a [PathBuf], + pub session_source: Option, + pub logs_override: Option>, +} + 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, - logs_override: Option>, - ) -> 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>, + session_source: Option<&SessionSource>, + ) -> BTreeMap { 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")); + } } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 71857c9700..15b129a169 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -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 diff --git a/codex-rs/login/src/token_data_tests.rs b/codex-rs/login/src/token_data_tests.rs index be160ecd64..aeb55ffb90 100644 --- a/codex-rs/login/src/token_data_tests.rs +++ b/codex-rs/login/src/token_data_tests.rs @@ -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); } diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index e9f2fc9ec3..bb46329a51 100644 --- a/codex-rs/protocol/src/account.rs +++ b/codex-rs/protocol/src/account.rs @@ -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::("\"self_serve_business_usage_based\"") .expect("self-serve business usage based should deserialize"), PlanType::SelfServeBusinessUsageBased ); + assert_eq!( + serde_json::from_str::("\"prolite\"").expect("prolite should deserialize"), + PlanType::ProLite + ); assert_eq!( serde_json::from_str::("\"enterprise_cbp_usage_based\"") .expect("enterprise cbp usage based should deserialize"), diff --git a/codex-rs/protocol/src/auth.rs b/codex-rs/protocol/src/auth.rs index 11455ec909..99e067bf24 100644 --- a/codex-rs/protocol/src/auth.rs +++ b/codex-rs/protocol/src/auth.rs @@ -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", diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 3ffdeb48e0..f421db8af5 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -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()) ), diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index a142144089..01ea10cb95 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -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)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7e3d4e0063..ad8b63a194 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2016,6 +2016,7 @@ impl App { app_server: &AppServerSession, category: FeedbackCategory, reason: Option, + turn_id: Option, 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, category: FeedbackCategory, reason: Option, + turn_id: Option, 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); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 78a2525350..0e71e1a432 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -559,6 +559,7 @@ pub(crate) enum AppEvent { SubmitFeedback { category: FeedbackCategory, reason: Option, + turn_id: Option, include_logs: bool, }, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 403a499bd5..0a82f7cf80 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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, }, + /// 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), 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, + /// 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, footer_flash: Option, context_window_percent: Option, // 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::(); + 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::(); + 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::(); + 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::(); diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index b1889abca0..f40a21cdfc 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -45,6 +45,7 @@ pub(crate) enum FeedbackAudience { /// through the app-server-managed feedback flow. pub(crate) struct FeedbackNoteView { category: FeedbackCategory, + turn_id: Option, 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, 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::(); 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::(); 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::(); 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, } )); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ef3898bafd..bd3bd946af 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dfbe10fed9..1e5ad3e56a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, + last_turn_id: Option, thread_name: Option, forked_from: Option, 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 ".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, - ) { - 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(); } } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs new file mode 100644 index 0000000000..e6e93d17fb --- /dev/null +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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, + ) { + 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 ".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, + ) { + 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), + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 09bc9a7013..6b18e2459d 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d99c304fc2..d5c6a5e3b4 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -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(), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index e751292550..2afb959349 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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::>() + .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::>() + .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::>() + .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::>() + .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] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index cd5049499d..1e3b58452b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1524,13 +1524,7 @@ async fn read_latest_turn_context(path: &Path) -> Option { } 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 { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index a3c076a5fb..d50d00bdda 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -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))] diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index 5103b9e4d3..ef28ed7fcf 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -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"), diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 9565831b27..8f6d5ba1a5 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -61,7 +61,7 @@ pub(crate) fn get_tooltip(plan: Option, 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() => { diff --git a/codex-rs/utils/path-utils/src/lib.rs b/codex-rs/utils/path-utils/src/lib.rs index 7e44e06d13..5390250a1b 100644 --- a/codex-rs/utils/path-utils/src/lib.rs +++ b/codex-rs/utils/path-utils/src/lib.rs @@ -16,6 +16,19 @@ pub fn normalize_for_path_comparison(path: impl AsRef) -> 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, right: impl AsRef) -> 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) -> PathBuf { normalize_for_native_workdir_with_flag(path.as_ref().to_path_buf(), cfg!(windows)) } diff --git a/codex-rs/utils/path-utils/src/path_utils_tests.rs b/codex-rs/utils/path-utils/src/path_utils_tests.rs index bda4b77ab3..94c914fec2 100644 --- a/codex-rs/utils/path-utils/src/path_utils_tests.rs +++ b/codex-rs/utils/path-utils/src/path_utils_tests.rs @@ -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(()) + } +}