diff --git a/.bazelrc b/.bazelrc index 603ad46e4..cc59037b5 100644 --- a/.bazelrc +++ b/.bazelrc @@ -136,6 +136,16 @@ common:ci-linux --config=remote common:ci-linux --strategy=remote common:ci-linux --platforms=//:rbe +# Fork-only Linux jobs that must stay on Bazel but cannot rely on BuildBuddy +# remote execution should use this explicit local variant instead of trying to +# partially undo `ci-linux` at the callsite. +common:ci-linux-local --config=ci-bazel +common:ci-linux-local --build_metadata=TAG_os=linux +common:ci-linux-local --extra_execution_platforms= +common:ci-linux-local --platforms=//:local_linux +common:ci-linux-local --spawn_strategy=local +common:ci-linux-local --strategy=local + # On mac, we can run all the build actions remotely but test actions locally. common:ci-macos --config=ci-bazel common:ci-macos --build_metadata=TAG_os=macos diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index 9c95fda15..301e9b2ca 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -65,6 +65,11 @@ case "${RUNNER_OS:-}" in ;; esac +force_local_ci_config= +if [[ "${RUNNER_OS:-}" == "Linux" ]]; then + force_local_ci_config=ci-linux-local +fi + print_bazel_test_log_tails() { local console_log="$1" local testlogs_dir @@ -210,7 +215,29 @@ if (( ${#bazel_startup_args[@]} > 0 )); then bazel_cmd+=("${bazel_startup_args[@]}") fi -if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then +if [[ "${CODEX_BAZEL_FORCE_LOCAL:-0}" == "1" ]]; then + echo "CODEX_BAZEL_FORCE_LOCAL=1; using local Bazel configuration." + bazel_run_args=( + "${bazel_args[@]}" + --remote_cache= + --remote_executor= + ) + if [[ -n "${force_local_ci_config}" ]]; then + bazel_run_args+=("--config=${force_local_ci_config}") + fi + if (( ${#post_config_bazel_args[@]} > 0 )); then + bazel_run_args+=("${post_config_bazel_args[@]}") + fi + set +e + run_bazel "${bazel_cmd[@]:1}" \ + --noexperimental_remote_repo_contents_cache \ + "${bazel_run_args[@]}" \ + -- \ + "${bazel_targets[@]}" \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e +elif [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then echo "BuildBuddy API key is available; using remote Bazel configuration." # Work around Bazel 9 remote repo contents cache / overlay materialization failures # seen in CI (for example "is not a symlink" or permission errors while diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d14817f00..66a92a5e8 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,6 +1,7 @@ # Workflow Strategy -The workflows in this directory are split so that pull requests get fast, review-friendly signal while `main` still gets the full cross-platform verification pass. +This fork keeps pushes to `main` quiet. Heavier validation runs stay available through +`workflow_dispatch`, while pull requests can still use targeted review-time checks. ## Pull Requests @@ -14,18 +15,23 @@ The workflows in this directory are split so that pull requests get fast, review - `argument-comment-lint` on Linux, macOS, and Windows - `tools/argument-comment-lint` package tests when the lint or its workflow wiring changes -## Post-Merge On `main` +## Manual Verification -- `bazel.yml` also runs on pushes to `main`. - This re-verifies the merged Bazel path and helps keep the BuildBuddy caches warm. +- `bazel.yml` is available as a manual verification path when the fork needs a full + Bazel pass. - `rust-ci-full.yml` is the full Cargo-native verification workflow. - It keeps the heavier checks off the PR path while still validating them after merge: + It keeps the heavier checks off the PR path while still providing an on-demand + validation path: - the full Cargo `clippy` matrix - the full Cargo `nextest` matrix - release-profile Cargo builds - cross-platform `argument-comment-lint` - Linux remote-env tests +Other repo-level checks that used to run on `push(main)` in upstream, such as +`ci.yml`, `cargo-deny.yml`, `codespell.yml`, `sdk.yml`, and `v8-canary.yml`, are also +manual-only in this fork so routine sync pushes do not fan out into unrelated CI. + ## Rule Of Thumb - If a build/test/clippy check can be expressed in Bazel, prefer putting the PR-time version in `bazel.yml`. diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index eeefcdada..307780546 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -5,9 +5,6 @@ name: Bazel on: pull_request: {} - push: - branches: - - main workflow_dispatch: concurrency: diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 5294d0c7c..b118046b5 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -2,9 +2,7 @@ name: cargo-deny on: pull_request: - push: - branches: - - main + workflow_dispatch: jobs: cargo-deny: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76a8aa014..2ca07b2b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: ci on: pull_request: {} - push: { branches: [main] } + workflow_dispatch: jobs: build-test: @@ -37,6 +37,7 @@ jobs: - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Stage npm package + if: ${{ github.repository == 'openai/codex' }} id: stage_npm_package env: GH_TOKEN: ${{ github.token }} @@ -53,6 +54,7 @@ jobs: echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact + if: ${{ github.repository == 'openai/codex' }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: codex-npm-staging diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 8e9f701ee..11815a01e 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -3,10 +3,9 @@ name: Codespell on: - push: - branches: [main] pull_request: branches: [main] + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/pypi-release-artifacts.yml b/.github/workflows/pypi-release-artifacts.yml new file mode 100644 index 000000000..ea176a2ed --- /dev/null +++ b/.github/workflows/pypi-release-artifacts.yml @@ -0,0 +1,165 @@ +name: pypi-release-artifacts + +on: + workflow_call: + inputs: + checkout_ref: + required: true + type: string + release_tag: + required: true + type: string + +concurrency: + group: pypi-release-artifacts-${{ inputs.release_tag }} + cancel-in-progress: true + +jobs: + build: + name: build-artifact-${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + env: + CARGO_INCREMENTAL: "0" + RUSTC_WRAPPER: "" + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + target: aarch64-apple-darwin + runtime_binary_name: codex + release_asset_name: codex-aarch64-apple-darwin.tar.gz + build_args: --bin codex + - runner: macos-15-intel + target: x86_64-apple-darwin + runtime_binary_name: codex + release_asset_name: codex-x86_64-apple-darwin.tar.gz + build_args: --bin codex + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + runtime_binary_name: codex + release_asset_name: codex-x86_64-unknown-linux-gnu.tar.gz + build_args: --bin codex + - runner: windows-2022 + target: x86_64-pc-windows-msvc + runtime_binary_name: codex.exe + release_asset_name: codex-x86_64-pc-windows-msvc.zip + build_args: --bin codex --bin codex-windows-sandbox-setup --bin codex-command-runner + steps: + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ inputs.checkout_ref }} + + - uses: dtolnay/rust-toolchain@1.93.0 + with: + targets: ${{ matrix.target }} + + - name: Clear workspace build rustflags for CI release builds + shell: bash + run: | + set -euo pipefail + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + + - name: Use default macOS linker + if: ${{ runner.os == 'macOS' }} + shell: bash + run: | + set -euo pipefail + echo "RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV" + echo "CARGO_TARGET_X86_64_APPLE_DARWIN_RUSTFLAGS=" >> "$GITHUB_ENV" + + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y libcap-dev pkg-config protobuf-compiler + + - name: Install macOS build dependencies + if: ${{ runner.os == 'macOS' }} + shell: bash + run: | + set -euo pipefail + if ! command -v protoc >/dev/null 2>&1; then + brew install protobuf + fi + + - name: Install Windows build dependencies + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + if (-not (Get-Command protoc -ErrorAction SilentlyContinue)) { + choco install protoc -y --no-progress + } + + - name: Build release binaries + shell: bash + working-directory: codex-rs + run: | + set -euo pipefail + cargo build --locked --release --target "${{ matrix.target }}" ${{ matrix.build_args }} + + - name: Stage runtime artifact + shell: bash + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/runtime-artifact" + cp "codex-rs/target/${{ matrix.target }}/release/${{ matrix.runtime_binary_name }}" \ + "${RUNNER_TEMP}/runtime-artifact/${{ matrix.runtime_binary_name }}" + + - name: Stage release asset + shell: bash + run: | + set -euo pipefail + release_dir="${RUNNER_TEMP}/release-asset" + mkdir -p "${release_dir}" + + if [[ "${{ runner.os }}" == "Windows" ]]; then + cp "codex-rs/target/${{ matrix.target }}/release/codex.exe" "${release_dir}/codex.exe" + cp "codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe" "${release_dir}/codex-windows-sandbox-setup.exe" + cp "codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" "${release_dir}/codex-command-runner.exe" + else + cp "codex-rs/target/${{ matrix.target }}/release/codex" "${release_dir}/codex" + fi + + - name: Archive Unix release asset + if: ${{ runner.os != 'Windows' }} + shell: bash + run: | + set -euo pipefail + release_dir="${RUNNER_TEMP}/release-asset" + archive_dir="${RUNNER_TEMP}/release-archive" + mkdir -p "${archive_dir}" + tar -C "${release_dir}" -czf "${archive_dir}/${{ matrix.release_asset_name }}" codex + + - name: Archive Windows release asset + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + $releaseDir = Join-Path $env:RUNNER_TEMP "release-asset" + $archiveDir = Join-Path $env:RUNNER_TEMP "release-archive" + New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null + Compress-Archive -Path (Join-Path $releaseDir '*') -DestinationPath (Join-Path $archiveDir '${{ matrix.release_asset_name }}') -Force + + - name: Upload runtime binary artifact + uses: actions/upload-artifact@v4 + with: + name: pypi-runtime-${{ matrix.target }} + path: ${{ runner.temp }}/runtime-artifact/* + if-no-files-found: error + + - name: Upload GitHub release asset + uses: actions/upload-artifact@v4 + with: + name: pypi-release-asset-${{ matrix.target }} + path: ${{ runner.temp }}/release-archive/* + if-no-files-found: error diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml new file mode 100644 index 000000000..c182054ce --- /dev/null +++ b/.github/workflows/pypi-release.yml @@ -0,0 +1,278 @@ +name: pypi-release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + release_tag: + description: Existing tag to build and publish, for example v0.1.12 + required: true + type: string + artifact_run_id: + description: Existing pypi-release run id whose wheel and release artifacts should be reused + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name || inputs.release_tag }} + cancel-in-progress: true + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.meta.outputs.release_tag }} + release_version: ${{ steps.meta.outputs.release_version }} + checkout_ref: ${{ steps.meta.outputs.checkout_ref }} + artifact_run_id: ${{ steps.meta.outputs.artifact_run_id }} + reuse_artifacts: ${{ steps.meta.outputs.reuse_artifacts }} + steps: + - name: Resolve release metadata + id: meta + shell: bash + run: | + set -euo pipefail + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + release_tag="${{ inputs.release_tag }}" + checkout_ref="refs/tags/${release_tag}" + else + release_tag="${GITHUB_REF_NAME}" + checkout_ref="${GITHUB_REF}" + fi + + [[ "${release_tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + || { echo "Release tag ${release_tag} is not in the expected format."; exit 1; } + + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" + echo "release_version=${release_tag#v}" >> "$GITHUB_OUTPUT" + echo "checkout_ref=${checkout_ref}" >> "$GITHUB_OUTPUT" + artifact_run_id="${{ inputs.artifact_run_id }}" + if [[ -n "${artifact_run_id}" ]]; then + echo "artifact_run_id=${artifact_run_id}" >> "$GITHUB_OUTPUT" + echo "reuse_artifacts=true" >> "$GITHUB_OUTPUT" + else + echo "artifact_run_id=" >> "$GITHUB_OUTPUT" + echo "reuse_artifacts=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ steps.meta.outputs.checkout_ref }} + + - name: Validate tag matches codex-enhanced runtime version + shell: bash + run: | + set -euo pipefail + runtime_ver="$(grep -m1 '^version' sdk/python-runtime-enhanced/pyproject.toml | sed -E 's/version *= *"([^"]+)".*/\1/')" + tag_ver="${{ steps.meta.outputs.release_version }}" + [[ "${tag_ver}" == "${runtime_ver}" ]] \ + || { echo "Tag version ${tag_ver} does not match sdk/python-runtime-enhanced ${runtime_ver}."; exit 1; } + + release-assets: + needs: prepare + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + uses: ./.github/workflows/pypi-release-artifacts.yml + with: + checkout_ref: ${{ needs.prepare.outputs.checkout_ref }} + release_tag: ${{ needs.prepare.outputs.release_tag }} + + build: + needs: + - prepare + - release-assets + name: build-wheel-${{ matrix.target }} + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' && needs.release-assets.result == 'success' }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + target: aarch64-apple-darwin + runtime_artifact_name: pypi-runtime-aarch64-apple-darwin + binary_name: codex + - runner: macos-15-intel + target: x86_64-apple-darwin + runtime_artifact_name: pypi-runtime-x86_64-apple-darwin + binary_name: codex + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + runtime_artifact_name: pypi-runtime-x86_64-unknown-linux-gnu + binary_name: codex + - runner: windows-2022 + target: x86_64-pc-windows-msvc + runtime_artifact_name: pypi-runtime-x86_64-pc-windows-msvc + binary_name: codex.exe + steps: + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Python build dependencies + shell: bash + run: python -m pip install --upgrade build hatchling packaging + + - name: Download release binary artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.runtime_artifact_name }} + path: ${{ runner.temp }}/runtime-artifact + + - name: Stage codex-enhanced runtime package + shell: bash + run: | + set -euo pipefail + python sdk/python/scripts/update_sdk_artifacts.py \ + stage-runtime \ + "${RUNNER_TEMP}/codex-enhanced" \ + "${RUNNER_TEMP}/runtime-artifact/${{ matrix.binary_name }}" \ + --runtime-version "${{ needs.prepare.outputs.release_version }}" \ + --runtime-package enhanced + + - name: Build wheel + shell: bash + run: python -m build --wheel "${RUNNER_TEMP}/codex-enhanced" + + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: pypi-wheel-${{ matrix.target }} + path: ${{ runner.temp }}/codex-enhanced/dist/* + if-no-files-found: error + + publish: + needs: + - prepare + - release-assets + - build + if: ${{ always() && needs.prepare.result == 'success' && ((needs.prepare.outputs.reuse_artifacts == 'true' && needs.build.result == 'skipped') || (needs.prepare.outputs.reuse_artifacts != 'true' && needs.release-assets.result == 'success' && needs.build.result == 'success')) }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + id-token: write + steps: + - name: Download wheel artifacts from current run + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + uses: actions/download-artifact@v4 + with: + pattern: pypi-wheel-* + path: dist + merge-multiple: true + + - name: Download wheel artifacts from an earlier run + if: ${{ needs.prepare.outputs.reuse_artifacts == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p dist + work_dir="$(mktemp -d)" + gh run download "${{ needs.prepare.outputs.artifact_run_id }}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "${work_dir}" + find "${work_dir}" -type f -name '*.whl' -exec mv {} dist/ \; + if ! find dist -maxdepth 1 -type f -name '*.whl' | grep -q .; then + echo "No wheel artifacts were downloaded from run ${{ needs.prepare.outputs.artifact_run_id }}." + exit 1 + fi + + - name: Publish codex-enhanced wheels to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist + skip-existing: true + verbose: true + + github-release: + needs: + - prepare + - release-assets + - build + - publish + if: ${{ always() && needs.prepare.result == 'success' && needs.publish.result == 'success' && ((needs.prepare.outputs.reuse_artifacts == 'true' && needs.release-assets.result == 'skipped' && needs.build.result == 'skipped') || (needs.prepare.outputs.reuse_artifacts != 'true' && needs.release-assets.result == 'success' && needs.build.result == 'success')) }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - name: Download release assets from current run + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + uses: actions/download-artifact@v4 + with: + pattern: pypi-release-asset-* + path: release-dist + merge-multiple: true + + - name: Download wheel artifacts from current run + if: ${{ needs.prepare.outputs.reuse_artifacts != 'true' }} + uses: actions/download-artifact@v4 + with: + pattern: pypi-wheel-* + path: release-dist + merge-multiple: true + + - name: Download release assets and wheels from an earlier run + if: ${{ needs.prepare.outputs.reuse_artifacts == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p release-dist + work_dir="$(mktemp -d)" + gh run download "${{ needs.prepare.outputs.artifact_run_id }}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "${work_dir}" + find "${work_dir}" -type f \( -name '*.whl' -o -name '*.tar.gz' -o -name '*.zip' \) -exec mv {} release-dist/ \; + if ! find release-dist -maxdepth 1 -type f | grep -q .; then + echo "No release assets were downloaded from run ${{ needs.prepare.outputs.artifact_run_id }}." + exit 1 + fi + + - name: Generate GitHub Release notes + shell: bash + env: + RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} + run: | + set -euo pipefail + commit="$(git rev-parse HEAD^{commit})" + notes_path="${RUNNER_TEMP}/release-notes.md" + pypi_url="https://pypi.org/project/codex-enhanced/${{ needs.prepare.outputs.release_version }}/" + + git log -1 --format=%B "${commit}" > "${notes_path}" + echo >> "${notes_path}" + { + echo "## PyPI" + echo + echo "- https://pypi.org/project/codex-enhanced/" + echo "- ${pypi_url}" + } >> "${notes_path}" + + echo "RELEASE_NOTES_PATH=${notes_path}" >> "$GITHUB_ENV" + + - name: Publish GitHub Release + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + with: + name: ${{ needs.prepare.outputs.release_version }} + tag_name: ${{ needs.prepare.outputs.release_tag }} + body_path: ${{ env.RELEASE_NOTES_PATH }} + files: release-dist/* + overwrite_files: true + prerelease: ${{ contains(needs.prepare.outputs.release_version, '-') }} diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 146e57524..5368ceca5 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -1,8 +1,5 @@ name: rust-ci-full on: - push: - branches: - - main workflow_dispatch: # CI builds in debug (dev) for faster signal. diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 3a9eadc8b..0065827eb 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -134,19 +134,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - name: Linux - runner: ubuntu-24.04 - timeout_minutes: 30 - - name: macOS - runner: macos-15-xlarge - timeout_minutes: 30 - - name: Windows - runner: windows-x64 - timeout_minutes: 30 - runs_on: - group: codex-runners - labels: codex-windows-x64 + include: ${{ fromJSON(github.repository == 'openai/codex' && '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":30},{"name":"macOS","runner":"macos-15-xlarge","timeout_minutes":30},{"name":"Windows","runner":"windows-x64","timeout_minutes":30,"runs_on":{"group":"codex-runners","labels":"codex-windows-x64"}}]' || '[{"name":"Linux","runner":"ubuntu-24.04","timeout_minutes":120}]') }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: ./.github/actions/setup-bazel-ci @@ -158,9 +146,60 @@ jobs: shell: bash run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev protobuf-compiler libprotobuf-dev + - name: Run argument comment lint on codex-rs via prebuilt wrapper + if: ${{ runner.os == 'Linux' && github.repository != 'openai/codex' }} + shell: bash + run: | + heartbeat() { + while true; do + sleep 60 + echo "argument comment lint still running via prebuilt wrapper ($(date -u '+%Y-%m-%dT%H:%M:%SZ'))" + done + } + + heartbeat & + heartbeat_pid=$! + cleanup() { + kill "$heartbeat_pid" 2>/dev/null || true + } + trap cleanup EXIT + + rustup toolchain install nightly-2025-09-18 \ + --profile minimal \ + --component llvm-tools-preview \ + --component rustc-dev \ + --component rust-src \ + --no-self-update + + set +e + timeout --foreground --signal=TERM --kill-after=30s 110m \ + ./tools/argument-comment-lint/run-prebuilt-linter.py + status=$? + set -e + + if [[ $status -eq 124 ]]; then + echo "argument comment lint prebuilt-wrapper step exceeded 110 minutes; failing explicitly before the 120-minute job timeout so shared CI follow-up can diagnose it." + fi + + exit "$status" + - name: Run argument comment lint on codex-rs via Bazel + if: ${{ runner.os == 'Linux' && github.repository == 'openai/codex' }} + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + bazel_targets="$(./tools/argument-comment-lint/list-bazel-targets.sh)" + ./.github/scripts/run-bazel-ci.sh \ + -- \ + build \ + --config=argument-comment-lint \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + -- \ + ${bazel_targets} - name: Run argument comment lint on codex-rs via Bazel - if: ${{ runner.os != 'Windows' }} + if: ${{ runner.os == 'macOS' }} env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 45c983ac1..0da6755fc 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -1,9 +1,8 @@ name: sdk on: - push: - branches: [main] pull_request: {} + workflow_dispatch: jobs: sdks: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 0dc7dc005..38ceaad87 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -12,19 +12,6 @@ on: - "patches/BUILD.bazel" - "patches/v8_*.patch" - "third_party/v8/**" - push: - branches: - - main - paths: - - ".github/scripts/rusty_v8_bazel.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/v8_*.patch" - - "third_party/v8/**" workflow_dispatch: concurrency: diff --git a/.gitignore b/.gitignore index 82269594b..993277dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,8 @@ CHANGELOG.ignore.md __pycache__/ *.pyc +.codex/ +.desloppify/ +scorecard.png +site/ +docs/assets/ diff --git a/AGENTS.md b/AGENTS.md index c8d989fe9..f13d07d29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,20 +16,13 @@ In the codex-rs folder where the rust code lives: - Use an exact `/*param_name*/` comment before opaque literal arguments such as `None`, booleans, and numeric literals when passing them by position. - Do not add these comments for string or char literals unless the comment adds real clarity; those literals are intentionally exempt from the lint. - The parameter name in the comment must exactly match the callee signature. - - You can run `just argument-comment-lint` to run the lint check locally. This is powered by Bazel, so running it the first time can be slow if Bazel is not warmed up, though incremental invocations should take <15s. Most of the time, it is best to update the PR and let CI take responsibility for checking this (or run it asynchronously in the background after submitting the PR). Note CI checks all three platforms, which the local run does not. + - Do not run bazel related commands unless in CI environement + - Do not run 'just argument-comment-lint' unless in CI environment - When possible, make `match` statements exhaustive and avoid wildcard arms. - Newly added traits should include doc comments that explain their role and how implementations are expected to use them. - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. -- If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the - repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change. -- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught - locally before CI. -- Bazel does not automatically make source-tree files available to compile-time Rust file access. If - you add `include_str!`, `include_bytes!`, `sqlx::migrate!`, or similar build-time file or - directory reads, update the crate's `BUILD.bazel` (`compile_data`, `build_script_data`, or test - data) or Bazel may fail even when Cargo passes. - Do not create small helper methods that are referenced only once. - Avoid large modules: - Prefer adding new modules instead of growing existing ones. @@ -207,3 +200,7 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially: - Validate with `cargo test -p codex-app-server-protocol`. - Avoid boilerplate tests that only assert experimental field markers for individual request fields in `common.rs`; rely on schema generation/tests and behavioral coverage instead. +- cargo run --bin codex -- -p 总是 加 -p pig ; it allows api usage +- 修改代码后总是执行 cargo build -p codex-cli; 然后执行交互式终端 (PTY) 验证要完成的特性是否实现; +- 在跑 codex 命令之前, 总是用sudo codesign --sign - --force --preserve-metadata=entitlements,requirements,flags,runtime 来给二进制签名 +- 当需求改变的时候, 要同步更新对应的文档 diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 1e2123ec4..7ef01da37 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -996,6 +996,7 @@ "lalrpop_0.19.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ascii-canvas\",\"req\":\"^3.0\"},{\"default_features\":false,\"name\":\"bit-set\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"name\":\"diff\",\"req\":\"^0.1.12\"},{\"default_features\":false,\"name\":\"ena\",\"req\":\"^0.14\"},{\"name\":\"is-terminal\",\"req\":\"^0.4.2\"},{\"default_features\":false,\"features\":[\"use_std\"],\"name\":\"itertools\",\"req\":\"^0.10\"},{\"name\":\"lalrpop-util\",\"req\":\"^0.19.12\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"pico-args\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-case\",\"unicode-perl\"],\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"unicode\"],\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"unicode-case\",\"unicode-perl\"],\"kind\":\"dev\",\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"string_cache\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"term\",\"req\":\"^0.7\"},{\"features\":[\"sha3\"],\"name\":\"tiny-keccak\",\"req\":\"^2.0.2\"},{\"default_features\":false,\"name\":\"unicode-xid\",\"req\":\"^0.2\"}],\"features\":{\"default\":[\"lexer\"],\"lexer\":[\"lalrpop-util/lexer\"],\"test\":[]}}", "landlock_0.4.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"enumflags2\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2.175\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.26\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{}}", "language-tags_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", + "lark-websocket-protobuf_0.1.1": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.6.0\"},{\"name\":\"prost\",\"req\":\"^0.13.1\"},{\"kind\":\"build\",\"name\":\"prost-build\",\"req\":\"^0.12.6\"}],\"features\":{}}", "lazy_static_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"once\"],\"name\":\"spin\",\"optional\":true,\"req\":\"^0.9.8\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"spin_no_std\":[\"spin\"]}}", "leb128fmt_0.1.0": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[]}}", "libc_0.2.182": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", @@ -1103,6 +1104,26 @@ "onig_6.5.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"onig_sys\",\"req\":\"^69.9.1\"}],\"features\":{\"default\":[\"generate\"],\"generate\":[\"onig_sys/generate\"],\"posix-api\":[\"onig_sys/posix-api\"],\"print-debug\":[\"onig_sys/print-debug\"],\"std-pattern\":[]}}", "onig_sys_69.9.1": "{\"dependencies\":[{\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.71\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.16\"}],\"features\":{\"default\":[\"generate\"],\"generate\":[\"bindgen\"],\"posix-api\":[],\"print-debug\":[]}}", "opaque-debug_0.3.1": "{\"dependencies\":[],\"features\":{}}", + "openlark-ai_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"}],\"features\":{\"default\":[\"v1\"],\"full\":[\"v1\"],\"v1\":[]}}", + "openlark-analytics_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"all-analytics\":[\"full\"],\"analytics\":[\"search\",\"report\"],\"core\":[],\"default\":[\"search\",\"report\"],\"full\":[\"search\",\"report\",\"v4\"],\"report\":[\"report-core\",\"core\"],\"report-core\":[],\"search\":[\"search-core\",\"core\"],\"search-core\":[],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"v4\":[\"v3\"]}}", + "openlark-application_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"default\":[\"v1\",\"async\"],\"full\":[\"v1\",\"async\"],\"v1\":[]}}", + "openlark-auth_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"features\":[\"serde\",\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"features\":[\"html_reports\"],\"name\":\"criterion\",\"optional\":true,\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12.7\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.18\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"optional\":true,\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\",\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"advanced-cache\":[\"cache\",\"encryption\"],\"cache\":[\"token-management\"],\"default\":[\"token-management\",\"cache\",\"oauth\"],\"encryption\":[\"ring\",\"sha2\",\"hmac\",\"pbkdf2\"],\"monitoring\":[],\"oauth\":[\"reqwest\",\"url\"],\"token-management\":[]}}", + "openlark-cardkit_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"kind\":\"dev\",\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"v1\"],\"full\":[\"v1\"],\"v1\":[]}}", + "openlark-client_0.15.0-rc.2": "{\"dependencies\":[{\"features\":[\"serde\",\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"lark-websocket-protobuf\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-ai\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-auth\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-cardkit\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-communication\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-docs\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-hr\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-meeting\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"openlark-security\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"rustls-tls-native-roots\"],\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.23\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"optional\":true,\"req\":\"^2.5.0\"},{\"features\":[\"v4\",\"serde\",\"v4\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"ai\":[\"dep:openlark-ai\"],\"auth\":[\"dep:openlark-auth\"],\"cardkit\":[\"auth\",\"dep:openlark-cardkit\"],\"communication\":[\"auth\",\"dep:openlark-communication\"],\"core-layer\":[\"communication\",\"docs\",\"security\"],\"default\":[\"auth\",\"communication\"],\"docs\":[\"auth\",\"dep:openlark-docs\"],\"hr\":[\"dep:openlark-hr\"],\"meeting\":[\"auth\",\"dep:openlark-meeting\"],\"p0-services\":[\"communication\",\"docs\",\"security\"],\"security\":[\"auth\",\"dep:openlark-security\"],\"websocket\":[\"tokio-tungstenite\",\"futures-util\",\"lark-websocket-protobuf\",\"url\",\"prost\",\"reqwest\",\"log\"]}}", + "openlark-communication_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"aily\":[],\"contact\":[],\"default\":[\"im\",\"contact\",\"moments\"],\"full\":[\"im\",\"contact\",\"moments\",\"aily\"],\"im\":[],\"moments\":[]}}", + "openlark-core_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"name\":\"http\",\"req\":\"^1.0\"},{\"name\":\"lark-websocket-protobuf\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"num_cpus\",\"req\":\"^1.16\"},{\"name\":\"openlark-protocol\",\"optional\":true,\"req\":\"^0.15.0-rc.2\"},{\"name\":\"opentelemetry\",\"optional\":true,\"req\":\"^0.24\"},{\"name\":\"opentelemetry-otlp\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"rt-tokio\"],\"name\":\"opentelemetry_sdk\",\"optional\":true,\"req\":\"^0.24\"},{\"name\":\"prost\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"quick_cache\",\"req\":\"^0.6.3\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_with\",\"req\":\"^3\"},{\"name\":\"sha2\",\"req\":\"^0.10.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"features\":[\"rustls-tls-native-roots\"],\"name\":\"tokio-tungstenite\",\"optional\":true,\"req\":\"^0.23\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"tracing-opentelemetry\",\"optional\":true,\"req\":\"^0.25\"},{\"features\":[\"env-filter\",\"json\"],\"name\":\"tracing-subscriber\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"env-filter\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"testing\"],\"otel\":[\"tracing-init\",\"opentelemetry\",\"opentelemetry_sdk\",\"opentelemetry-otlp\",\"tracing-opentelemetry\"],\"testing\":[\"tracing-init\"],\"tracing-init\":[\"tracing-subscriber\"],\"websocket\":[\"tokio-tungstenite\",\"prost\",\"openlark-protocol\",\"lark-websocket-protobuf\"]}}", + "openlark-docs_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"all-cloud-docs\":[\"full\"],\"baike\":[],\"base\":[\"core\"],\"bitable\":[\"core\"],\"ccm\":[\"ccm-core\",\"ccm-doc\",\"ccm-docx\",\"ccm-drive\",\"ccm-sheets\",\"ccm-wiki\"],\"ccm-core\":[],\"ccm-doc\":[\"ccm-core\"],\"ccm-docx\":[\"ccm-core\"],\"ccm-drive\":[\"ccm-core\"],\"ccm-sheets\":[\"ccm-sheets-v3\"],\"ccm-sheets-v3\":[\"ccm-core\"],\"ccm-wiki\":[\"ccm-core\"],\"cloud-docs\":[\"ccm\",\"bitable\",\"base\"],\"core\":[],\"default\":[],\"docs\":[\"ccm-doc\"],\"docx\":[\"ccm-docx\"],\"full\":[\"ccm\",\"bitable\",\"base\",\"baike\",\"minutes\",\"v3\"],\"lingo\":[],\"minutes\":[\"core\"],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"wiki\":[\"ccm-wiki\"]}}", + "openlark-helpdesk_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"default\":[\"v1\",\"async\"],\"full\":[\"v1\",\"async\"],\"v1\":[]}}", + "openlark-hr_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"attendance\":[],\"compensation\":[],\"corehr\":[],\"default\":[\"attendance\",\"corehr\",\"compensation\",\"payroll\",\"performance\",\"okr\",\"hire\",\"ehr\"],\"ehr\":[],\"hire\":[],\"hr-full\":[\"attendance\",\"corehr\",\"compensation\",\"payroll\",\"performance\",\"okr\",\"hire\",\"ehr\"],\"okr\":[],\"payroll\":[],\"performance\":[]}}", + "openlark-mail_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"default\":[\"v1\",\"async\"],\"full\":[\"v1\",\"async\"],\"v1\":[]}}", + "openlark-meeting_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1\"}],\"features\":{\"calendar\":[\"calendar-v4\"],\"calendar-v4\":[],\"default\":[\"vc\",\"calendar\"],\"full\":[\"vc\",\"calendar\",\"meeting-room\"],\"meeting-room\":[\"meeting-room-v1\"],\"meeting-room-v1\":[],\"vc\":[\"vc-v1\"],\"vc-v1\":[]}}", + "openlark-platform_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"admin\":[\"admin-core\",\"core\"],\"admin-core\":[],\"all-platform\":[\"full\"],\"app-engine\":[\"app-engine-core\",\"core\"],\"app-engine-core\":[],\"core\":[],\"default\":[\"app-engine\",\"directory\",\"admin\",\"mdm\",\"tenant\",\"trust_party\"],\"directory\":[\"directory-core\",\"core\"],\"directory-core\":[],\"full\":[\"app-engine\",\"directory\",\"admin\",\"mdm\",\"tenant\",\"trust_party\",\"v4\"],\"mdm\":[\"mdm-core\",\"core\"],\"mdm-core\":[],\"platform\":[\"app-engine\",\"directory\",\"admin\"],\"tenant\":[\"tenant-core\",\"core\"],\"tenant-core\":[],\"trust_party\":[\"trust_party-core\",\"core\"],\"trust_party-core\":[],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"v4\":[\"v3\"]}}", + "openlark-protocol_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1.6.0\"},{\"name\":\"prost\",\"req\":\"^0.13.1\"},{\"kind\":\"build\",\"name\":\"prost-build\",\"req\":\"^0.12.6\"}],\"features\":{}}", + "openlark-security_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"name\":\"sha2\",\"req\":\"^0.10.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"acs\":[\"auth\"],\"audit\":[\"core\"],\"auth\":[\"core\"],\"compliance\":[\"auth\"],\"core\":[],\"default\":[\"auth\",\"acs\"],\"full\":[\"auth\",\"acs\",\"audit\",\"token\",\"compliance\",\"v3\"],\"security\":[\"full\"],\"token\":[\"auth\"],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"]}}", + "openlark-user_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.86\"},{\"name\":\"async-trait\",\"req\":\"^0.1.83\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"name\":\"futures\",\"req\":\"^0.3.30\"},{\"name\":\"log\",\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"regex\",\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.5.0\"},{\"features\":[\"v4\",\"serde\"],\"name\":\"uuid\",\"req\":\"^1.6\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"all-user\":[\"full\"],\"core\":[],\"default\":[\"settings\",\"preferences\"],\"full\":[\"settings\",\"preferences\",\"v4\"],\"preferences\":[\"preferences-core\",\"core\"],\"preferences-core\":[],\"settings\":[\"settings-core\",\"core\"],\"settings-core\":[],\"user\":[\"settings\",\"preferences\"],\"v1\":[\"core\"],\"v2\":[\"v1\"],\"v3\":[\"v2\"],\"v4\":[\"v3\"]}}", + "openlark-webhook_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22.1\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.8\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"card\":[],\"default\":[\"robot\"],\"robot\":[],\"signature\":[\"hmac\",\"sha2\",\"base64\"]}}", + "openlark-workflow_0.15.0-rc.2": "{\"dependencies\":[{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.2\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"async\":[\"tokio\"],\"board\":[],\"default\":[\"v1\",\"v2\",\"async\",\"board\"],\"full\":[\"v1\",\"v2\",\"async\",\"board\"],\"v1\":[],\"v2\":[]}}", + "openlark_0.15.0-rc.1": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"colored\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.19\"},{\"name\":\"openlark-ai\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-analytics\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-application\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-auth\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-cardkit\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-client\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-communication\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-core\",\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-docs\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-helpdesk\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-hr\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-mail\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-meeting\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-platform\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-protocol\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-security\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-user\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-webhook\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"name\":\"openlark-workflow\",\"optional\":true,\"req\":\"^0.15.0-rc.1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"json\",\"multipart\",\"rustls-tls\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12.7\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.19\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.8\"},{\"kind\":\"dev\",\"name\":\"test-log\",\"req\":\"^0.2\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"wiremock\",\"req\":\"^0.6\"}],\"features\":{\"ai\":[\"client\",\"openlark-ai\"],\"analytics\":[\"client\",\"openlark-analytics\"],\"application\":[\"client\",\"openlark-application\"],\"auth\":[\"client\",\"openlark-auth\"],\"base\":[\"client\",\"openlark-docs\"],\"bitable\":[\"client\",\"openlark-docs\"],\"cardkit\":[\"client\",\"openlark-cardkit\"],\"client\":[\"openlark-client\"],\"communication\":[\"client\",\"openlark-communication\"],\"core-services\":[\"auth\",\"communication\",\"docs\",\"workflow\"],\"default\":[\"core-services\"],\"dev-tools\":[],\"docs\":[\"client\",\"openlark-docs\"],\"helpdesk\":[\"client\",\"openlark-helpdesk\"],\"hr\":[\"client\",\"openlark-hr\"],\"mail\":[\"client\",\"openlark-mail\"],\"meeting\":[\"client\",\"openlark-meeting\"],\"platform\":[\"client\",\"openlark-platform\"],\"protocol\":[\"openlark-protocol\"],\"security\":[\"client\",\"openlark-security\"],\"user\":[\"client\",\"openlark-user\"],\"webhook\":[\"client\",\"openlark-webhook\"],\"websocket\":[\"protocol\",\"openlark-client/websocket\"],\"workflow\":[\"client\",\"openlark-workflow\"]}}", "openssl-macros_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "openssl-probe_0.1.6": "{\"dependencies\":[],\"features\":{}}", "openssl-probe_0.2.1": "{\"dependencies\":[],\"features\":{}}", @@ -1169,7 +1190,13 @@ "proc-macro2_1.0.106": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"proc-macro\"],\"nightly\":[],\"proc-macro\":[],\"span-locations\":[]}}", "process-wrap_9.0.1": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.30\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"default_features\":false,\"features\":[\"fs\",\"poll\",\"signal\"],\"name\":\"nix\",\"optional\":true,\"req\":\"^0.30.1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"remoteprocess\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.20.0\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.38.2\"},{\"features\":[\"io-util\",\"macros\",\"process\",\"rt\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.38.2\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"name\":\"windows\",\"optional\":true,\"req\":\"^0.62.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"creation-flags\":[\"dep:windows\",\"windows/Win32_System_Threading\"],\"default\":[\"creation-flags\",\"job-object\",\"kill-on-drop\",\"process-group\",\"process-session\",\"tracing\"],\"job-object\":[\"dep:windows\",\"windows/Win32_Security\",\"windows/Win32_System_Diagnostics_ToolHelp\",\"windows/Win32_System_IO\",\"windows/Win32_System_JobObjects\",\"windows/Win32_System_Threading\"],\"kill-on-drop\":[],\"process-group\":[],\"process-session\":[\"process-group\"],\"reset-sigmask\":[],\"std\":[\"dep:nix\"],\"tokio1\":[\"dep:nix\",\"dep:futures\",\"dep:tokio\"],\"tracing\":[\"dep:tracing\"]}}", "proptest_1.9.0": "{\"dependencies\":[{\"name\":\"bit-set\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"bitflags\",\"req\":\"^2.9\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.15\"},{\"name\":\"proptest-macro\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"req\":\"^0.9\"},{\"name\":\"rand_xorshift\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rusty-fork\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"=1.0.112\"},{\"name\":\"unarray\",\"req\":\"^0.1.4\"},{\"name\":\"x86\",\"optional\":true,\"req\":\"^0.52.0\"}],\"features\":{\"alloc\":[],\"atomic64bit\":[],\"attr-macro\":[\"proptest-macro\"],\"bit-set\":[\"dep:bit-set\",\"dep:bit-vec\"],\"default\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"default-code-coverage\":[\"std\",\"fork\",\"timeout\",\"bit-set\"],\"fork\":[\"std\",\"rusty-fork\",\"tempfile\"],\"handle-panics\":[\"std\"],\"hardware-rng\":[\"x86\"],\"no_std\":[\"num-traits/libm\"],\"std\":[\"rand/std\",\"rand/os_rng\",\"regex-syntax\",\"num-traits/std\"],\"timeout\":[\"fork\",\"rusty-fork/timeout\"],\"unstable\":[]}}", + "prost-build_0.12.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"heck\",\"req\":\">=0.4, <=0.5\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.12\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"name\":\"multimap\",\"req\":\">=0.8, <=0.10\"},{\"name\":\"once_cell\",\"req\":\"^1.17.1\"},{\"default_features\":false,\"name\":\"petgraph\",\"req\":\"^0.6\"},{\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"prost\",\"req\":\"^0.12.6\"},{\"default_features\":false,\"name\":\"prost-types\",\"req\":\"^0.12.6\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.9.1\"},{\"name\":\"pulldown-cmark-to-cmark\",\"optional\":true,\"req\":\"^10.0.1\"},{\"default_features\":false,\"features\":[\"std\",\"unicode-bool\"],\"name\":\"regex\",\"req\":\"^1.8.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"cleanup-markdown\":[\"dep:pulldown-cmark\",\"dep:pulldown-cmark-to-cmark\"],\"default\":[\"format\"],\"format\":[\"dep:prettyplease\",\"dep:syn\"]}}", + "prost-derive_0.12.6": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\">=0.10, <=0.12\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "prost-derive_0.13.5": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"name\":\"itertools\",\"req\":\">=0.10.1, <=0.14\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "prost-derive_0.14.3": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.1\"},{\"name\":\"itertools\",\"req\":\">=0.10.1, <=0.14\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", + "prost-types_0.12.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"prost-derive\"],\"name\":\"prost\",\"req\":\"^0.12.6\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"prost/std\"]}}", + "prost_0.12.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.12.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"prost-derive\":[\"derive\"],\"std\":[]}}", + "prost_0.13.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.13.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"prost-derive\":[\"derive\"],\"std\":[]}}", "prost_0.14.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"prost-derive\",\"optional\":true,\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"derive\",\"std\"],\"derive\":[\"dep:prost-derive\"],\"no-recursion-limit\":[],\"std\":[]}}", "psl-types_2.0.11": "{\"dependencies\":[],\"features\":{}}", "psl_2.1.184": "{\"dependencies\":[{\"name\":\"psl-types\",\"req\":\"^2.0.11\"},{\"kind\":\"dev\",\"name\":\"rspec\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[\"helpers\"],\"helpers\":[]}}", @@ -1178,6 +1205,7 @@ "pxfm_0.1.27": "{\"dependencies\":[{\"name\":\"num-traits\",\"req\":\"^0.2.3\"}],\"features\":{}}", "quick-error_2.0.1": "{\"dependencies\":[],\"features\":{}}", "quick-xml_0.38.4": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\">=0.4, <0.8\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\">=1.0.139\"},{\"kind\":\"dev\",\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.206\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"}],\"features\":{\"async-tokio\":[\"tokio\"],\"default\":[],\"encoding\":[\"encoding_rs\"],\"escape-html\":[],\"overlapped-lists\":[],\"serde-types\":[\"serde/derive\"],\"serialize\":[\"serde\"]}}", + "quick_cache_0.6.21": "{\"dependencies\":[{\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"crossbeam-utils\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"equivalent\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"name\":\"shuttle\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{\"default\":[\"ahash\",\"parking_lot\"],\"sharded-lock\":[\"dep:crossbeam-utils\"],\"shuttle\":[\"dep:shuttle\"],\"stats\":[]}}", "quinn-proto_0.11.13": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"fastbloom\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"lru-slab\",\"req\":\"^0.1.2\"},{\"name\":\"qlog\",\"optional\":true,\"req\":\"^0.15.2\"},{\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"features\":[\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"features\":[\"web\"],\"name\":\"rustls-pki-types\",\"req\":\"^1.7\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"slab\",\"req\":\"^0.4.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"alloc\",\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs?/aws-lc-sys\",\"aws-lc-rs?/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"aws-lc-rs\",\"aws-lc-rs?/fips\"],\"bloom\":[\"dep:fastbloom\"],\"default\":[\"rustls-ring\",\"log\",\"bloom\"],\"log\":[\"tracing/log\"],\"platform-verifier\":[\"dep:rustls-platform-verifier\"],\"qlog\":[\"dep:qlog\"],\"ring\":[\"dep:ring\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"rustls?/aws-lc-rs\",\"aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"rustls-aws-lc-rs\",\"aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"ring\"]}}", "quinn-udp_0.5.14": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"libc\",\"req\":\"^0.2.158\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19\",\"target\":\"cfg(windows)\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.60\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"log\"],\"direct-log\":[\"dep:log\"],\"fast-apple-datapath\":[],\"log\":[\"tracing/log\"]}}", "quinn_0.11.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.22\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.11\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"crc\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"directories-next\",\"req\":\"^2\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.19\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"proto\",\"package\":\"quinn-proto\",\"req\":\"^0.11.12\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"smol\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"std-future\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"tracing\"],\"name\":\"udp\",\"package\":\"quinn-udp\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"aws-lc-rs\":[\"proto/aws-lc-rs\"],\"aws-lc-rs-fips\":[\"proto/aws-lc-rs-fips\"],\"bloom\":[\"proto/bloom\"],\"default\":[\"log\",\"platform-verifier\",\"runtime-tokio\",\"rustls-ring\",\"bloom\"],\"lock_tracking\":[],\"log\":[\"tracing/log\",\"proto/log\",\"udp/log\"],\"platform-verifier\":[\"proto/platform-verifier\"],\"qlog\":[\"proto/qlog\"],\"ring\":[\"proto/ring\"],\"runtime-async-std\":[\"async-io\",\"async-std\"],\"runtime-smol\":[\"async-io\",\"smol\"],\"runtime-tokio\":[\"tokio/time\",\"tokio/rt\",\"tokio/net\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"aws-lc-rs\",\"proto/rustls-aws-lc-rs\",\"proto/aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"dep:rustls\",\"aws-lc-rs-fips\",\"proto/rustls-aws-lc-rs-fips\",\"proto/aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"ring\",\"proto/rustls-ring\",\"proto/ring\"]}}", @@ -1249,7 +1277,9 @@ "rustix_0.38.44": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.161\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.161\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", target_arch = \\\"s390x\\\"), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.5.2\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\"))\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_NetworkManagement_IpHelper\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"procfs\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"cc\":[],\"default\":[\"std\",\"use-libc-auxv\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"linux-raw-sys/io_uring\"],\"libc-extra-traits\":[\"libc?/extra_traits\"],\"linux_4_11\":[],\"linux_latest\":[\"linux_4_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[\"fs\"],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"procfs\":[\"once_cell\",\"itoa\",\"fs\"],\"pty\":[\"itoa\",\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"compiler_builtins\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\",\"compiler_builtins?/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\",\"libc-extra-traits\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\",\"libc-extra-traits\"],\"use-libc-auxv\":[]}}", "rustix_1.1.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.177\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.11.0\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", + "rustls-native-certs_0.7.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.1.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.16\"}],\"features\":{}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", + "rustls-pemfile_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.9\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"pki-types/std\"]}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", "rustls-webpki_0.103.10": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", @@ -1407,6 +1437,7 @@ "tokio-rustls_0.26.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.1\"},{\"features\":[\"pem\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"rustls/aws_lc_rs\"],\"brotli\":[\"rustls/brotli\"],\"default\":[\"logging\",\"tls12\",\"aws_lc_rs\"],\"early-data\":[],\"fips\":[\"rustls/fips\"],\"logging\":[\"rustls/logging\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"rustls/tls12\"],\"zlib\":[\"rustls/zlib\"]}}", "tokio-stream_0.1.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"}],\"features\":{\"default\":[\"time\"],\"fs\":[\"tokio/fs\"],\"full\":[\"time\",\"net\",\"io-util\",\"fs\",\"sync\",\"signal\"],\"io-util\":[\"tokio/io-util\"],\"net\":[\"tokio/net\"],\"signal\":[\"tokio/signal\"],\"sync\":[\"tokio/sync\",\"tokio-util\"],\"time\":[\"tokio/time\"]}}", "tokio-test_0.4.5": "{\"dependencies\":[{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"features\":[\"rt\",\"sync\",\"time\",\"test-util\"],\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"}],\"features\":{}}", + "tokio-tungstenite_0.23.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.11\"},{\"default_features\":false,\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27.0\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26.0\"},{\"default_features\":false,\"name\":\"tungstenite\",\"req\":\"^0.23.0\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26.0\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]}}", "tokio-util_0.7.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.44.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", "tokio_1.49.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", "toml_0.5.11": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.97\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[],\"preserve_order\":[\"indexmap\"]}}", @@ -1442,6 +1473,7 @@ "try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}", "ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}", "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", + "tungstenite_0.23.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.3.2\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.3.4\"},{\"kind\":\"dev\",\"name\":\"input_buffer\",\"req\":\"^0.5.0\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.3\"},{\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.5.5\"},{\"name\":\"thiserror\",\"req\":\"^1.0.23\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"utf-8\",\"req\":\"^0.7.5\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]}}", "two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}", "type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}", "typenum_1.19.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", diff --git a/README.md b/README.md index 1e44875f2..318f0a0eb 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,196 @@ -

npm i -g @openai/codex
or brew install --cask codex

-

Codex CLI is a coding agent from OpenAI that runs locally on your computer. -

- Codex CLI splash -

-
-If you want Codex in your code editor (VS Code, Cursor, Windsurf), install in your IDE. -
If you want the desktop app experience, run codex app or visit the Codex App page. -
If you are looking for the cloud-based agent from OpenAI, Codex Web, go to chatgpt.com/codex.

- ---- +
+ +# Codex Enhanced + +> The Codex distribution built for real 24/7 use. + +[中文版本](./README.zh-CN.md) · [Website](https://codex-enhanced.com) · [Workflows](./docs/workflows.md) · [Slash Commands](./docs/slash_commands.md) · [Structured Input UI](./docs/tui-request-user-input.md) + +
+ +`codex-enhanced` is a Codex distribution built on top of the OpenAI Codex CLI Rust stack. It is focused less on prompt theater and more on turning Codex into an operator surface that can stay online across accounts, sessions, workflows, and external message channels. + +If you need a terminal chatbot, the base Codex experience is already strong. This distribution is for the next step: keeping Codex running as a controllable workspace system instead of a single ephemeral chat loop. + +## Why codex-enhanced + +Most AI CLI projects compete on model access and UI polish. + +`codex-enhanced` moves the investment somewhere else: + +- multi-subscription account management instead of manual env switching +- multi-profile routing and fallback instead of a single fragile default +- resumable sessions instead of repeated context rebuilds +- workflow triggers and background jobs instead of one-turn-at-a-time operation +- Feishu bridge entrypoints instead of terminal-only interaction +- lower-noise operator UX instead of more surface-level prompt ceremony + +That is the core idea: make Codex feel less like a terminal chatbot and more like a persistent control surface. + +## What You Can Do + +| Capability | Entry point | What it enables | +| --- | --- | --- | +| Multi-profile routing | `/profile` | Switch named profiles at runtime, manage fallback routes, and recover from rate limits or auth failures without rewriting local environment state. | +| Workflow orchestration | `/workflow` | Manage `.codex/workflows/*.yaml`, run jobs manually, and attach triggers such as `before_turn`, `after_turn`, `interval`, `cron`, and `file_watch`. | +| Session insight report | `/insight` | Scan local Codex sessions and generate an offline HTML report under `~/.codex/reports/` for rollout analysis and drill-down. | +| Session continuity | `/resume` | Reconnect to saved work instead of reconstructing long-running context from scratch. | +| External message bridge | `/clawbot` | Bind workspace-local Feishu sessions to Codex threads, capture unread messages, and forward final replies back out. | +| UI and alignment control | `/settings`, `question`, keyboard chords | Reduce noise, collect structured answers in the TUI, and keep operator interactions explicit. | ## Quickstart -### Installing and running Codex CLI +Install from PyPI: -Install globally with your preferred package manager: +```bash +pip3 install -U codex-enhanced +codex-enhanced +``` + +Run from source in this repository: -```shell -# Install using npm -npm install -g @openai/codex +```bash +just codex ``` -```shell -# Install using Homebrew -brew install --cask codex +`just codex` runs `cargo run --bin codex -- ...` from the `codex-rs` workspace, which is the fastest way to inspect or develop the Rust TUI locally. + +## Typical Operator Flows + +### 1. Route traffic across multiple accounts and profiles + +`/profile` opens a dedicated management panel for: + +- named profiles +- runtime switching +- fallback routes +- switching policies for rate limits, auth failures, and service overload + +This is the difference between "change a key and restart the tool" and "keep the operator surface online." + +### 2. Turn conversations into jobs + +`/workflow` manages workflow definitions directly from `.codex/workflows/*.yaml`. + +Supported trigger families include: + +- `before_turn` +- `after_turn` +- `manual` +- `file_watch` +- `idle` +- `interval` +- `cron` + +That lets Codex participate in repeatable automation loops instead of only answering the current prompt. + +Minimal example: + +```yaml +name: director + +triggers: + - id: pulse + type: interval + every: 30m + enabled: true + jobs: [notify] + +jobs: + notify: + enabled: true + context: ephemeral + response: assistant + steps: + - prompt: | + Send a concise update on the current workspace state. ``` -Then simply run `codex` to get started. +Documentation: + +- [`docs/workflows.md`](./docs/workflows.md) + +### 3. Resume work instead of restarting it + +`/resume` and the underlying thread/session plumbing let you reconnect to saved work instead of paying the cost of rebuilding context every time a session is interrupted. + +This matters once Codex is doing real work over hours or days rather than a single short exchange. + +### 4. Bring Feishu into the same loop + +`/clawbot` connects workspace-local Feishu sessions, thread binding, unread message queues, and reply forwarding into the same runtime. + +In practice, that means: + +- a Feishu chat can be bound to the current Codex thread +- inbound messages can enter the active workspace loop +- final replies can be sent back to Feishu +- session and binding state stays local to the workspace runtime + +## What Ships Today + +These capabilities are already implemented in the repository: + +- multi-subscription account management and runtime account display +- multi-profile API management and `/profile` route switching +- `/workflow` task orchestration +- `/resume` for saved sessions +- `/settings` for UI information control +- `/clawbot` for Feishu send and receive flows +- stronger `question`-based alignment interactions +- keyboard chord support +- PyPI packaging and release flow for `codex-enhanced` + +## Inspectable by Design + +This project is intentionally built so the important runtime state stays visible: + +- profile routing state lives in `accounts/profile-router.json` +- workflows live in `.codex/workflows/*.yaml` +- clawbot state is stored under `.codex/clawbot/` +- structured operator answers are collected through the TUI `question` flow instead of being guessed from ambiguous free text + +This distribution is opinionated, but it is not trying to hide the system from the operator. + +## Repository Map + +If you want to inspect or extend the project, start here: + +- [`codex-rs/`](./codex-rs) contains the Rust workspace, including the CLI, TUI, workflow support, app-server pieces, and clawbot integration +- [`sdk/python-runtime-enhanced/`](./sdk/python-runtime-enhanced) contains the Python wheel packaging for `codex-enhanced` +- [`docs/workflows.md`](./docs/workflows.md) explains workflow files, triggers, and job management +- [`docs/slash_commands.md`](./docs/slash_commands.md) documents TUI slash commands including `/insight` +- [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) explains the structured input overlay used for `question` + +## Capability Boundaries + +### What it is built to solve -
-You can also go to the latest GitHub Release and download the appropriate binary for your platform. +This distribution is strong at connecting the agent to real operating workflows. -Each GitHub Release contains many executables, but in practice, you likely want one of these: +Current strengths: -- macOS - - Apple Silicon/arm64: `codex-aarch64-apple-darwin.tar.gz` - - x86_64 (older Mac hardware): `codex-x86_64-apple-darwin.tar.gz` -- Linux - - x86_64: `codex-x86_64-unknown-linux-musl.tar.gz` - - arm64: `codex-aarch64-unknown-linux-musl.tar.gz` +- multi-account and multi-profile routing +- long-session recovery and continuity +- workspace-local workflow orchestration +- Feishu clawbot integration +- local TUI information shaping and visibility control +- stronger alignment flows for human-in-the-loop operation -Each archive contains a single entry with the platform baked into the name (e.g., `codex-x86_64-unknown-linux-musl`), so you likely want to rename it to `codex` after extracting it. +### What it is not trying to be -
+- a replacement for every official hosted or distributed Codex surface +- a general-purpose IM automation hub beyond the current Feishu focus +- a zero-configuration black box for business workflow automation -### Using Codex with your ChatGPT plan +## Project Attribution -Run `codex` and select **Sign in with ChatGPT**. We recommend signing into your ChatGPT account to use Codex as part of your Plus, Pro, Team, Edu, or Enterprise plan. [Learn more about what's included in your ChatGPT plan](https://help.openai.com/en/articles/11369540-codex-in-chatgpt). +This project builds on the OpenAI Codex CLI Rust, TUI, and app-server foundation, then pushes harder on the parts that matter in sustained use: account operations, session continuity, workflows, Feishu entrypoints, lower-noise UI, and operator ergonomics. -You can also use Codex with an API key, but this requires [additional setup](https://developers.openai.com/codex/auth#sign-in-with-an-api-key). +If you only need a Codex that chats in a terminal, the official distribution is already enough. -## Docs +If you need a Codex that can stay online across accounts, inputs, tasks, and long-running sessions, that is the point of this distribution. -- [**Codex Documentation**](https://developers.openai.com/codex) -- [**Contributing**](./docs/contributing.md) -- [**Installing & building**](./docs/install.md) -- [**Open source fund**](./docs/open-source-fund.md) +## License -This repository is licensed under the [Apache-2.0 License](LICENSE). +Apache-2.0 diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 000000000..4d5cc61be --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,196 @@ +
+ +# Codex Enhanced + +> 真正 24/7 使用的 Codex 发行版。 + +[English](./README.md) · [Website](https://codex-enhanced.com) · [工作流文档](./docs/workflows.md) · [Slash Command 文档](./docs/slash_commands.md) · [结构化输入 UI](./docs/tui-request-user-input.md) + +
+ +`codex-enhanced` 是一个基于 OpenAI Codex CLI Rust 技术栈继续演进的 Codex 发行版。它的重点不是再包一层 prompt,而是把 Codex 变成一个可以跨账户、跨会话、跨工作流、跨外部消息入口持续在线的 operator surface。 + +如果你只需要一个终端聊天工具,基础 Codex 体验已经足够强。这个发行版要解决的是下一层问题:让 Codex 不只是一次性的对话循环,而是一个可持续运转的工作台。 + +## 为什么是 codex-enhanced + +大多数 AI CLI 项目主要在卷模型接入和 UI 打磨。 + +`codex-enhanced` 把工程投入放在另一侧: + +- 多 subscription 账户管理,而不是手动切环境变量 +- 多 profile 路由和 fallback,而不是只有一个脆弱默认值 +- 会话续接,而不是反复重建上下文 +- 工作流触发器和后台任务,而不是永远一问一答 +- 飞书桥接入口,而不是只接受终端输入 +- 更低噪音的 operator UX,而不是继续堆 prompt 仪式感 + +核心目标只有一个:让 Codex 更像持续在线的控制台,而不是终端里的聊天框。 + +## 你可以做什么 + +| 能力 | 入口 | 能解决什么 | +| --- | --- | --- | +| 多 profile 路由 | `/profile` | 在运行时切换命名 profile、管理 fallback route,并在限流或鉴权失败时继续运转,而不是改完配置再重启。 | +| 工作流编排 | `/workflow` | 直接管理 `.codex/workflows/*.yaml`,手动运行 job,或者挂接 `before_turn`、`after_turn`、`interval`、`cron`、`file_watch` 等触发器。 | +| 会话洞察报告 | `/insight` | 扫描本地 Codex session,并在 `~/.codex/reports/` 下生成离线 HTML 分析报告,便于回看 rollout 和逐层钻取。 | +| 会话连续性 | `/resume` | 把保存过的工作续上,而不是每次从零重建长上下文。 | +| 外部消息桥接 | `/clawbot` | 把 workspace-local 的飞书会话绑定到 Codex thread,接收未读消息并把最终回复发回外部。 | +| UI 与对齐控制 | `/settings`、`question`、键盘 chord | 降低界面噪音,在 TUI 中收集结构化答案,并让人工参与点更明确。 | + +## Quickstart + +通过 PyPI 安装: + +```bash +pip3 install -U codex-enhanced +codex-enhanced +``` + +在当前仓库里直接从源码运行: + +```bash +just codex +``` + +`just codex` 实际执行的是 `codex-rs` 工作区下的 `cargo run --bin codex -- ...`,这是本地调试和验证 Rust TUI 的最快路径。 + +## 典型使用流 + +### 1. 在多个账户和 profile 之间路由流量 + +`/profile` 会打开独立管理面板,支持: + +- 命名 profile +- 当前 runtime 切换 +- fallback route +- 在限流、鉴权失败、服务过载时按策略切换 profile + +这和“改个 key 然后重开工具”是两种完全不同的操作体验。 + +### 2. 把对话变成可编排 job + +`/workflow` 直接管理 `.codex/workflows/*.yaml` 中的工作流定义。 + +目前支持的触发类型包括: + +- `before_turn` +- `after_turn` +- `manual` +- `file_watch` +- `idle` +- `interval` +- `cron` + +这意味着 Codex 不只是响应当前输入,还可以进入可重复执行的自动化闭环。 + +最小示例: + +```yaml +name: director + +triggers: + - id: pulse + type: interval + every: 30m + enabled: true + jobs: [notify] + +jobs: + notify: + enabled: true + context: ephemeral + response: assistant + steps: + - prompt: | + Send a concise update on the current workspace state. +``` + +相关文档: + +- [`docs/workflows.md`](./docs/workflows.md) + +### 3. 续上工作,而不是反复重开 + +`/resume` 和底层的 thread/session 基础设施允许你把保存过的工作重新接起来,而不是每次都付出重建上下文的成本。 + +当 Codex 开始承担按小时或按天推进的任务时,这一点会非常重要。 + +### 4. 把飞书接进同一个闭环 + +`/clawbot` 把 workspace-local 的飞书会话、线程绑定、未读消息队列和回复回传放进同一个运行时。 + +具体来说,它支持: + +- 把飞书会话绑定到当前 Codex thread +- 让外部消息进入当前 workspace 的执行闭环 +- 把最终回复发回飞书 +- 让 session 和 binding 状态保持在 workspace 本地 + +## 当前已经落地的能力 + +下面这些能力都已经在仓库中实现: + +- 多 subscription 账户管理和 runtime account 展示 +- 多 profile API 管理和 `/profile` 路由切换 +- `/workflow` 任务编排 +- `/resume` 恢复已保存会话 +- `/settings` 控制 UI 展示信息 +- `/clawbot` 对接飞书收发消息 +- 更强的 `question` 式对齐交互 +- 键盘 chord 支持 +- `codex-enhanced` 的 PyPI 打包与发布流程 + +## 可观测设计 + +这个项目有意把关键状态保持为可检查、可定位的本地文件: + +- profile 路由状态保存在 `accounts/profile-router.json` +- workflow 定义直接保存在 `.codex/workflows/*.yaml` +- clawbot 相关状态保存在 `.codex/clawbot/` +- operator 的结构化回答通过 TUI 中的 `question` 流收集,而不是靠自由文本猜测 + +它是有观点的,但不是黑盒。 + +## 仓库导览 + +如果你要继续查看实现或扩展能力,可以从这些位置开始: + +- [`codex-rs/`](./codex-rs) 是 Rust 工作区,包含 CLI、TUI、workflow、app-server 和 clawbot 集成 +- [`sdk/python-runtime-enhanced/`](./sdk/python-runtime-enhanced) 是 `codex-enhanced` 的 Python wheel 打包目录 +- [`docs/workflows.md`](./docs/workflows.md) 说明 workflow 文件、trigger 和 job 管理方式 +- [`docs/slash_commands.md`](./docs/slash_commands.md) 说明 TUI slash commands,包括 `/insight` +- [`docs/tui-request-user-input.md`](./docs/tui-request-user-input.md) 说明 `question` 使用的结构化输入浮层 + +## 能力边界 + +### 它主要解决什么 + +这个发行版的强项,是把 agent 接进真实可操作的工作流。 + +当前重点能力: + +- 多账户和多 profile 路由 +- 长会话恢复与连续性 +- workspace-local workflow orchestration +- Feishu clawbot 集成 +- 本地 TUI 信息展示裁剪和可见性控制 +- 更强的人在环对齐交互 + +### 它不打算解决什么 + +- 替代官方原版的全部托管和分发形态 +- 在飞书之外直接变成通用 IM 自动化中台 +- 把业务流程自动化做成零配置黑盒 + +## 项目说明 + +这个项目不是从零开始。它建立在 OpenAI Codex CLI 的 Rust、TUI 和 app-server 基础之上,然后把精力进一步放到长期使用更痛的部分:账户运营、会话连续性、workflow、飞书入口、更低噪音的 UI 和 operator ergonomics。 + +如果你只需要一个在终端里聊天的 Codex,官方原版已经够用。 + +如果你需要一个能长期在线,能跨账户、跨入口、跨任务持续运转的 Codex,这就是这个发行版存在的理由。 + +## License + +Apache-2.0 diff --git a/codex-rs/.gitignore b/codex-rs/.gitignore index e31566047..b8563bddf 100644 --- a/codex-rs/.gitignore +++ b/codex-rs/.gitignore @@ -6,3 +6,5 @@ # Value of CARGO_TARGET_DIR when using .devcontainer/devcontainer.json. /target-arm64/ + +.codex/ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5cc4fe2ee..4d67a13ec 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -448,7 +448,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -800,7 +800,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tower", "tower-layer", "tower-service", @@ -1385,10 +1385,10 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-test", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "tracing", - "tungstenite", + "tungstenite 0.27.0", "url", "wiremock", ] @@ -1452,7 +1452,7 @@ dependencies = [ "tempfile", "time", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "toml 0.9.11+spec-1.1.0", "tracing", @@ -1477,7 +1477,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "toml 0.9.11+spec-1.1.0", "tracing", "url", @@ -1527,7 +1527,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "tungstenite", + "tungstenite 0.27.0", "url", "uuid", ] @@ -1616,6 +1616,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-clawbot" +version = "0.0.0" +dependencies = [ + "anyhow", + "openlark", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml 0.9.11+spec-1.1.0", +] + [[package]] name = "codex-cli" version = "0.0.0" @@ -1684,7 +1698,7 @@ dependencies = [ "rand 0.9.2", "reqwest", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -1935,7 +1949,7 @@ dependencies = [ "test-log", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "toml 0.9.11+spec-1.1.0", "toml_edit 0.24.0+spec-1.1.0", @@ -2059,7 +2073,7 @@ dependencies = [ "test-case", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", ] @@ -2469,7 +2483,7 @@ dependencies = [ "strum_macros 0.28.0", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -2781,6 +2795,7 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-chatgpt", + "codex-clawbot", "codex-cli", "codex-cloud-requirements", "codex-config", @@ -2807,6 +2822,7 @@ dependencies = [ "codex-utils-fuzzy-match", "codex-utils-oss", "codex-utils-pty", + "codex-utils-rustls-provider", "codex-utils-sandbox-summary", "codex-utils-sleep-inhibitor", "codex-utils-string", @@ -2818,11 +2834,14 @@ dependencies = [ "diffy", "dirs", "dunce", + "humantime", "image", + "indexmap 2.13.0", "insta", "itertools 0.14.0", "lazy_static", "libc", + "notify", "pathdiff", "pretty_assertions", "pulldown-cmark", @@ -2834,6 +2853,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "serde_yaml", "serial_test", "shlex", "strum 0.27.2", @@ -3317,7 +3337,7 @@ dependencies = [ "shlex", "tempfile", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -3956,7 +3976,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4201,7 +4221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5035,6 +5055,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "1.8.1" @@ -5068,7 +5094,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", @@ -5122,7 +5148,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -5413,9 +5439,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -5426,8 +5452,8 @@ dependencies = [ "num-traits", "png", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", + "zune-core", + "zune-jpeg", ] [[package]] @@ -5628,7 +5654,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5849,6 +5875,17 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" +[[package]] +name = "lark-websocket-protobuf" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79d4b1f8c37d37efd353c1b116df0f98ff21c8bcb216f67ab026dd2fd2b9ab81" +dependencies = [ + "bytes", + "prost 0.13.5", + "prost-build", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -6233,9 +6270,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -6406,7 +6443,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6582,7 +6619,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -6852,6 +6889,113 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openlark" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad168187bbb6fc22aa95df6c684b4d1d3345ca36c16f342270e75dbf869b3d8" +dependencies = [ + "chrono", + "openlark-auth", + "openlark-client", + "openlark-communication", + "openlark-core", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "openlark-auth" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fee5a868321d6307fbbda4dd3a127a50b2d90375072bdbc5cbd1e633a545db4" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "hex", + "openlark-core", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "openlark-client" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da8bcf21d0d5e5beef862a29c794679d50d5d376e9f9ea4a851e081356628910" +dependencies = [ + "chrono", + "futures-util", + "lark-websocket-protobuf", + "log", + "openlark-auth", + "openlark-communication", + "openlark-core", + "prost 0.13.5", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.23.1", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openlark-communication" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d102d0ad5f63ebbe729e7f513bce18cb0e45bd0da6efb346e8b281ea5b619b1f" +dependencies = [ + "openlark-core", + "reqwest", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "openlark-core" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336a10748627ee7c57fc2d80d686b210065670bc7c01a17cacd6e85fd7548975" +dependencies = [ + "base64 0.22.1", + "chrono", + "futures-util", + "hmac", + "http 1.4.0", + "num_cpus", + "quick_cache", + "rand 0.8.5", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_with", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "uuid", +] + [[package]] name = "openssl" version = "0.10.75" @@ -6962,7 +7106,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "reqwest", "serde_json", "thiserror 2.0.18", @@ -6981,7 +7125,7 @@ dependencies = [ "const-hex", "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.3", "serde", "serde_json", "tonic", @@ -7050,7 +7194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -7501,6 +7645,26 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive 0.12.6", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -7508,7 +7672,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck", + "itertools 0.10.5", + "log", + "multimap", + "once_cell", + "petgraph 0.6.5", + "prettyplease", + "prost 0.12.6", + "prost-types", + "regex", + "syn 2.0.114", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -7524,6 +7735,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", +] + [[package]] name = "psl" version = "2.1.184" @@ -7583,6 +7803,18 @@ dependencies = [ "serde", ] +[[package]] +name = "quick_cache" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -7596,7 +7828,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -7633,9 +7865,9 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7933,7 +8165,7 @@ dependencies = [ "rama-utils", "rcgen", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", @@ -8238,12 +8470,13 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", @@ -8471,7 +8704,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8490,6 +8723,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" @@ -8502,6 +8748,15 @@ dependencies = [ "security-framework 3.5.1", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -9905,7 +10160,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10103,16 +10358,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -10291,6 +10546,22 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "tokio-tungstenite" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.23.0", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -10299,11 +10570,11 @@ dependencies = [ "futures-util", "log", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.27.0", ] [[package]] @@ -10411,7 +10682,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "sync_wrapper", "tokio", "tokio-rustls", @@ -10429,7 +10700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -10687,6 +10958,26 @@ dependencies = [ "termcolor", ] +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.27.0" @@ -11351,7 +11642,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -12333,34 +12624,19 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 16329276c..865dd8c02 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -20,6 +20,7 @@ members = [ "cloud-tasks-client", "cloud-tasks-mock-client", "cli", + "clawbot", "collaboration-mode-templates", "connectors", "config", @@ -115,6 +116,7 @@ codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } +codex-clawbot = { path = "clawbot" } codex-client = { path = "codex-client" } codex-collaboration-mode-templates = { path = "collaboration-mode-templates" } codex-cloud-requirements = { path = "cloud-requirements" } @@ -235,7 +237,7 @@ icu_decimal = "2.1" icu_locale_core = "2.1" icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" -image = { version = "^0.25.9", default-features = false } +image = { version = "^0.25.10", default-features = false } include_dir = "0.7.4" indexmap = "2.12.0" insta = "1.46.3" diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c24c3fd7e..64f788548 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -994,9 +994,9 @@ Order of messages: UI guidance for IDEs: surface an approval dialog as soon as the request arrives. The turn will proceed after the server receives a response to the approval request. The terminal `item/completed` notification will be sent with the appropriate status. -### request_user_input +### question / request_user_input -When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup. +When the client responds to `item/tool/requestUserInput`, whether it originated from the `question` tool or the legacy `request_user_input` tool, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup. ### MCP server elicitations diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b99d1cb73..126a0043b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -586,7 +586,11 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { let payload = request.body_json(); assert_eq!(payload["model"].as_str(), Some("mock-model-collab")); let payload_text = payload.to_string(); - assert!(payload_text.contains("The `request_user_input` tool is available in Default mode.")); + assert!( + payload_text.contains( + "The `question` and `request_user_input` tools are available in Default mode." + ) + ); Ok(()) } @@ -671,7 +675,11 @@ async fn turn_start_uses_thread_feature_overrides_for_collaboration_mode_instruc let request = response_mock.single_request(); let payload_text = request.body_json().to_string(); - assert!(payload_text.contains("The `request_user_input` tool is available in Default mode.")); + assert!( + payload_text.contains( + "The `question` and `request_user_input` tools are available in Default mode." + ) + ); Ok(()) } diff --git a/codex-rs/clawbot/BUILD.bazel b/codex-rs/clawbot/BUILD.bazel new file mode 100644 index 000000000..f559c0e78 --- /dev/null +++ b/codex-rs/clawbot/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "clawbot", + crate_name = "codex_clawbot", +) diff --git a/codex-rs/clawbot/Cargo.toml b/codex-rs/clawbot/Cargo.toml new file mode 100644 index 000000000..f69f99455 --- /dev/null +++ b/codex-rs/clawbot/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "codex-clawbot" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_clawbot" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +openlark = { version = "0.15.0", default-features = false, features = ["auth", "communication", "websocket"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt", "sync", "time"] } +toml = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/clawbot/src/bin/feishu-payload-dump.rs b/codex-rs/clawbot/src/bin/feishu-payload-dump.rs new file mode 100644 index 000000000..b981256d7 --- /dev/null +++ b/codex-rs/clawbot/src/bin/feishu-payload-dump.rs @@ -0,0 +1,174 @@ +use std::env; +use std::fs; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_clawbot::CLAWBOT_RELATIVE_DIR; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::FeishuConfig; +use open_lark::openlark_client; +use open_lark::openlark_client::ws_client::EventDispatcherHandler; +use open_lark::openlark_client::ws_client::LarkWsClient; +use serde_json::Value; +use tokio::runtime::Builder; +use tokio::sync::mpsc; + +const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(2); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); +const DEFAULT_DUMP_FILE_NAME: &str = "feishu_payload_dump.jsonl"; + +fn main() -> Result<()> { + Builder::new_current_thread() + .enable_all() + .build() + .context("failed to build tokio runtime")? + .block_on(async_main()) +} + +async fn async_main() -> Result<()> { + let workspace_root = workspace_root_from_args()?; + let runtime = ClawbotRuntime::load(workspace_root.clone()).with_context(|| { + format!( + "failed to load clawbot config from {}", + workspace_root.display() + ) + })?; + let feishu = runtime + .snapshot() + .config + .feishu + .clone() + .context("missing [feishu] config in .codex/clawbot/config.toml")?; + if !feishu.has_api_credentials() { + return Err(anyhow!( + "missing app_id/app_secret in clawbot feishu config" + )); + } + + let dump_path = dump_path_from_args(&workspace_root); + ensure_parent_dir(&dump_path)?; + eprintln!("workspace: {}", workspace_root.display()); + eprintln!("dump file: {}", dump_path.display()); + + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; + loop { + match run_once(&feishu, &dump_path).await { + Ok(()) => { + eprintln!( + "feishu websocket exited; reconnecting in {}s", + reconnect_delay.as_secs() + ); + } + Err(err) => { + eprintln!( + "feishu websocket failed: {err}; reconnecting in {}s", + reconnect_delay.as_secs() + ); + } + } + + tokio::time::sleep(reconnect_delay).await; + reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); + } +} + +async fn run_once(feishu: &FeishuConfig, dump_path: &Path) -> Result<()> { + let ws_config = Arc::new(build_websocket_config(feishu)?); + let (payload_tx, mut payload_rx) = mpsc::unbounded_channel::>(); + let dump_path = dump_path.to_path_buf(); + let payload_task = tokio::spawn(async move { + while let Some(payload) = payload_rx.recv().await { + if let Err(err) = append_payload_record(&dump_path, &payload) { + eprintln!("failed to write payload record: {err}"); + } + } + }); + + let event_handler = EventDispatcherHandler::builder() + .payload_sender(payload_tx) + .build(); + eprintln!("feishu websocket connected; waiting for events..."); + let result = LarkWsClient::open(ws_config, event_handler) + .await + .map_err(|err| anyhow!("websocket runtime failed: {err}")); + payload_task.abort(); + let _ = payload_task.await; + result +} + +fn workspace_root_from_args() -> Result { + Ok(match env::args().nth(1) { + Some(path) => PathBuf::from(path), + None => env::current_dir().context("failed to resolve current directory")?, + }) +} + +fn dump_path_from_args(workspace_root: &Path) -> PathBuf { + env::args().nth(2).map_or_else( + || { + workspace_root + .join(CLAWBOT_RELATIVE_DIR) + .join(DEFAULT_DUMP_FILE_NAME) + }, + PathBuf::from, + ) +} + +fn ensure_parent_dir(path: &Path) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("path has no parent: {}", path.display()))?; + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display())) +} + +fn build_websocket_config(feishu: &FeishuConfig) -> Result { + openlark_client::Config::builder() + .app_id(feishu.app_id.clone()) + .app_secret(feishu.app_secret.clone()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|err| anyhow!("failed to build websocket config: {err}")) +} + +fn append_payload_record(dump_path: &Path, payload: &[u8]) -> Result<()> { + let raw_text = String::from_utf8_lossy(payload).into_owned(); + let parsed_payload = serde_json::from_slice::(payload).ok(); + let event_type = parsed_payload + .as_ref() + .and_then(|value| value.pointer("/header/event_type")) + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let recorded_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX_EPOCH")? + .as_secs() as i64; + let record = match parsed_payload { + Some(payload) => serde_json::json!({ + "recorded_at": recorded_at, + "event_type": event_type, + "payload": payload, + }), + None => serde_json::json!({ + "recorded_at": recorded_at, + "event_type": event_type, + "payload_text": raw_text, + }), + }; + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(dump_path) + .with_context(|| format!("failed to open {}", dump_path.display()))?; + writeln!(file, "{}", serde_json::to_string(&record)?) + .with_context(|| format!("failed to append {}", dump_path.display())) +} diff --git a/codex-rs/clawbot/src/config.rs b/codex-rs/clawbot/src/config.rs new file mode 100644 index 000000000..4c07757a5 --- /dev/null +++ b/codex-rs/clawbot/src/config.rs @@ -0,0 +1,79 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ClawbotTurnMode { + #[default] + Interactive, + NonInteractive, +} + +impl ClawbotTurnMode { + pub fn uses_noninteractive_prompt_handling(self) -> bool { + matches!(self, Self::NonInteractive) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct ClawbotConfig { + pub feishu: Option, + pub turn_mode: ClawbotTurnMode, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct FeishuConfig { + pub app_id: String, + pub app_secret: String, + pub verification_token: Option, + pub encrypt_key: Option, + pub bot_open_id: Option, + pub bot_user_id: Option, +} + +impl FeishuConfig { + pub fn has_api_credentials(&self) -> bool { + !self.app_id.trim().is_empty() && !self.app_secret.trim().is_empty() + } + + pub fn is_bot_sender( + &self, + open_id: Option<&str>, + user_id: Option<&str>, + app_id: Option<&str>, + ) -> bool { + self.bot_open_id + .as_deref() + .zip(open_id) + .is_some_and(|(bot_open_id, sender_open_id)| bot_open_id == sender_open_id) + || self + .bot_user_id + .as_deref() + .zip(user_id) + .is_some_and(|(bot_user_id, sender_user_id)| bot_user_id == sender_user_id) + || app_id.is_some_and(|sender_app_id| sender_app_id == self.app_id) + } + + pub fn is_empty(&self) -> bool { + self.app_id.trim().is_empty() + && self.app_secret.trim().is_empty() + && self + .verification_token + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && self + .encrypt_key + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && self + .bot_open_id + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + && self + .bot_user_id + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + } +} diff --git a/codex-rs/clawbot/src/diagnostics.rs b/codex-rs/clawbot/src/diagnostics.rs new file mode 100644 index 000000000..bd32bfe1d --- /dev/null +++ b/codex-rs/clawbot/src/diagnostics.rs @@ -0,0 +1,50 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; +use serde::Serialize; + +use crate::model::CLAWBOT_DIAGNOSTICS_RELATIVE_PATH; + +#[derive(Debug, Serialize)] +struct DiagnosticEvent { + ts_ms: i64, + kind: String, + payload: T, +} + +pub fn append_diagnostic_event(workspace_root: &Path, kind: &str, payload: T) -> Result<()> +where + T: Serialize, +{ + let path = workspace_root.join(CLAWBOT_DIAGNOSTICS_RELATIVE_PATH); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let event = DiagnosticEvent { + ts_ms: unix_timestamp_ms_now()?, + kind: kind.to_string(), + payload, + }; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + serde_json::to_writer(&mut file, &event) + .context("failed to encode clawbot diagnostic event")?; + file.write_all(b"\n") + .with_context(|| format!("failed to append {}", path.display())) +} + +fn unix_timestamp_ms_now() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before unix epoch")? + .as_millis() as i64) +} diff --git a/codex-rs/clawbot/src/events.rs b/codex-rs/clawbot/src/events.rs new file mode 100644 index 000000000..d66710856 --- /dev/null +++ b/codex-rs/clawbot/src/events.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::model::ProviderSessionRef; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderInboundMessage { + pub session: ProviderSessionRef, + pub message_id: String, + pub text: String, + pub received_at: i64, +} diff --git a/codex-rs/clawbot/src/lib.rs b/codex-rs/clawbot/src/lib.rs new file mode 100644 index 000000000..92645f5cc --- /dev/null +++ b/codex-rs/clawbot/src/lib.rs @@ -0,0 +1,42 @@ +mod config; +mod diagnostics; +mod events; +mod model; +mod provider; +mod runtime; +mod store; + +pub use config::ClawbotConfig; +pub use config::ClawbotTurnMode; +pub use config::FeishuConfig; +pub use diagnostics::append_diagnostic_event; +pub use events::ProviderInboundMessage; +pub use model::CLAWBOT_BINDINGS_RELATIVE_PATH; +pub use model::CLAWBOT_CONFIG_RELATIVE_PATH; +pub use model::CLAWBOT_DIAGNOSTICS_RELATIVE_PATH; +pub use model::CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH; +pub use model::CLAWBOT_PENDING_TURNS_RELATIVE_PATH; +pub use model::CLAWBOT_RELATIVE_DIR; +pub use model::CLAWBOT_RUNTIME_RELATIVE_PATH; +pub use model::CLAWBOT_SESSIONS_RELATIVE_PATH; +pub use model::CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH; +pub use model::CachedUnreadMessage; +pub use model::ClawbotSnapshot; +pub use model::ConnectionStatus; +pub use model::ForwardingDirection; +pub use model::ForwardingState; +pub use model::PendingClawbotTurn; +pub use model::ProviderKind; +pub use model::ProviderMessageRef; +pub use model::ProviderRuntimeState; +pub use model::ProviderSession; +pub use model::ProviderSessionRef; +pub use model::SessionBinding; +pub use model::SessionStatus; +pub use provider::FeishuProviderRuntime; +pub use provider::ProviderEvent; +pub use provider::ProviderOutboundReaction; +pub use provider::ProviderOutboundTextMessage; +pub use provider::feishu_failure_reply_text; +pub use runtime::ClawbotRuntime; +pub use store::ClawbotStore; diff --git a/codex-rs/clawbot/src/model.rs b/codex-rs/clawbot/src/model.rs new file mode 100644 index 000000000..a21e0ff18 --- /dev/null +++ b/codex-rs/clawbot/src/model.rs @@ -0,0 +1,204 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::config::ClawbotConfig; +use crate::config::ClawbotTurnMode; + +pub const CLAWBOT_RELATIVE_DIR: &str = ".codex/clawbot"; +pub const CLAWBOT_CONFIG_RELATIVE_PATH: &str = ".codex/clawbot/config.toml"; +pub const CLAWBOT_SESSIONS_RELATIVE_PATH: &str = ".codex/clawbot/sessions.json"; +pub const CLAWBOT_BINDINGS_RELATIVE_PATH: &str = ".codex/clawbot/bindings.json"; +pub const CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH: &str = ".codex/clawbot/unread_messages.jsonl"; +pub const CLAWBOT_PENDING_TURNS_RELATIVE_PATH: &str = ".codex/clawbot/pending_turns.json"; +pub const CLAWBOT_RUNTIME_RELATIVE_PATH: &str = ".codex/clawbot/runtime.json"; +pub const CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH: &str = ".codex/clawbot/inbound_receipts.json"; +pub const CLAWBOT_DIAGNOSTICS_RELATIVE_PATH: &str = ".codex/clawbot/diagnostics.jsonl"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ClawbotSnapshot { + pub config: ClawbotConfig, + pub runtime: Vec, + pub sessions: Vec, + pub bindings: Vec, + pub unread_message_count: usize, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + Feishu, +} + +impl ProviderKind { + pub fn title(self) -> &'static str { + match self { + Self::Feishu => "Feishu", + } + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionStatus { + #[default] + Unconfigured, + Disconnected, + Connecting, + Connected, + Error, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionStatus { + #[default] + Discovered, + Bound, + Disconnected, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderRuntimeState { + pub provider: ProviderKind, + pub connection: ConnectionStatus, + pub last_error: Option, + pub updated_at: Option, +} + +impl ProviderRuntimeState { + pub fn unconfigured(provider: ProviderKind) -> Self { + Self { + provider, + connection: ConnectionStatus::Unconfigured, + last_error: None, + updated_at: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ProviderSessionRef { + pub provider: ProviderKind, + pub session_id: String, +} + +impl ProviderSessionRef { + pub fn new(provider: ProviderKind, session_id: impl Into) -> Self { + Self { + provider, + session_id: session_id.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ProviderMessageRef { + pub provider: ProviderKind, + pub session_id: String, + pub message_id: String, +} + +impl ProviderMessageRef { + pub fn new( + provider: ProviderKind, + session_id: impl Into, + message_id: impl Into, + ) -> Self { + Self { + provider, + session_id: session_id.into(), + message_id: message_id.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderSession { + pub provider: ProviderKind, + pub session_id: String, + pub display_name: Option, + pub unread_count: usize, + pub last_message_at: Option, + pub status: SessionStatus, + pub bound_thread_id: Option, +} + +impl ProviderSession { + pub fn session_ref(&self) -> ProviderSessionRef { + ProviderSessionRef::new(self.provider, self.session_id.clone()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionBinding { + pub provider: ProviderKind, + pub session_id: String, + pub thread_id: String, + #[serde(default = "default_session_forwarding_enabled")] + pub inbound_forwarding_enabled: bool, + #[serde(default = "default_session_forwarding_enabled")] + pub outbound_forwarding_enabled: bool, + pub created_at: i64, + pub updated_at: i64, +} + +impl SessionBinding { + pub fn session_ref(&self) -> ProviderSessionRef { + ProviderSessionRef::new(self.provider, self.session_id.clone()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForwardingDirection { + Inbound, + Outbound, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForwardingState { + Enabled, + Disabled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CachedUnreadMessage { + pub provider: ProviderKind, + pub session_id: String, + pub message_id: String, + pub text: String, + pub received_at: i64, +} + +impl CachedUnreadMessage { + pub fn session_ref(&self) -> ProviderSessionRef { + ProviderSessionRef::new(self.provider, self.session_id.clone()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingClawbotTurn { + pub thread_id: String, + pub turn_id: String, + pub session: ProviderSessionRef, + pub message_id: String, + pub turn_mode: ClawbotTurnMode, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InboundMessageReceipt { + pub provider: ProviderKind, + pub session_id: String, + pub message_id: String, + pub received_at: i64, +} + +impl InboundMessageReceipt { + pub fn session_ref(&self) -> ProviderSessionRef { + ProviderSessionRef::new(self.provider, self.session_id.clone()) + } +} + +fn default_session_forwarding_enabled() -> bool { + true +} diff --git a/codex-rs/clawbot/src/provider/feishu.rs b/codex-rs/clawbot/src/provider/feishu.rs new file mode 100644 index 000000000..772bb417b --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu.rs @@ -0,0 +1,917 @@ +mod runtime_loop; +mod sync; + +use std::path::Path; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Result; +use anyhow::anyhow; +use open_lark::openlark_client; +use open_lark::openlark_communication::common::api_utils::serialize_params; +use open_lark::openlark_communication::endpoints::IM_V1_MESSAGES; +use open_lark::openlark_communication::im::im::v1::message::create::CreateMessageBody; +use open_lark::openlark_communication::im::im::v1::message::create::CreateMessageRequest; +use open_lark::openlark_communication::im::im::v1::message::models::ReceiveIdType; +use open_lark::openlark_communication::im::im::v1::message::reaction::models::CreateMessageReactionBody; +use open_lark::openlark_communication::im::im::v1::message::reaction::models::ReactionType; +use open_lark::openlark_core::api::ApiRequest; +use serde::Deserialize; +use serde_json::Value; +use tokio::sync::mpsc; + +use super::ProviderEvent; +use super::ProviderOutboundReaction; +use super::ProviderOutboundTextMessage; +use crate::append_diagnostic_event; +use crate::config::FeishuConfig; +use crate::events::ProviderInboundMessage; +use crate::model::ConnectionStatus; +use crate::model::ProviderKind; +use crate::model::ProviderRuntimeState; +use crate::model::ProviderSession; +use crate::model::ProviderSessionRef; +use crate::model::SessionStatus; + +#[derive(Debug, Clone)] +pub struct FeishuInboundMessage { + pub chat_id: String, + pub chat_type: String, + pub chat_name: Option, + pub message_id: String, + pub sender_open_id: Option, + pub sender_user_id: Option, + pub sender_union_id: Option, + pub text: String, + pub received_at: i64, +} + +#[derive(Debug, Clone)] +pub struct FeishuProviderRuntime { + workspace_root: PathBuf, + config: FeishuConfig, +} + +impl FeishuProviderRuntime { + pub fn new(workspace_root: impl Into, config: FeishuConfig) -> Self { + Self { + workspace_root: workspace_root.into(), + config, + } + } + + pub async fn run(self, provider_event_tx: mpsc::UnboundedSender) -> Result<()> { + runtime_loop::run_with_reconnect(self.workspace_root, self.config, provider_event_tx).await + } + + pub async fn send_text(&self, message: ProviderOutboundTextMessage) -> Result<()> { + if message.session.provider != ProviderKind::Feishu { + return Err(anyhow!( + "cannot send {} message via Feishu runtime", + message.session.provider.title() + )); + } + + let text = message.text; + let session_id = message.session.session_id; + let response = CreateMessageRequest::new(self.messaging_config()?) + .receive_id_type(ReceiveIdType::ChatId) + .execute(CreateMessageBody { + receive_id: session_id.clone(), + msg_type: "text".to_string(), + content: serde_json::to_string(&serde_json::json!({ "text": text.clone() }))?, + uuid: None, + }) + .await + .map_err(|error| anyhow!("failed to send Feishu text message: {error}")); + match response { + Ok(_) => { + let _ = append_diagnostic_event( + self.workspace_root.as_path(), + "feishu.send_text_succeeded", + serde_json::json!({ + "session_id": session_id, + "text": text, + }), + ); + Ok(()) + } + Err(error) => { + let _ = append_diagnostic_event( + self.workspace_root.as_path(), + "feishu.send_text_failed", + serde_json::json!({ + "session_id": session_id, + "text": text, + "error": error.to_string(), + }), + ); + Err(error) + } + } + } + + pub async fn add_reaction(&self, reaction: ProviderOutboundReaction) -> Result<()> { + if reaction.target.provider != ProviderKind::Feishu { + return Err(anyhow!( + "cannot send {} reaction via Feishu runtime", + reaction.target.provider.title() + )); + } + + let request: ApiRequest = ApiRequest::post(format!( + "{IM_V1_MESSAGES}/{}/reactions", + reaction.target.message_id + )) + .body(serialize_params( + &CreateMessageReactionBody { + reaction_type: ReactionType { + emoji_type: reaction.emoji_type, + }, + }, + "添加消息表情回复", + )?); + let response = open_lark::openlark_core::http::Transport::::request( + request, + &self.messaging_config()?, + Some(Default::default()), + ) + .await + .map_err(|error| anyhow!("failed to add Feishu message reaction: {error}"))?; + if response.is_success() { + Ok(()) + } else { + Err(anyhow!( + "failed to add Feishu message reaction: {}", + response.msg() + )) + } + } + + pub async fn scan_sessions(&self) -> Result> { + let sessions = sync::discover_supported_sessions(&self.messaging_config()?).await?; + Ok(sessions + .into_iter() + .map(ProviderEvent::SessionUpserted) + .collect()) + } + + pub fn normalize_chat_message(message: FeishuInboundMessage) -> Option> { + if !is_supported_chat_type(&message.chat_type) || message.text.trim().is_empty() { + return None; + } + + let display_name = if is_group_chat_type(&message.chat_type) { + message.chat_name + } else { + message + .chat_name + .or(message.sender_open_id.clone()) + .or(message.sender_user_id.clone()) + .or(message.sender_union_id.clone()) + }; + let session = ProviderSession { + provider: ProviderKind::Feishu, + session_id: message.chat_id.clone(), + display_name, + unread_count: 0, + last_message_at: Some(message.received_at), + status: SessionStatus::Discovered, + bound_thread_id: None, + }; + let inbound_message = ProviderInboundMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, message.chat_id), + message_id: message.message_id, + text: message.text, + received_at: message.received_at, + }; + + Some(vec![ + ProviderEvent::SessionUpserted(session), + ProviderEvent::InboundMessage(inbound_message), + ]) + } + + pub(super) fn websocket_config(&self) -> Result { + runtime_loop::build_websocket_config(&self.config) + } + + fn messaging_config(&self) -> Result { + Ok(self + .websocket_config()? + .build_core_config_with_token_provider()) + } +} + +pub fn failure_reply_text(message: &str) -> String { + let summary = message + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("unknown error"); + let truncated = truncate_chars(summary, /*max_chars*/ 160); + format!("Request failed: {truncated}") +} + +pub(super) fn provider_events_from_payload( + payload: &[u8], + config: &FeishuConfig, + workspace_root: &Path, +) -> Vec { + let Ok(envelope) = serde_json::from_slice::(payload) else { + let _ = append_diagnostic_event( + workspace_root, + "feishu.payload_parse_failed", + serde_json::json!({ + "payload": String::from_utf8_lossy(payload), + }), + ); + return Vec::new(); + }; + + match envelope.header.event_type.as_str() { + "im.message.receive_v1" => { + serde_json::from_value::(envelope.event) + .ok() + .and_then(|event| { + normalize_message_receive_event( + FeishuMessageReceiveEnvelope { event }, + config, + workspace_root, + ) + }) + .unwrap_or_default() + } + "im.chat.access_event.bot_p2p_chat_entered_v1" => { + serde_json::from_value::(envelope.event) + .ok() + .map(|event| normalize_chat_entered_event(FeishuChatEnteredEnvelope { event })) + .unwrap_or_default() + } + _ => { + let _ = append_diagnostic_event( + workspace_root, + "feishu.unsupported_event", + serde_json::json!({ + "event_type": envelope.header.event_type, + }), + ); + Vec::new() + } + } +} + +fn normalize_message_receive_event( + envelope: FeishuMessageReceiveEnvelope, + config: &FeishuConfig, + workspace_root: &Path, +) -> Option> { + let chat = envelope.event.chat; + let message = envelope.event.message; + let chat_type = message + .chat_type + .clone() + .or(chat.as_ref().and_then(|chat| chat.chat_type.clone())); + let message_type = message + .message_type + .as_deref() + .or(message.msg_type.as_deref()); + let sender = envelope.event.sender.sender_id; + let Some(chat_type) = chat_type else { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "missing_chat_type", + "message_id": message.message_id, + }), + ); + return None; + }; + let Some(message_type) = message_type else { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "missing_message_type", + "chat_id": message.chat_id, + "message_id": message.message_id, + "chat_type": chat_type, + }), + ); + return None; + }; + if !is_supported_chat_type(&chat_type) { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "unsupported_chat_type", + "chat_id": message.chat_id, + "message_id": message.message_id, + "chat_type": chat_type, + }), + ); + return None; + } + if message_type != "text" { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "unsupported_message_type", + "chat_id": message.chat_id, + "message_id": message.message_id, + "chat_type": chat_type, + "message_type": message_type, + }), + ); + return None; + } + if config.is_bot_sender( + sender.open_id.as_deref(), + sender.user_id.as_deref(), + sender.app_id.as_deref(), + ) { + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_dropped", + serde_json::json!({ + "reason": "bot_sender", + "chat_id": message.chat_id, + "message_id": message.message_id, + "chat_type": chat_type, + "sender_open_id": sender.open_id, + "sender_user_id": sender.user_id, + "sender_app_id": sender.app_id, + }), + ); + return None; + } + + let chat_id = chat + .as_ref() + .map(|chat| chat.chat_id.clone()) + .or(message.chat_id.clone())?; + let raw_content = message + .content + .or(message.body.and_then(|body| body.content)) + .unwrap_or_default(); + let text = serde_json::from_str::(&raw_content) + .ok() + .map(|content| content.text) + .unwrap_or(raw_content); + let normalized_text = if is_group_chat_type(&chat_type) { + strip_group_mention_prefix(&text) + } else { + text + }; + let received_at = parse_timestamp_value(message.create_time)?; + let _ = append_diagnostic_event( + workspace_root, + "feishu.message_normalized", + serde_json::json!({ + "chat_id": chat_id.clone(), + "chat_type": chat_type.clone(), + "message_id": message.message_id.clone(), + "sender_open_id": sender.open_id.clone(), + "sender_user_id": sender.user_id.clone(), + "sender_app_id": sender.app_id.clone(), + "text": normalized_text.clone(), + }), + ); + + FeishuProviderRuntime::normalize_chat_message(FeishuInboundMessage { + chat_id, + chat_type, + chat_name: chat.and_then(|chat| chat.name), + message_id: message.message_id, + sender_open_id: sender.open_id, + sender_user_id: sender.user_id, + sender_union_id: sender.union_id, + text: normalized_text, + received_at, + }) +} + +fn normalize_chat_entered_event(envelope: FeishuChatEnteredEnvelope) -> Vec { + let operator = envelope.event.operator_id; + vec![ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: envelope.event.chat_id, + display_name: operator + .open_id + .clone() + .or(operator.user_id.clone()) + .or(operator.union_id), + unread_count: 0, + last_message_at: parse_optional_timestamp(envelope.event.last_message_create_time), + status: SessionStatus::Discovered, + bound_thread_id: None, + })] +} + +fn parse_optional_timestamp(timestamp: Option) -> Option { + timestamp.and_then(|value| value.parse::().ok()) +} + +fn parse_timestamp_value(timestamp: serde_json::Value) -> Option { + match timestamp { + serde_json::Value::String(value) => parse_optional_timestamp(Some(value)), + serde_json::Value::Number(value) => value.as_i64(), + _ => None, + } +} + +fn is_supported_chat_type(chat_type: &str) -> bool { + is_private_chat_type(chat_type) || is_group_chat_type(chat_type) +} + +fn is_private_chat_type(chat_type: &str) -> bool { + matches!(chat_type, "p2p" | "private") +} + +fn is_group_chat_type(chat_type: &str) -> bool { + matches!(chat_type, "group") +} + +fn strip_group_mention_prefix(text: &str) -> String { + let mut remaining = text.trim_start(); + let mut stripped = false; + + loop { + let Some(after_at) = remaining.strip_prefix('@') else { + break; + }; + let mention_len = after_at + .char_indices() + .find_map(|(idx, ch)| { + (ch.is_whitespace() || matches!(ch, ':' | ':' | ',' | ',')).then_some(idx) + }) + .unwrap_or(after_at.len()); + if mention_len == 0 { + break; + } + remaining = &after_at[mention_len..]; + remaining = remaining.trim_start_matches(|ch: char| { + ch.is_whitespace() || matches!(ch, ':' | ':' | ',' | ',') + }); + stripped = true; + } + + if stripped { + remaining.to_string() + } else { + text.to_string() + } +} + +pub(super) fn runtime_state( + connection: ConnectionStatus, + last_error: Option, +) -> Result { + Ok(ProviderRuntimeState { + provider: ProviderKind::Feishu, + connection, + last_error, + updated_at: Some(unix_timestamp_now()?), + }) +} + +fn unix_timestamp_now() -> Result { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64) +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +#[derive(Debug, Deserialize)] +struct FeishuEventEnvelope { + header: FeishuEventHeader, + event: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventHeader { + event_type: String, +} + +#[derive(Debug, Deserialize)] +struct FeishuMessageReceiveEnvelope { + event: FeishuMessageReceiveEvent, +} + +#[derive(Debug, Deserialize)] +struct FeishuMessageReceiveEvent { + sender: FeishuEventSender, + message: FeishuEventMessage, + #[serde(default)] + chat: Option, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventSender { + sender_id: FeishuUserId, +} + +#[derive(Debug, Deserialize)] +struct FeishuUserId { + open_id: Option, + user_id: Option, + union_id: Option, + #[serde(default)] + app_id: Option, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventMessage { + message_id: String, + create_time: serde_json::Value, + #[serde(default)] + chat_id: Option, + #[serde(default)] + chat_type: Option, + #[serde(default)] + message_type: Option, + #[serde(default)] + msg_type: Option, + #[serde(default)] + content: Option, + #[serde(default)] + body: Option, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventChat { + chat_id: String, + #[serde(default)] + chat_type: Option, + #[serde(default)] + name: Option, +} + +#[derive(Debug, Deserialize)] +struct FeishuTextContent { + text: String, +} + +#[derive(Debug, Deserialize)] +struct FeishuEventMessageBody { + content: Option, +} + +#[derive(Debug, Deserialize)] +struct FeishuChatEnteredEnvelope { + event: FeishuChatEnteredEvent, +} + +#[derive(Debug, Deserialize)] +struct FeishuChatEnteredEvent { + chat_id: String, + operator_id: FeishuUserId, + last_message_create_time: Option, +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use pretty_assertions::assert_eq; + + use super::FeishuInboundMessage; + use super::failure_reply_text; + use super::normalize_chat_entered_event; + use super::normalize_message_receive_event; + use super::strip_group_mention_prefix; + use crate::config::FeishuConfig; + use crate::model::ProviderKind; + use crate::model::ProviderSession; + use crate::model::ProviderSessionRef; + use crate::model::SessionStatus; + use crate::provider::ProviderEvent; + + #[test] + fn normalize_chat_message_creates_session_and_inbound_events() { + let events = super::FeishuProviderRuntime::normalize_chat_message(FeishuInboundMessage { + chat_id: "chat_123".to_string(), + chat_type: "p2p".to_string(), + chat_name: Some("Alice".to_string()), + message_id: "msg_123".to_string(), + sender_open_id: Some("ou_123".to_string()), + sender_user_id: None, + sender_union_id: None, + text: "hello".to_string(), + received_at: 123, + }) + .expect("events"); + + assert_eq!( + events, + vec![ + ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_123".to_string(), + display_name: Some("Alice".to_string()), + unread_count: 0, + last_message_at: Some(123), + status: SessionStatus::Discovered, + bound_thread_id: None, + }), + ProviderEvent::InboundMessage(crate::events::ProviderInboundMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_123"), + message_id: "msg_123".to_string(), + text: "hello".to_string(), + received_at: 123, + }), + ] + ); + } + + #[test] + fn message_receive_event_skips_non_text_messages() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: Some("ou_123".to_string()), + user_id: None, + union_id: None, + app_id: None, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_123".to_string(), + create_time: serde_json::json!("456"), + chat_id: Some("chat_123".to_string()), + chat_type: Some("p2p".to_string()), + message_type: Some("image".to_string()), + msg_type: None, + content: Some("{}".to_string()), + body: None, + }, + chat: None, + }, + }; + + assert_eq!( + normalize_message_receive_event(envelope, &FeishuConfig::default(), Path::new("/tmp")), + None + ); + } + + #[test] + fn message_receive_event_accepts_group_text_messages() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: Some("ou_member".to_string()), + user_id: None, + union_id: None, + app_id: None, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_group_1".to_string(), + create_time: serde_json::json!("456"), + chat_id: Some("chat_group_123".to_string()), + chat_type: Some("group".to_string()), + message_type: Some("text".to_string()), + msg_type: None, + content: Some("{\"text\":\"hello group\"}".to_string()), + body: None, + }, + chat: Some(super::FeishuEventChat { + chat_id: "chat_group_123".to_string(), + chat_type: Some("group".to_string()), + name: Some("tracker".to_string()), + }), + }, + }; + + assert_eq!( + normalize_message_receive_event(envelope, &FeishuConfig::default(), Path::new("/tmp")), + Some(vec![ + ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_group_123".to_string(), + display_name: Some("tracker".to_string()), + unread_count: 0, + last_message_at: Some(456), + status: SessionStatus::Discovered, + bound_thread_id: None, + }), + ProviderEvent::InboundMessage(crate::events::ProviderInboundMessage { + session: ProviderSessionRef::new(ProviderKind::Feishu, "chat_group_123"), + message_id: "msg_group_1".to_string(), + text: "hello group".to_string(), + received_at: 456, + }), + ]) + ); + } + + #[test] + fn message_receive_event_skips_bot_authored_messages() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: Some("ou_bot".to_string()), + user_id: None, + union_id: None, + app_id: None, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_bot_1".to_string(), + create_time: serde_json::json!("456"), + chat_id: Some("chat_group_123".to_string()), + chat_type: Some("group".to_string()), + message_type: Some("text".to_string()), + msg_type: None, + content: Some("{\"text\":\"hello group\"}".to_string()), + body: None, + }, + chat: Some(super::FeishuEventChat { + chat_id: "chat_group_123".to_string(), + chat_type: Some("group".to_string()), + name: Some("tracker".to_string()), + }), + }, + }; + + assert_eq!( + normalize_message_receive_event( + envelope, + &FeishuConfig { + bot_open_id: Some("ou_bot".to_string()), + ..FeishuConfig::default() + }, + Path::new("/tmp"), + ), + None + ); + } + + #[test] + fn message_receive_event_skips_app_authored_messages() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: None, + user_id: None, + union_id: None, + app_id: Some("cli_app_123".to_string()), + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_bot_app_1".to_string(), + create_time: serde_json::json!("456"), + chat_id: Some("chat_group_123".to_string()), + chat_type: Some("group".to_string()), + message_type: Some("text".to_string()), + msg_type: None, + content: Some("{\"text\":\"hello group\"}".to_string()), + body: None, + }, + chat: Some(super::FeishuEventChat { + chat_id: "chat_group_123".to_string(), + chat_type: Some("group".to_string()), + name: Some("tracker".to_string()), + }), + }, + }; + + assert_eq!( + normalize_message_receive_event( + envelope, + &FeishuConfig { + app_id: "cli_app_123".to_string(), + ..FeishuConfig::default() + }, + Path::new("/tmp"), + ), + None + ); + } + + #[test] + fn message_receive_event_uses_chat_fallbacks_for_group_payloads() { + let envelope = super::FeishuMessageReceiveEnvelope { + event: super::FeishuMessageReceiveEvent { + sender: super::FeishuEventSender { + sender_id: super::FeishuUserId { + open_id: Some("ou_member".to_string()), + user_id: None, + union_id: None, + app_id: None, + }, + }, + message: super::FeishuEventMessage { + message_id: "msg_group_fallback_1".to_string(), + create_time: serde_json::json!(456), + chat_id: None, + chat_type: None, + message_type: None, + msg_type: Some("text".to_string()), + content: None, + body: Some(super::FeishuEventMessageBody { + content: Some("{\"text\":\"hello fallback\"}".to_string()), + }), + }, + chat: Some(super::FeishuEventChat { + chat_id: "chat_group_fallback_123".to_string(), + chat_type: Some("group".to_string()), + name: Some("tracker".to_string()), + }), + }, + }; + + assert_eq!( + normalize_message_receive_event(envelope, &FeishuConfig::default(), Path::new("/tmp")), + Some(vec![ + ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_group_fallback_123".to_string(), + display_name: Some("tracker".to_string()), + unread_count: 0, + last_message_at: Some(456), + status: SessionStatus::Discovered, + bound_thread_id: None, + }), + ProviderEvent::InboundMessage(crate::events::ProviderInboundMessage { + session: ProviderSessionRef::new( + ProviderKind::Feishu, + "chat_group_fallback_123", + ), + message_id: "msg_group_fallback_1".to_string(), + text: "hello fallback".to_string(), + received_at: 456, + }), + ]) + ); + } + + #[test] + fn chat_entered_event_creates_discovered_session() { + let events = normalize_chat_entered_event(super::FeishuChatEnteredEnvelope { + event: super::FeishuChatEnteredEvent { + chat_id: "chat_123".to_string(), + operator_id: super::FeishuUserId { + open_id: Some("ou_123".to_string()), + user_id: None, + union_id: None, + app_id: None, + }, + last_message_create_time: Some("456".to_string()), + }, + }); + + assert_eq!( + events, + vec![ProviderEvent::SessionUpserted(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_123".to_string(), + display_name: Some("ou_123".to_string()), + unread_count: 0, + last_message_at: Some(456), + status: SessionStatus::Discovered, + bound_thread_id: None, + })] + ); + } + + #[test] + fn failure_reply_text_uses_first_nonempty_line() { + assert_eq!( + failure_reply_text("\nboom\nsecond"), + "Request failed: boom".to_string() + ); + } + + #[test] + fn strip_group_mention_prefix_removes_leading_mentions() { + assert_eq!( + strip_group_mention_prefix("@_user_1 TRACKER TEST 2"), + "TRACKER TEST 2".to_string() + ); + assert_eq!( + strip_group_mention_prefix("@bot: hello"), + "hello".to_string() + ); + assert_eq!( + strip_group_mention_prefix("@bot @helper ping"), + "ping".to_string() + ); + } +} diff --git a/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs new file mode 100644 index 000000000..1e1537c6d --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu/runtime_loop.rs @@ -0,0 +1,209 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use open_lark::openlark_client; +use open_lark::openlark_client::ws_client::EventDispatcherHandler; +use open_lark::openlark_client::ws_client::LarkWsClient; +use tokio::sync::mpsc; +use tokio::sync::watch; +use tokio::time::Instant; +use tokio::time::MissedTickBehavior; + +use super::provider_events_from_payload; +use super::runtime_state; +use crate::append_diagnostic_event; +use crate::config::FeishuConfig; +use crate::model::ConnectionStatus; +use crate::provider::ProviderEvent; + +const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(2); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const IDLE_WATCHDOG_POLL_INTERVAL: Duration = Duration::from_secs(1); + +pub(super) async fn run_with_reconnect( + workspace_root: PathBuf, + config: FeishuConfig, + provider_event_tx: mpsc::UnboundedSender, +) -> Result<()> { + if !config.has_api_credentials() { + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Unconfigured, + Some("missing app_id/app_secret".to_string()), + )?)); + return Err(anyhow!("missing app_id/app_secret")); + } + + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; + loop { + match run_once(workspace_root.as_path(), &config, &provider_event_tx).await { + Ok(()) => { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "feishu.runtime_disconnected", + serde_json::json!({ + "reconnect_delay_secs": reconnect_delay.as_secs(), + }), + ); + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Disconnected, + Some(format!( + "Feishu websocket runtime exited; reconnecting in {}s", + reconnect_delay.as_secs() + )), + )?)); + } + Err(error) => { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "feishu.runtime_failed", + serde_json::json!({ + "error": error.to_string(), + "reconnect_delay_secs": reconnect_delay.as_secs(), + }), + ); + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Error, + Some(format!( + "Feishu websocket runtime failed: {error}; reconnecting in {}s", + reconnect_delay.as_secs() + )), + )?)); + } + } + + tokio::time::sleep(reconnect_delay).await; + reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY); + } +} + +async fn run_once( + workspace_root: &Path, + config: &FeishuConfig, + provider_event_tx: &mpsc::UnboundedSender, +) -> Result<()> { + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Connecting, + /*last_error*/ None, + )?)); + + let ws_config = Arc::new(build_websocket_config(config)?); + let (payload_tx, mut payload_rx) = mpsc::unbounded_channel::>(); + let (last_payload_at_tx, last_payload_at_rx) = watch::channel(Instant::now()); + let event_handler = EventDispatcherHandler::builder() + .payload_sender(payload_tx) + .build(); + let payload_provider_event_tx = provider_event_tx.clone(); + let payload_config = config.clone(); + let payload_workspace_root = workspace_root.to_path_buf(); + let payload_task = tokio::spawn(async move { + while let Some(payload) = payload_rx.recv().await { + let _ = last_payload_at_tx.send(Instant::now()); + let _ = append_diagnostic_event( + payload_workspace_root.as_path(), + "feishu.raw_payload", + payload_debug_value(&payload), + ); + for event in provider_events_from_payload( + &payload, + &payload_config, + payload_workspace_root.as_path(), + ) { + let _ = payload_provider_event_tx.send(event); + } + } + }); + let last_payload_at_rx = last_payload_at_rx; + + let _ = append_diagnostic_event( + workspace_root, + "feishu.runtime_connected", + serde_json::json!({}), + ); + let _ = provider_event_tx.send(ProviderEvent::RuntimeStateUpdated(runtime_state( + ConnectionStatus::Connected, + /*last_error*/ None, + )?)); + + let mut websocket_task = + tokio::spawn(async move { LarkWsClient::open(ws_config, event_handler).await }); + let mut idle_watchdog = tokio::time::interval(IDLE_WATCHDOG_POLL_INTERVAL); + idle_watchdog.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + tokio::select! { + websocket_result = &mut websocket_task => { + payload_task.abort(); + return websocket_result + .map_err(|error| anyhow!("Feishu websocket runtime task failed: {error}"))? + .map_err(|error| anyhow!("Feishu websocket runtime failed: {error}")); + } + _ = idle_watchdog.tick() => { + let last_payload_at = *last_payload_at_rx.borrow(); + if idle_timeout_exceeded(last_payload_at, Instant::now()) { + let _ = append_diagnostic_event( + workspace_root, + "feishu.runtime_idle_timeout", + serde_json::json!({ + "idle_timeout_secs": IDLE_TIMEOUT.as_secs(), + }), + ); + websocket_task.abort(); + let _ = websocket_task.await; + payload_task.abort(); + return Err(anyhow!( + "Feishu websocket idle timeout after {}s without payloads", + IDLE_TIMEOUT.as_secs() + )); + } + } + } + } +} + +pub(super) fn build_websocket_config(config: &FeishuConfig) -> Result { + openlark_client::Config::builder() + .app_id(config.app_id.clone()) + .app_secret(config.app_secret.clone()) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| anyhow!("failed to build Feishu websocket config: {error}")) +} + +fn payload_debug_value(payload: &[u8]) -> serde_json::Value { + serde_json::from_slice(payload).unwrap_or_else(|_| { + serde_json::json!({ + "raw": String::from_utf8_lossy(payload), + }) + }) +} + +fn idle_timeout_exceeded(last_payload_at: Instant, now: Instant) -> bool { + now.duration_since(last_payload_at) >= IDLE_TIMEOUT +} + +#[cfg(test)] +mod tests { + use super::IDLE_TIMEOUT; + use super::idle_timeout_exceeded; + use std::time::Duration; + use tokio::time::Instant; + + #[test] + fn idle_timeout_triggers_at_threshold() { + let last_payload_at = Instant::now(); + + assert!(!idle_timeout_exceeded( + last_payload_at, + last_payload_at + IDLE_TIMEOUT - Duration::from_millis(1), + )); + assert!(idle_timeout_exceeded( + last_payload_at, + last_payload_at + IDLE_TIMEOUT, + )); + } +} diff --git a/codex-rs/clawbot/src/provider/feishu/sync.rs b/codex-rs/clawbot/src/provider/feishu/sync.rs new file mode 100644 index 000000000..a8b6a0aff --- /dev/null +++ b/codex-rs/clawbot/src/provider/feishu/sync.rs @@ -0,0 +1,255 @@ +use anyhow::Context; +use anyhow::Result; +use open_lark::openlark_communication::im::im::v1::chat::get::GetChatRequest; +use open_lark::openlark_communication::im::im::v1::chat::list::ListChatsRequest; +use open_lark::openlark_communication::im::im::v1::chat::models::ChatSortType; +use open_lark::openlark_communication::im::im::v1::message::models::UserIdType; +use serde::Deserialize; + +use crate::model::ProviderKind; +use crate::model::ProviderSession; +use crate::model::SessionStatus; + +pub(super) async fn discover_supported_sessions( + config: &open_lark::openlark_core::config::Config, +) -> Result> { + let mut sessions = Vec::new(); + let mut page_token = None; + + loop { + let response = list_chat_page(config, page_token.clone()).await?; + let next_page_token = response.page_token.clone(); + + for chat in response.items { + if let Some(session) = load_supported_session(config, chat).await? { + sessions.push(session); + } + } + + if !response.has_more { + break; + } + + let Some(token) = next_page_token.filter(|token| !token.is_empty()) else { + break; + }; + page_token = Some(token); + } + + sessions.sort_by(|left, right| left.session_id.cmp(&right.session_id)); + sessions.dedup_by(|left, right| left.session_id == right.session_id); + Ok(sessions) +} + +async fn list_chat_page( + config: &open_lark::openlark_core::config::Config, + page_token: Option, +) -> Result { + let mut request = ListChatsRequest::new(config.clone()) + .user_id_type(UserIdType::OpenId) + .sort_type(ChatSortType::ByActiveTimeDesc) + .page_size(100); + if let Some(token) = page_token { + request = request.page_token(token); + } + + let response = request + .execute() + .await + .context("failed to list Feishu chats")?; + serde_json::from_value(response).context("failed to parse Feishu chat list response") +} + +async fn load_supported_session( + config: &open_lark::openlark_core::config::Config, + chat: FeishuChatListItem, +) -> Result> { + let chat_id = chat.chat_id.clone(); + let response = GetChatRequest::new(config.clone()) + .chat_id(chat_id.clone()) + .user_id_type(UserIdType::OpenId) + .execute() + .await + .with_context(|| format!("failed to load Feishu chat {chat_id}"))?; + let details: FeishuChatDetails = serde_json::from_value(response) + .with_context(|| format!("failed to parse Feishu chat details for {chat_id}"))?; + + if !is_supported_chat(&details) { + return Ok(None); + } + + Ok(Some(ProviderSession { + provider: ProviderKind::Feishu, + session_id: chat.chat_id, + display_name: first_non_empty([chat.name, details.name]), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + })) +} + +fn first_non_empty(values: [Option; 2]) -> Option { + values + .into_iter() + .flatten() + .map(|value| value.trim().to_string()) + .find(|value| !value.is_empty()) +} + +fn is_supported_chat(details: &FeishuChatDetails) -> bool { + is_private_chat(details) || is_group_chat(details) +} + +fn is_private_chat(details: &FeishuChatDetails) -> bool { + details.chat_type.as_deref() == Some("private") + || details.chat_mode.as_deref() == Some("p2p") + || details.r#type.as_deref() == Some("p2p") +} + +fn is_group_chat(details: &FeishuChatDetails) -> bool { + details.chat_type.as_deref() == Some("group") + || details.chat_mode.as_deref() == Some("group") + || details.r#type.as_deref() == Some("group") +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct FeishuChatListResponse { + #[serde(default)] + items: Vec, + #[serde(default)] + page_token: Option, + #[serde(default)] + has_more: bool, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct FeishuChatListItem { + chat_id: String, + name: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +struct FeishuChatDetails { + name: Option, + chat_mode: Option, + chat_type: Option, + #[serde(rename = "type")] + r#type: Option, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::FeishuChatDetails; + use super::FeishuChatListItem; + use super::FeishuChatListResponse; + use super::first_non_empty; + use super::is_group_chat; + use super::is_private_chat; + use super::is_supported_chat; + + #[test] + fn parse_list_chat_response_with_items() { + let response: FeishuChatListResponse = serde_json::from_value(serde_json::json!({ + "items": [ + { "chat_id": "oc_1", "name": "Alice" }, + { "chat_id": "oc_2", "name": "Bob" } + ], + "page_token": "next_token", + "has_more": true + })) + .expect("response"); + + assert_eq!( + response, + FeishuChatListResponse { + items: vec![ + FeishuChatListItem { + chat_id: "oc_1".to_string(), + name: Some("Alice".to_string()), + }, + FeishuChatListItem { + chat_id: "oc_2".to_string(), + name: Some("Bob".to_string()), + }, + ], + page_token: Some("next_token".to_string()), + has_more: true, + } + ); + } + + #[test] + fn parse_chat_details_with_type_field() { + let response: FeishuChatDetails = serde_json::from_value(serde_json::json!({ + "chat_id": "oc_1", + "name": "Alice", + "type": "p2p" + })) + .expect("response"); + + assert_eq!( + response, + FeishuChatDetails { + name: Some("Alice".to_string()), + chat_mode: None, + chat_type: None, + r#type: Some("p2p".to_string()), + } + ); + } + + #[test] + fn private_chat_detection_accepts_private_variants() { + assert_eq!( + is_private_chat(&FeishuChatDetails { + name: Some("Alice".to_string()), + chat_mode: Some("group".to_string()), + chat_type: Some("private".to_string()), + r#type: None, + }), + true + ); + assert_eq!( + is_private_chat(&FeishuChatDetails { + name: Some("Alice".to_string()), + chat_mode: Some("p2p".to_string()), + chat_type: Some("group".to_string()), + r#type: None, + }), + true + ); + } + + #[test] + fn group_chat_detection_accepts_group_variants() { + assert_eq!( + is_group_chat(&FeishuChatDetails { + name: Some("tracker".to_string()), + chat_mode: Some("group".to_string()), + chat_type: Some("group".to_string()), + r#type: None, + }), + true + ); + assert_eq!( + is_supported_chat(&FeishuChatDetails { + name: Some("tracker".to_string()), + chat_mode: None, + chat_type: Some("group".to_string()), + r#type: None, + }), + true + ); + } + + #[test] + fn first_non_empty_skips_blank_values() { + assert_eq!( + first_non_empty([Some(" ".to_string()), Some("Alice".to_string())]), + Some("Alice".to_string()) + ); + } +} diff --git a/codex-rs/clawbot/src/provider/mod.rs b/codex-rs/clawbot/src/provider/mod.rs new file mode 100644 index 000000000..a7304ff23 --- /dev/null +++ b/codex-rs/clawbot/src/provider/mod.rs @@ -0,0 +1,30 @@ +mod feishu; + +use crate::events::ProviderInboundMessage; +use crate::model::ProviderMessageRef; +use crate::model::ProviderRuntimeState; +use crate::model::ProviderSession; +use crate::model::ProviderSessionRef; + +pub use feishu::FeishuProviderRuntime; +pub use feishu::failure_reply_text as feishu_failure_reply_text; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderOutboundTextMessage { + pub session: ProviderSessionRef, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderOutboundReaction { + pub target: ProviderMessageRef, + pub emoji_type: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderEvent { + RuntimeStateUpdated(ProviderRuntimeState), + SessionUpserted(ProviderSession), + SessionRemoved(ProviderSessionRef), + InboundMessage(ProviderInboundMessage), +} diff --git a/codex-rs/clawbot/src/runtime.rs b/codex-rs/clawbot/src/runtime.rs new file mode 100644 index 000000000..51abe4ea1 --- /dev/null +++ b/codex-rs/clawbot/src/runtime.rs @@ -0,0 +1,797 @@ +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Context; +use anyhow::Result; + +use crate::config::ClawbotTurnMode; +use crate::config::FeishuConfig; +use crate::model::CachedUnreadMessage; +use crate::model::ClawbotSnapshot; +use crate::model::ForwardingDirection; +use crate::model::ForwardingState; +use crate::model::InboundMessageReceipt; +use crate::model::ProviderKind; +use crate::model::ProviderSession; +use crate::model::ProviderSessionRef; +use crate::model::SessionBinding; +use crate::model::SessionStatus; +use crate::provider::FeishuProviderRuntime; +use crate::provider::ProviderEvent; +use crate::store::ClawbotStore; + +#[derive(Debug)] +pub struct ClawbotRuntime { + store: ClawbotStore, + snapshot: ClawbotSnapshot, +} + +impl ClawbotRuntime { + pub fn load(workspace_root: PathBuf) -> Result { + let store = ClawbotStore::new(workspace_root); + let snapshot = store.load_snapshot()?; + Ok(Self { store, snapshot }) + } + + pub fn reload(&mut self) -> Result<&ClawbotSnapshot> { + self.snapshot = self.store.load_snapshot()?; + Ok(&self.snapshot) + } + + pub fn snapshot(&self) -> &ClawbotSnapshot { + &self.snapshot + } + + pub fn store(&self) -> &ClawbotStore { + &self.store + } + + pub fn feishu_provider(&self) -> Option { + self.snapshot.config.feishu.clone().map(|config| { + FeishuProviderRuntime::new(self.store.workspace_root().to_path_buf(), config) + }) + } + + pub fn update_feishu_config( + &mut self, + feishu: Option, + ) -> Result<&ClawbotSnapshot> { + self.snapshot.config.feishu = feishu.filter(|config| !config.is_empty()); + self.store.save_config(&self.snapshot.config)?; + self.reload() + } + + pub fn update_turn_mode(&mut self, mode: ClawbotTurnMode) -> Result<&ClawbotSnapshot> { + self.snapshot.config.turn_mode = mode; + self.store.save_config(&self.snapshot.config)?; + self.reload() + } + + pub async fn scan_feishu_sessions(&mut self) -> Result<&ClawbotSnapshot> { + let provider = self + .feishu_provider() + .context("Feishu credentials are not configured")?; + let discovered_sessions = provider + .scan_sessions() + .await? + .into_iter() + .filter_map(|event| match event { + ProviderEvent::SessionUpserted(session) => Some(session), + ProviderEvent::RuntimeStateUpdated(_) + | ProviderEvent::SessionRemoved(_) + | ProviderEvent::InboundMessage(_) => None, + }) + .collect::>(); + self.reconcile_feishu_sessions(discovered_sessions) + } + + pub fn clear_unbound_feishu_sessions(&mut self) -> Result<&ClawbotSnapshot> { + let bound_sessions = self + .store + .load_bindings()? + .into_iter() + .map(|binding| binding.session_ref()) + .collect::>(); + let sessions = self + .store + .load_sessions()? + .into_iter() + .filter(|session| { + session.provider != ProviderKind::Feishu + || bound_sessions.contains(&session.session_ref()) + }) + .collect::>(); + let unread_messages = self + .store + .load_unread_messages()? + .into_iter() + .filter(|message| { + message.provider != ProviderKind::Feishu + || bound_sessions.contains(&message.session_ref()) + }) + .collect::>(); + + self.store.save_sessions(&sessions)?; + self.store.save_unread_messages(&unread_messages)?; + self.reload() + } + + pub fn persist_session(&mut self, session: ProviderSession) -> Result<&ClawbotSnapshot> { + self.store.upsert_session(session)?; + self.reload() + } + + pub fn persist_binding(&mut self, binding: SessionBinding) -> Result<&ClawbotSnapshot> { + self.store.upsert_binding(binding)?; + self.reload() + } + + pub fn connect_session_to_thread( + &mut self, + session: &ProviderSessionRef, + thread_id: String, + ) -> Result<&ClawbotSnapshot> { + let now = unix_timestamp_now()?; + let mut bindings = self.store.load_bindings()?; + let mut sessions = self.store.load_sessions()?; + let existing_binding = bindings + .iter() + .find(|binding| binding.session_ref() == *session) + .cloned(); + + for binding in &bindings { + if binding.thread_id == thread_id + && binding.session_ref() != *session + && let Some(existing_session) = sessions + .iter_mut() + .find(|existing| existing.session_ref() == binding.session_ref()) + { + existing_session.bound_thread_id = None; + existing_session.status = SessionStatus::Discovered; + } + } + + bindings + .retain(|binding| binding.thread_id != thread_id || binding.session_ref() == *session); + if let Some(binding) = bindings + .iter_mut() + .find(|binding| binding.session_ref() == *session) + { + binding.thread_id = thread_id.clone(); + binding.updated_at = now; + } else { + bindings.push(SessionBinding { + provider: session.provider, + session_id: session.session_id.clone(), + thread_id: thread_id.clone(), + inbound_forwarding_enabled: true, + outbound_forwarding_enabled: true, + created_at: existing_binding + .as_ref() + .map_or(now, |binding| binding.created_at), + updated_at: now, + }); + } + + if let Some(provider_session) = sessions + .iter_mut() + .find(|provider_session| provider_session.session_ref() == *session) + { + provider_session.bound_thread_id = Some(thread_id.clone()); + provider_session.status = SessionStatus::Bound; + } else { + sessions.push(ProviderSession { + provider: session.provider, + session_id: session.session_id.clone(), + display_name: None, + unread_count: self.unread_count_for_session(session)?, + last_message_at: None, + status: SessionStatus::Bound, + bound_thread_id: Some(thread_id), + }); + } + + self.store.save_bindings(&bindings)?; + self.store.save_sessions(&sessions)?; + self.reload() + } + + pub fn load_binding_for_thread(&self, thread_id: &str) -> Result> { + Ok(self + .store + .load_bindings()? + .into_iter() + .find(|binding| binding.thread_id == thread_id)) + } + + pub fn load_binding_for_session( + &self, + session: &ProviderSessionRef, + ) -> Result> { + Ok(self + .store + .load_bindings()? + .into_iter() + .find(|binding| binding.session_ref() == *session)) + } + + pub fn bound_session_for_thread(&self, thread_id: &str) -> Result> { + Ok(self + .load_binding_for_thread(thread_id)? + .as_ref() + .map(SessionBinding::session_ref)) + } + + pub fn disconnect_thread(&mut self, thread_id: &str) -> Result> { + let Some(binding) = self.load_binding_for_thread(thread_id)? else { + return Ok(None); + }; + let session = binding.session_ref(); + let mut bindings = self.store.load_bindings()?; + bindings.retain(|candidate| candidate.thread_id != thread_id); + self.store.save_bindings(&bindings)?; + + let mut sessions = self.store.load_sessions()?; + if let Some(existing) = sessions + .iter_mut() + .find(|candidate| candidate.session_ref() == session) + { + existing.bound_thread_id = None; + existing.status = SessionStatus::Discovered; + } + self.store.save_sessions(&sessions)?; + self.reload()?; + Ok(Some(session)) + } + + pub fn set_forwarding_state_for_thread( + &mut self, + thread_id: &str, + direction: ForwardingDirection, + state: ForwardingState, + ) -> Result> { + let mut bindings = self.store.load_bindings()?; + let Some(binding) = bindings + .iter_mut() + .find(|candidate| candidate.thread_id == thread_id) + else { + return Ok(None); + }; + let next_enabled = matches!(state, ForwardingState::Enabled); + match direction { + ForwardingDirection::Inbound => binding.inbound_forwarding_enabled = next_enabled, + ForwardingDirection::Outbound => binding.outbound_forwarding_enabled = next_enabled, + } + binding.updated_at = unix_timestamp_now()?; + let updated = binding.clone(); + self.store.save_bindings(&bindings)?; + self.reload()?; + Ok(Some(updated)) + } + + pub fn take_next_unread_message( + &mut self, + session: &ProviderSessionRef, + ) -> Result> { + let message = self.store.take_next_unread_message(session)?; + if message.is_some() + && let Some(mut provider_session) = self.load_session(session)? + { + provider_session.unread_count = provider_session.unread_count.saturating_sub(1); + self.store.upsert_session(provider_session)?; + } + self.reload()?; + Ok(message) + } + + pub fn apply_provider_event(&mut self, event: ProviderEvent) -> Result<&ClawbotSnapshot> { + match event { + ProviderEvent::RuntimeStateUpdated(state) => { + self.store.upsert_runtime_state(state)?; + } + ProviderEvent::SessionUpserted(mut session) => { + session.bound_thread_id = self.lookup_bound_thread_id(&session.session_ref())?; + session.unread_count = self.unread_count_for_session(&session.session_ref())?; + if session.bound_thread_id.is_some() { + session.status = SessionStatus::Bound; + } + self.store.upsert_session(session)?; + } + ProviderEvent::SessionRemoved(session) => { + self.store.remove_session(&session)?; + } + ProviderEvent::InboundMessage(message) => { + if self + .store + .has_inbound_receipt(&message.session, &message.message_id)? + { + return self.reload(); + } + + self.store.append_unread_message(&CachedUnreadMessage { + provider: message.session.provider, + session_id: message.session.session_id.clone(), + message_id: message.message_id.clone(), + text: message.text, + received_at: message.received_at, + })?; + self.store.record_inbound_receipt(InboundMessageReceipt { + provider: message.session.provider, + session_id: message.session.session_id.clone(), + message_id: message.message_id, + received_at: message.received_at, + })?; + + let mut session = self + .load_session(&message.session)? + .unwrap_or(ProviderSession { + provider: message.session.provider, + session_id: message.session.session_id.clone(), + display_name: None, + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }); + session.bound_thread_id = self.lookup_bound_thread_id(&message.session)?; + session.unread_count = self.unread_count_for_session(&message.session)?; + session.last_message_at = Some(message.received_at); + if session.bound_thread_id.is_some() { + session.status = SessionStatus::Bound; + } + self.store.upsert_session(session)?; + } + } + self.reload() + } + + fn load_session(&self, session: &ProviderSessionRef) -> Result> { + Ok(self + .store + .load_sessions()? + .into_iter() + .find(|existing| existing.session_ref() == *session)) + } + + fn lookup_bound_thread_id(&self, session: &ProviderSessionRef) -> Result> { + Ok(self + .store + .load_bindings()? + .into_iter() + .find(|binding| binding.session_ref() == *session) + .map(|binding| binding.thread_id)) + } + + fn unread_count_for_session(&self, session: &ProviderSessionRef) -> Result { + Ok(self + .store + .load_unread_messages()? + .into_iter() + .filter(|message| message.session_ref() == *session) + .count()) + } + + fn reconcile_feishu_sessions( + &mut self, + discovered_sessions: Vec, + ) -> Result<&ClawbotSnapshot> { + let discovered_refs = discovered_sessions + .iter() + .map(ProviderSession::session_ref) + .collect::>(); + let bindings = self + .store + .load_bindings()? + .into_iter() + .filter(|binding| { + binding.provider != ProviderKind::Feishu + || discovered_refs.contains(&binding.session_ref()) + }) + .collect::>(); + let sessions = self + .store + .load_sessions()? + .into_iter() + .filter(|session| { + session.provider != ProviderKind::Feishu + || discovered_refs.contains(&session.session_ref()) + }) + .collect::>(); + let unread_messages = self + .store + .load_unread_messages()? + .into_iter() + .filter(|message| { + message.provider != ProviderKind::Feishu + || discovered_refs.contains(&message.session_ref()) + }) + .collect::>(); + self.store.save_bindings(&bindings)?; + self.store.save_sessions(&sessions)?; + self.store.save_unread_messages(&unread_messages)?; + for session in discovered_sessions { + self.apply_provider_event(ProviderEvent::SessionUpserted(session))?; + } + self.reload() + } +} + +fn unix_timestamp_now() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX_EPOCH")? + .as_secs() as i64) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use super::ClawbotRuntime; + use crate::config::ClawbotTurnMode; + use crate::config::FeishuConfig; + use crate::events::ProviderInboundMessage; + use crate::model::CachedUnreadMessage; + use crate::model::ConnectionStatus; + use crate::model::ForwardingDirection; + use crate::model::ForwardingState; + use crate::model::ProviderKind; + use crate::model::ProviderRuntimeState; + use crate::model::ProviderSession; + use crate::model::ProviderSessionRef; + use crate::model::SessionBinding; + use crate::model::SessionStatus; + use crate::provider::ProviderEvent; + + #[test] + fn take_next_unread_message_is_fifo_per_session() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_1"); + + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_1".to_string(), + display_name: Some("Alice".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("session"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: session.clone(), + message_id: "msg_2".to_string(), + text: "second".to_string(), + received_at: 2, + })) + .expect("second"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "first".to_string(), + received_at: 1, + })) + .expect("first"); + + assert_eq!( + runtime + .take_next_unread_message(&session) + .expect("take first") + .expect("message") + .message_id, + "msg_1" + ); + assert_eq!( + runtime + .take_next_unread_message(&session) + .expect("take second") + .expect("message") + .message_id, + "msg_2" + ); + assert_eq!( + runtime + .take_next_unread_message(&session) + .expect("take none"), + None + ); + } + + #[test] + fn apply_provider_event_deduplicates_inbound_messages() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_2"); + + runtime + .apply_provider_event(ProviderEvent::RuntimeStateUpdated(ProviderRuntimeState { + provider: ProviderKind::Feishu, + connection: ConnectionStatus::Connected, + last_error: None, + updated_at: Some(1), + })) + .expect("runtime state"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "hello".to_string(), + received_at: 10, + })) + .expect("first inbound"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session, + message_id: "msg_1".to_string(), + text: "hello".to_string(), + received_at: 10, + })) + .expect("duplicate inbound"); + + assert_eq!(runtime.snapshot().runtime.len(), 1); + assert_eq!(runtime.snapshot().unread_message_count, 1); + assert_eq!(runtime.snapshot().sessions.len(), 1); + assert_eq!(runtime.snapshot().sessions[0].unread_count, 1); + } + + #[test] + fn update_feishu_config_clears_empty_values() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + + runtime + .update_feishu_config(Some(FeishuConfig { + app_id: "app".to_string(), + app_secret: "secret".to_string(), + verification_token: Some("token".to_string()), + encrypt_key: None, + bot_open_id: None, + bot_user_id: None, + })) + .expect("save config"); + assert_eq!( + runtime.snapshot().config.feishu, + Some(FeishuConfig { + app_id: "app".to_string(), + app_secret: "secret".to_string(), + verification_token: Some("token".to_string()), + encrypt_key: None, + bot_open_id: None, + bot_user_id: None, + }) + ); + + runtime + .update_feishu_config(Some(FeishuConfig::default())) + .expect("clear config"); + assert_eq!(runtime.snapshot().config.feishu, None); + } + + #[test] + fn update_turn_mode_persists_in_config() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + + runtime + .update_turn_mode(ClawbotTurnMode::NonInteractive) + .expect("save turn mode"); + + let reloaded = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("reload runtime"); + assert_eq!( + reloaded.snapshot().config.turn_mode, + ClawbotTurnMode::NonInteractive + ); + } + + #[test] + fn disconnect_thread_clears_binding_and_session_state() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_3"); + + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_3".to_string(), + display_name: Some("Bob".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("session"); + runtime + .connect_session_to_thread(&session, "thread_1".to_string()) + .expect("bind session"); + + assert_eq!( + runtime.disconnect_thread("thread_1").expect("disconnect"), + Some(session.clone()) + ); + assert_eq!( + runtime + .bound_session_for_thread("thread_1") + .expect("bound session"), + None + ); + assert_eq!( + runtime.snapshot().sessions, + vec![ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_3".to_string(), + display_name: Some("Bob".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }] + ); + } + + #[test] + fn set_forwarding_state_for_thread_updates_binding_flags() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_4"); + + runtime + .connect_session_to_thread(&session, "thread_2".to_string()) + .expect("bind session"); + + runtime + .set_forwarding_state_for_thread( + "thread_2", + ForwardingDirection::Inbound, + ForwardingState::Disabled, + ) + .expect("disable inbound"); + runtime + .set_forwarding_state_for_thread( + "thread_2", + ForwardingDirection::Outbound, + ForwardingState::Disabled, + ) + .expect("disable outbound"); + + assert_eq!( + runtime.snapshot().bindings[0].clone(), + SessionBinding { + provider: ProviderKind::Feishu, + session_id: "chat_4".to_string(), + thread_id: "thread_2".to_string(), + inbound_forwarding_enabled: false, + outbound_forwarding_enabled: false, + created_at: runtime.snapshot().bindings[0].created_at, + updated_at: runtime.snapshot().bindings[0].updated_at, + } + ); + } + + #[test] + fn clear_unbound_feishu_sessions_removes_unbound_sessions_and_unread_cache() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let bound_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_bound"); + let unbound_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_unbound"); + + runtime + .connect_session_to_thread(&bound_session, "thread_3".to_string()) + .expect("bind session"); + runtime + .persist_session(ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_unbound".to_string(), + display_name: Some("Unbound".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("persist unbound session"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: bound_session.clone(), + message_id: "msg_bound".to_string(), + text: "bound".to_string(), + received_at: 1, + })) + .expect("bound unread"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: unbound_session, + message_id: "msg_unbound".to_string(), + text: "unbound".to_string(), + received_at: 2, + })) + .expect("unbound unread"); + + runtime + .clear_unbound_feishu_sessions() + .expect("clear unbound sessions"); + + assert_eq!( + runtime.snapshot().sessions, + vec![ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_bound".to_string(), + display_name: None, + unread_count: 1, + last_message_at: Some(1), + status: SessionStatus::Bound, + bound_thread_id: Some("thread_3".to_string()), + }] + ); + assert_eq!( + runtime + .store() + .load_unread_messages() + .expect("unread messages"), + vec![CachedUnreadMessage { + provider: ProviderKind::Feishu, + session_id: "chat_bound".to_string(), + message_id: "msg_bound".to_string(), + text: "bound".to_string(), + received_at: 1, + }] + ); + } + + #[test] + fn reconcile_feishu_sessions_prunes_missing_bound_sessions_and_unread_cache() { + let tempdir = tempdir().expect("tempdir"); + let mut runtime = ClawbotRuntime::load(tempdir.path().to_path_buf()).expect("runtime"); + let stale_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_stale"); + let live_session = ProviderSessionRef::new(ProviderKind::Feishu, "chat_live"); + + runtime + .connect_session_to_thread(&stale_session, "thread_stale".to_string()) + .expect("bind stale session"); + runtime + .apply_provider_event(ProviderEvent::InboundMessage(ProviderInboundMessage { + session: stale_session, + message_id: "msg_stale".to_string(), + text: "stale".to_string(), + received_at: 1, + })) + .expect("stale unread"); + runtime + .reconcile_feishu_sessions(vec![ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_live".to_string(), + display_name: Some("Live".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }]) + .expect("reconcile discovered sessions"); + + assert_eq!(runtime.snapshot().bindings, Vec::::new()); + assert_eq!( + runtime.snapshot().sessions, + vec![ProviderSession { + provider: ProviderKind::Feishu, + session_id: live_session.session_id, + display_name: Some("Live".to_string()), + unread_count: 0, + last_message_at: None, + status: SessionStatus::Discovered, + bound_thread_id: None, + }] + ); + assert_eq!( + runtime + .store() + .load_unread_messages() + .expect("unread messages"), + Vec::::new() + ); + } +} diff --git a/codex-rs/clawbot/src/store.rs b/codex-rs/clawbot/src/store.rs new file mode 100644 index 000000000..e3ab419ea --- /dev/null +++ b/codex-rs/clawbot/src/store.rs @@ -0,0 +1,410 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use serde::Serialize; +use serde::de::DeserializeOwned; +use toml::Value as TomlValue; + +use crate::config::ClawbotConfig; +use crate::model::CLAWBOT_BINDINGS_RELATIVE_PATH; +use crate::model::CLAWBOT_CONFIG_RELATIVE_PATH; +use crate::model::CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH; +use crate::model::CLAWBOT_PENDING_TURNS_RELATIVE_PATH; +use crate::model::CLAWBOT_RELATIVE_DIR; +use crate::model::CLAWBOT_RUNTIME_RELATIVE_PATH; +use crate::model::CLAWBOT_SESSIONS_RELATIVE_PATH; +use crate::model::CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH; +use crate::model::CachedUnreadMessage; +use crate::model::ClawbotSnapshot; +use crate::model::InboundMessageReceipt; +use crate::model::PendingClawbotTurn; +use crate::model::ProviderKind; +use crate::model::ProviderRuntimeState; +use crate::model::ProviderSession; +use crate::model::ProviderSessionRef; +use crate::model::SessionBinding; + +const MAX_INBOUND_RECEIPTS: usize = 4_096; + +#[derive(Debug, Clone)] +pub struct ClawbotStore { + workspace_root: PathBuf, +} + +impl ClawbotStore { + pub fn new(workspace_root: impl Into) -> Self { + Self { + workspace_root: workspace_root.into(), + } + } + + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub fn root_dir(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_RELATIVE_DIR) + } + + pub fn config_path(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_CONFIG_RELATIVE_PATH) + } + + pub fn sessions_path(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_SESSIONS_RELATIVE_PATH) + } + + pub fn bindings_path(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_BINDINGS_RELATIVE_PATH) + } + + pub fn unread_messages_path(&self) -> PathBuf { + self.workspace_root + .join(CLAWBOT_UNREAD_MESSAGES_RELATIVE_PATH) + } + + pub fn runtime_path(&self) -> PathBuf { + self.workspace_root.join(CLAWBOT_RUNTIME_RELATIVE_PATH) + } + + pub fn pending_turns_path(&self) -> PathBuf { + self.workspace_root + .join(CLAWBOT_PENDING_TURNS_RELATIVE_PATH) + } + + pub fn inbound_receipts_path(&self) -> PathBuf { + self.workspace_root + .join(CLAWBOT_INBOUND_RECEIPTS_RELATIVE_PATH) + } + + pub fn ensure_root_dir(&self) -> Result<()> { + fs::create_dir_all(self.root_dir()) + .with_context(|| format!("failed to create {}", self.root_dir().display())) + } + + pub fn load_snapshot(&self) -> Result { + let config = self.load_config()?; + let mut runtime = self.load_runtime_states()?; + if config.feishu.is_none() + && runtime + .iter() + .all(|state| state.provider != ProviderKind::Feishu) + { + runtime.push(ProviderRuntimeState::unconfigured(ProviderKind::Feishu)); + } + let sessions = self.load_sessions()?; + let bindings = self.load_bindings()?; + let unread_message_count = self.load_unread_messages()?.len(); + Ok(ClawbotSnapshot { + config, + runtime, + sessions, + bindings, + unread_message_count, + }) + } + + pub fn load_config(&self) -> Result { + let config_path = self.config_path(); + if !config_path.exists() { + return Ok(ClawbotConfig::default()); + } + let raw = fs::read_to_string(&config_path) + .with_context(|| format!("failed to read {}", config_path.display()))?; + toml::from_str(&raw).with_context(|| format!("failed to parse {}", config_path.display())) + } + + pub fn save_config(&self, config: &ClawbotConfig) -> Result<()> { + let rendered = toml::to_string_pretty(config).context("failed to encode config")?; + let contents = if rendered.trim().is_empty() { + String::new() + } else { + let normalized = rendered + .parse::() + .ok() + .and_then(|value| toml::to_string_pretty(&value).ok()) + .unwrap_or(rendered); + format!("{normalized}\n") + }; + self.write_string_file(&self.config_path(), &contents) + } + + pub fn load_runtime_states(&self) -> Result> { + read_optional_json_file(&self.runtime_path()) + .with_context(|| format!("failed to load {}", self.runtime_path().display())) + } + + pub fn save_runtime_states(&self, runtime_states: &[ProviderRuntimeState]) -> Result<()> { + let mut sorted = runtime_states.to_vec(); + sorted.sort_by_key(|state| state.provider.title()); + self.write_json_file(&self.runtime_path(), &sorted) + } + + pub fn upsert_runtime_state( + &self, + runtime_state: ProviderRuntimeState, + ) -> Result> { + let mut runtime_states = self.load_runtime_states()?; + if let Some(existing) = runtime_states + .iter_mut() + .find(|state| state.provider == runtime_state.provider) + { + *existing = runtime_state; + } else { + runtime_states.push(runtime_state); + } + self.save_runtime_states(&runtime_states)?; + Ok(runtime_states) + } + + pub fn load_sessions(&self) -> Result> { + read_optional_json_file(&self.sessions_path()) + .with_context(|| format!("failed to load {}", self.sessions_path().display())) + } + + pub fn save_sessions(&self, sessions: &[ProviderSession]) -> Result<()> { + let mut sorted = sessions.to_vec(); + sorted.sort_by(|left, right| { + left.provider + .title() + .cmp(right.provider.title()) + .then(left.session_id.cmp(&right.session_id)) + }); + self.write_json_file(&self.sessions_path(), &sorted) + } + + pub fn upsert_session(&self, session: ProviderSession) -> Result> { + let mut sessions = self.load_sessions()?; + if let Some(existing) = sessions + .iter_mut() + .find(|existing| existing.session_ref() == session.session_ref()) + { + *existing = session; + } else { + sessions.push(session); + } + self.save_sessions(&sessions)?; + Ok(sessions) + } + + pub fn remove_session(&self, session: &ProviderSessionRef) -> Result> { + let mut sessions = self.load_sessions()?; + sessions.retain(|existing| existing.session_ref() != *session); + self.save_sessions(&sessions)?; + Ok(sessions) + } + + pub fn load_bindings(&self) -> Result> { + read_optional_json_file(&self.bindings_path()) + .with_context(|| format!("failed to load {}", self.bindings_path().display())) + } + + pub fn save_bindings(&self, bindings: &[SessionBinding]) -> Result<()> { + let mut sorted = bindings.to_vec(); + sorted.sort_by(|left, right| { + left.provider + .title() + .cmp(right.provider.title()) + .then(left.session_id.cmp(&right.session_id)) + .then(left.thread_id.cmp(&right.thread_id)) + }); + self.write_json_file(&self.bindings_path(), &sorted) + } + + pub fn upsert_binding(&self, binding: SessionBinding) -> Result> { + let mut bindings = self.load_bindings()?; + if let Some(existing) = bindings + .iter_mut() + .find(|existing| existing.session_ref() == binding.session_ref()) + { + *existing = binding; + } else { + bindings.push(binding); + } + self.save_bindings(&bindings)?; + Ok(bindings) + } + + pub fn load_unread_messages(&self) -> Result> { + let unread_messages_path = self.unread_messages_path(); + if !unread_messages_path.exists() { + return Ok(Vec::new()); + } + + let raw = fs::read_to_string(&unread_messages_path) + .with_context(|| format!("failed to read {}", unread_messages_path.display()))?; + raw.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str::(line) + .with_context(|| format!("failed to parse {}", unread_messages_path.display())) + }) + .collect() + } + + pub fn save_unread_messages(&self, unread_messages: &[CachedUnreadMessage]) -> Result<()> { + let mut sorted = unread_messages.to_vec(); + sorted.sort_by(|left, right| { + left.provider + .title() + .cmp(right.provider.title()) + .then(left.session_id.cmp(&right.session_id)) + .then(left.received_at.cmp(&right.received_at)) + .then(left.message_id.cmp(&right.message_id)) + }); + sorted.dedup_by(|left, right| { + left.provider == right.provider + && left.session_id == right.session_id + && left.message_id == right.message_id + }); + + let rendered = if sorted.is_empty() { + String::new() + } else { + let lines = sorted + .iter() + .map(|message| serde_json::to_string(message).context("failed to encode unread")) + .collect::>>()?; + format!("{}\n", lines.join("\n")) + }; + self.write_string_file(&self.unread_messages_path(), &rendered) + } + + pub fn append_unread_message(&self, message: &CachedUnreadMessage) -> Result<()> { + let mut unread_messages = self.load_unread_messages()?; + unread_messages.push(message.clone()); + self.save_unread_messages(&unread_messages) + } + + pub fn take_next_unread_message( + &self, + session: &ProviderSessionRef, + ) -> Result> { + let mut unread_messages = self.load_unread_messages()?; + let Some(index) = unread_messages + .iter() + .enumerate() + .filter(|(_, message)| message.session_ref() == *session) + .min_by(|(_, left), (_, right)| { + left.received_at + .cmp(&right.received_at) + .then(left.message_id.cmp(&right.message_id)) + }) + .map(|(index, _)| index) + else { + return Ok(None); + }; + let message = unread_messages.remove(index); + self.save_unread_messages(&unread_messages)?; + Ok(Some(message)) + } + + pub fn load_pending_turns(&self) -> Result> { + read_optional_json_file(&self.pending_turns_path()) + .with_context(|| format!("failed to load {}", self.pending_turns_path().display())) + } + + pub fn save_pending_turns(&self, pending_turns: &[PendingClawbotTurn]) -> Result<()> { + let mut sorted = pending_turns.to_vec(); + sorted.sort_by(|left, right| { + left.thread_id + .cmp(&right.thread_id) + .then(left.turn_id.cmp(&right.turn_id)) + .then(left.session.session_id.cmp(&right.session.session_id)) + .then(left.message_id.cmp(&right.message_id)) + }); + sorted.dedup_by(|left, right| { + left.thread_id == right.thread_id && left.turn_id == right.turn_id + }); + self.write_json_file(&self.pending_turns_path(), &sorted) + } + + pub fn upsert_pending_turn(&self, pending_turn: PendingClawbotTurn) -> Result<()> { + let mut pending_turns = self.load_pending_turns()?; + pending_turns.retain(|existing| { + existing.thread_id != pending_turn.thread_id || existing.turn_id != pending_turn.turn_id + }); + pending_turns.push(pending_turn); + self.save_pending_turns(&pending_turns) + } + + pub fn remove_pending_turn( + &self, + thread_id: &str, + turn_id: &str, + ) -> Result> { + let mut pending_turns = self.load_pending_turns()?; + let Some(index) = pending_turns + .iter() + .position(|pending| pending.thread_id == thread_id && pending.turn_id == turn_id) + else { + return Ok(None); + }; + let pending_turn = pending_turns.remove(index); + self.save_pending_turns(&pending_turns)?; + Ok(Some(pending_turn)) + } + + pub fn load_inbound_receipts(&self) -> Result> { + read_optional_json_file(&self.inbound_receipts_path()) + .with_context(|| format!("failed to load {}", self.inbound_receipts_path().display())) + } + + pub fn has_inbound_receipt( + &self, + session: &ProviderSessionRef, + message_id: &str, + ) -> Result { + Ok(self + .load_inbound_receipts()? + .into_iter() + .any(|receipt| receipt.session_ref() == *session && receipt.message_id == message_id)) + } + + pub fn record_inbound_receipt(&self, receipt: InboundMessageReceipt) -> Result<()> { + let mut receipts = self.load_inbound_receipts()?; + receipts.retain(|existing| { + existing.session_ref() != receipt.session_ref() + || existing.message_id != receipt.message_id + }); + receipts.push(receipt); + receipts.sort_by(|left, right| { + left.received_at + .cmp(&right.received_at) + .then(left.session_id.cmp(&right.session_id)) + .then(left.message_id.cmp(&right.message_id)) + }); + if receipts.len() > MAX_INBOUND_RECEIPTS { + receipts.drain(..receipts.len() - MAX_INBOUND_RECEIPTS); + } + self.write_json_file(&self.inbound_receipts_path(), &receipts) + } + + fn write_json_file(&self, path: &Path, value: &T) -> Result<()> + where + T: Serialize, + { + let rendered = serde_json::to_string_pretty(value).context("failed to encode json")?; + self.write_string_file(path, &format!("{rendered}\n")) + } + + fn write_string_file(&self, path: &Path, contents: &str) -> Result<()> { + self.ensure_root_dir()?; + fs::write(path, contents).with_context(|| format!("failed to write {}", path.display())) + } +} + +fn read_optional_json_file(path: &Path) -> Result> +where + T: DeserializeOwned, +{ + if !path.exists() { + return Ok(Vec::new()); + } + let raw = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display())) +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1c4f5068f..31250ef9e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -30,6 +30,7 @@ use codex_tui::ExitReason; use codex_tui::update_action::UpdateAction; use codex_utils_cli::CliConfigOverrides; use owo_colors::OwoColorize; +use std::ffi::OsString; use std::io::IsTerminal; use std::path::PathBuf; use supports_color::Stream; @@ -461,6 +462,28 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec anyhow::Result<()> { + if matches!(exit_info.exit_reason, ExitReason::RespawnRequested) { + let Some(thread_id) = exit_info.thread_id.as_ref() else { + anyhow::bail!("cannot respawn Codex: current session has no thread id"); + }; + respawn_current_codex_session( + arg0_paths, + respawn_args, + &thread_id.to_string(), + exit_info.respawn_with_yolo, + )?; + return Ok(()); + } + + handle_app_exit(exit_info) +} + /// Handle the app exit and print the results. Optionally run the update action. fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { match exit_info.exit_reason { @@ -468,7 +491,7 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { eprintln!("ERROR: {message}"); std::process::exit(1); } - ExitReason::UserRequested => { /* normal exit */ } + ExitReason::UserRequested | ExitReason::RespawnRequested => { /* normal exit */ } } let update_action = exit_info.update_action; @@ -482,6 +505,167 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { Ok(()) } +fn respawn_current_codex_session( + arg0_paths: &Arg0DispatchPaths, + respawn_args: &[OsString], + thread_id: &str, + respawn_with_yolo: bool, +) -> anyhow::Result<()> { + let Some(exe_path) = arg0_paths.codex_self_exe.as_ref() else { + anyhow::bail!("unable to respawn Codex: current executable path is unavailable"); + }; + + let mut command = std::process::Command::new(exe_path); + command.args(build_codex_respawn_argv( + respawn_args, + thread_id, + respawn_with_yolo, + )); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + + let error = command.exec(); + anyhow::bail!( + "failed to respawn Codex via {}: {error}", + exe_path.display() + ); + } + + #[cfg(not(unix))] + { + command + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + command.spawn().map_err(|error| { + anyhow::anyhow!( + "failed to respawn Codex via {}: {error}", + exe_path.display() + ) + })?; + Ok(()) + } +} + +fn build_codex_respawn_argv( + respawn_args: &[OsString], + thread_id: &str, + respawn_with_yolo: bool, +) -> Vec { + let mut args = normalize_respawn_mode_args(respawn_args, respawn_with_yolo); + args.push("resume".into()); + args.push(thread_id.into()); + if respawn_with_yolo { + args.push("--yolo".into()); + } + args +} + +fn normalize_respawn_mode_args(args: &[OsString], respawn_with_yolo: bool) -> Vec { + let mut normalized = Vec::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + let Some(arg_str) = arg.to_str() else { + normalized.push(arg.clone()); + continue; + }; + match arg_str { + "--yolo" | "--dangerously-bypass-approvals-and-sandbox" => {} + "--ask-for-approval" | "--sandbox" if respawn_with_yolo => { + let _ = iter.next(); + } + "--full-auto" if respawn_with_yolo => {} + _ => normalized.push(arg.clone()), + } + } + normalized +} + +fn approval_mode_cli_arg_name(value: codex_utils_cli::ApprovalModeCliArg) -> &'static str { + match value { + codex_utils_cli::ApprovalModeCliArg::Untrusted => "untrusted", + codex_utils_cli::ApprovalModeCliArg::OnFailure => "on-failure", + codex_utils_cli::ApprovalModeCliArg::OnRequest => "on-request", + codex_utils_cli::ApprovalModeCliArg::Never => "never", + } +} + +fn sandbox_mode_cli_arg_name(value: codex_utils_cli::SandboxModeCliArg) -> &'static str { + match value { + codex_utils_cli::SandboxModeCliArg::ReadOnly => "read-only", + codex_utils_cli::SandboxModeCliArg::WorkspaceWrite => "workspace-write", + codex_utils_cli::SandboxModeCliArg::DangerFullAccess => "danger-full-access", + } +} + +fn push_arg_value(args: &mut Vec, flag: &'static str, value: impl Into) { + args.push(flag.into()); + args.push(value.into()); +} + +fn extend_tui_cli_respawn_args(args: &mut Vec, interactive: &TuiCli) { + if let Some(model) = &interactive.model { + push_arg_value(args, "--model", model.clone()); + } + if interactive.oss { + args.push("--oss".into()); + } + if let Some(provider) = &interactive.oss_provider { + push_arg_value(args, "--local-provider", provider.clone()); + } + if let Some(profile) = &interactive.config_profile { + push_arg_value(args, "--profile", profile.clone()); + } + if let Some(sandbox_mode) = interactive.sandbox_mode { + push_arg_value(args, "--sandbox", sandbox_mode_cli_arg_name(sandbox_mode)); + } + if let Some(approval_policy) = interactive.approval_policy { + push_arg_value( + args, + "--ask-for-approval", + approval_mode_cli_arg_name(approval_policy), + ); + } + if interactive.full_auto { + args.push("--full-auto".into()); + } + if interactive.dangerously_bypass_approvals_and_sandbox { + args.push("--yolo".into()); + } + if let Some(cwd) = &interactive.cwd { + push_arg_value(args, "--cd", cwd.as_os_str().to_os_string()); + } + if interactive.web_search { + args.push("--search".into()); + } + for dir in &interactive.add_dir { + push_arg_value(args, "--add-dir", dir.as_os_str().to_os_string()); + } + if interactive.no_alt_screen { + args.push("--no-alt-screen".into()); + } + for raw_override in &interactive.config_overrides.raw_overrides { + push_arg_value(args, "-c", raw_override.clone()); + } +} + +fn build_interactive_respawn_args( + remote: &InteractiveRemoteOptions, + interactive: &TuiCli, +) -> Vec { + let mut args = Vec::new(); + if let Some(remote_addr) = &remote.remote { + push_arg_value(&mut args, "--remote", remote_addr.clone()); + } + if let Some(env_var) = &remote.remote_auth_token_env { + push_arg_value(&mut args, "--remote-auth-token-env", env_var.clone()); + } + extend_tui_cli_respawn_args(&mut args, interactive); + args +} + /// Run the update action and print the result. fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { println!(); @@ -638,6 +822,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { &mut interactive.config_overrides, root_config_overrides.clone(), ); + let respawn_args = build_interactive_respawn_args( + &InteractiveRemoteOptions { + remote: root_remote.clone(), + remote_auth_token_env: root_remote_auth_token_env.clone(), + }, + &interactive, + ); let exit_info = run_interactive_tui( interactive, root_remote.clone(), @@ -645,7 +836,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { arg0_paths.clone(), ) .await?; - handle_app_exit(exit_info)?; + finish_interactive_exit(exit_info, &arg0_paths, &respawn_args)?; } Some(Subcommand::Exec(mut exec_cli)) => { reject_remote_mode_for_subcommand( @@ -766,16 +957,21 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { include_non_interactive, config_overrides, ); - let exit_info = run_interactive_tui( - interactive, - remote.remote.or(root_remote.clone()), - remote + let effective_remote = InteractiveRemoteOptions { + remote: remote.remote.or(root_remote.clone()), + remote_auth_token_env: remote .remote_auth_token_env .or(root_remote_auth_token_env.clone()), + }; + let respawn_args = build_interactive_respawn_args(&effective_remote, &interactive); + let exit_info = run_interactive_tui( + interactive, + effective_remote.remote, + effective_remote.remote_auth_token_env, arg0_paths.clone(), ) .await?; - handle_app_exit(exit_info)?; + finish_interactive_exit(exit_info, &arg0_paths, &respawn_args)?; } Some(Subcommand::Fork(ForkCommand { session_id, @@ -792,16 +988,21 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { all, config_overrides, ); - let exit_info = run_interactive_tui( - interactive, - remote.remote.or(root_remote.clone()), - remote + let effective_remote = InteractiveRemoteOptions { + remote: remote.remote.or(root_remote.clone()), + remote_auth_token_env: remote .remote_auth_token_env .or(root_remote_auth_token_env.clone()), + }; + let respawn_args = build_interactive_respawn_args(&effective_remote, &interactive); + let exit_info = run_interactive_tui( + interactive, + effective_remote.remote, + effective_remote.remote_auth_token_env, arg0_paths.clone(), ) .await?; - handle_app_exit(exit_info)?; + finish_interactive_exit(exit_info, &arg0_paths, &respawn_args)?; } Some(Subcommand::Login(mut login_cli)) => { reject_remote_mode_for_subcommand( @@ -1474,6 +1675,12 @@ mod tests { use codex_protocol::protocol::TokenUsage; use pretty_assertions::assert_eq; + fn lossy_args(args: &[OsString]) -> Vec { + args.iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + fn finalize_resume_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let MultitoolCli { @@ -1624,6 +1831,7 @@ mod tests { .map(Result::unwrap), thread_name: thread_name.map(str::to_string), update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, } } @@ -1635,12 +1843,95 @@ mod tests { thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }; let lines = format_exit_messages(exit_info, /*color_enabled*/ false); assert!(lines.is_empty()); } + #[test] + fn build_interactive_respawn_args_preserves_effective_session_args() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--remote", + "ws://127.0.0.1:4500", + "--remote-auth-token-env", + "CODEX_AUTH", + "--model", + "gpt-5", + "--profile", + "work", + "--cd", + "/tmp/project", + "--search", + "--add-dir", + "/tmp/extra", + "--no-alt-screen", + "-c", + "foo=1", + ]) + .expect("parse"); + let MultitoolCli { + mut interactive, + config_overrides: root_overrides, + remote, + .. + } = cli; + prepend_config_flags(&mut interactive.config_overrides, root_overrides); + + let args = build_interactive_respawn_args(&remote, &interactive); + + assert_eq!( + lossy_args(&args), + vec![ + "--remote", + "ws://127.0.0.1:4500", + "--remote-auth-token-env", + "CODEX_AUTH", + "--model", + "gpt-5", + "--profile", + "work", + "--cd", + "/tmp/project", + "--search", + "--add-dir", + "/tmp/extra", + "--no-alt-screen", + "-c", + "foo=1", + ] + ); + } + + #[test] + fn build_codex_respawn_argv_rewrites_mode_flags_for_yolo_respawn() { + let respawn_args = vec![ + OsString::from("--remote"), + OsString::from("ws://127.0.0.1:4500"), + OsString::from("--sandbox"), + OsString::from("workspace-write"), + OsString::from("--ask-for-approval"), + OsString::from("on-request"), + OsString::from("--full-auto"), + ]; + + let argv = + build_codex_respawn_argv(&respawn_args, "thread-123", /*respawn_with_yolo*/ true); + + assert_eq!( + lossy_args(&argv), + vec![ + "--remote", + "ws://127.0.0.1:4500", + "resume", + "thread-123", + "--yolo", + ] + ); + } + #[test] fn format_exit_messages_includes_resume_hint_without_color() { let exit_info = sample_exit_info( diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index c52383352..e7441084f 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -450,6 +450,29 @@ pub struct ModelAvailabilityNuxConfig { pub shown_count: HashMap, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiDisplayPreferences { + /// Show MCP/custom tool result bodies in transcript cells. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_tool_results: bool, + + /// Show patch/edit diff summaries in transcript cells. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_patch_diffs: bool, +} + +impl Default for TuiDisplayPreferences { + fn default() -> Self { + Self { + show_tool_results: true, + show_patch_diffs: true, + } + } +} + /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -510,6 +533,10 @@ pub struct Tui { /// Startup tooltip availability NUX state persisted by the TUI. #[serde(default)] pub model_availability_nux: ModelAvailabilityNuxConfig, + + /// Transcript visibility preferences that affect only local TUI rendering. + #[serde(default)] + pub display_preferences: TuiDisplayPreferences, } const fn default_true() -> bool { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7f5be535e..696d6fe45 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1735,6 +1735,18 @@ "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, + "display_preferences": { + "allOf": [ + { + "$ref": "#/definitions/TuiDisplayPreferences" + } + ], + "default": { + "show_patch_diffs": true, + "show_tool_results": true + }, + "description": "Transcript visibility preferences that affect only local TUI rendering." + }, "model_availability_nux": { "allOf": [ { @@ -1791,6 +1803,22 @@ }, "type": "object" }, + "TuiDisplayPreferences": { + "additionalProperties": false, + "properties": { + "show_patch_diffs": { + "default": true, + "description": "Show patch/edit diff summaries in transcript cells. Defaults to `true`.", + "type": "boolean" + }, + "show_tool_results": { + "default": true, + "description": "Show MCP/custom tool result bodies in transcript cells. Defaults to `true`.", + "type": "boolean" + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 384458a69..87f43d02d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -18,6 +18,7 @@ use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; use codex_config::types::ToolSuggestDiscoverableType; +use codex_config::types::TuiDisplayPreferences; use codex_features::Feature; use codex_features::FeaturesToml; use codex_model_provider_info::WireApi; @@ -293,6 +294,7 @@ fn config_toml_deserializes_model_availability_nux() { ("gpt-foo".to_string(), 2), ]), }, + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -310,6 +312,31 @@ fn runtime_config_defaults_model_availability_nux() { cfg.model_availability_nux, ModelAvailabilityNuxConfig::default() ); + assert_eq!( + cfg.tui_display_preferences, + TuiDisplayPreferences::default() + ); +} + +#[test] +fn config_toml_deserializes_tui_display_preferences() { + let toml = r#" +[tui.display_preferences] +show_tool_results = false +show_patch_diffs = false +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for TUI display prefs"); + + assert_eq!( + cfg.tui + .expect("tui config should deserialize") + .display_preferences, + TuiDisplayPreferences { + show_tool_results: false, + show_patch_diffs: false, + } + ); } #[test] @@ -985,6 +1012,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig::default(), + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -4519,6 +4547,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4664,6 +4693,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4807,6 +4837,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(false), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4936,6 +4967,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8eb069133..d0f72ece6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -48,6 +48,7 @@ use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::Tui; +use codex_config::types::TuiDisplayPreferences; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_config::types::WindowsToml; @@ -337,6 +338,9 @@ pub struct Config { /// Persisted startup availability NUX state for model tooltips. pub model_availability_nux: ModelAvailabilityNuxConfig, + /// Transcript visibility preferences that affect only local TUI rendering. + pub tui_display_preferences: TuiDisplayPreferences, + /// Start the TUI in the specified collaboration mode (plan/default). /// Controls whether the TUI uses the terminal's alternate screen buffer. @@ -2780,6 +2784,11 @@ impl Config { .as_ref() .map(|t| t.model_availability_nux.clone()) .unwrap_or_default(), + tui_display_preferences: cfg + .tui + .as_ref() + .map(|t| t.display_preferences.clone()) + .unwrap_or_default(), tui_alternate_screen: cfg .tui .as_ref() diff --git a/codex-rs/core/src/tools/handlers/grep_files.rs b/codex-rs/core/src/tools/handlers/grep_files.rs new file mode 100644 index 000000000..ddc61d437 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/grep_files.rs @@ -0,0 +1,159 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use serde::Deserialize; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::SystemTime; +use tokio::process::Command; + +pub struct GrepFilesHandler; + +const DEFAULT_LIMIT: usize = 100; + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +#[derive(Debug, Deserialize)] +struct GrepFilesArgs { + pattern: String, + #[serde(default)] + include: Option, + #[serde(default)] + path: Option, + #[serde(default = "default_limit")] + limit: usize, +} + +impl ToolHandler for GrepFilesHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { payload, turn, .. } = invocation; + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "grep_files handler received unsupported payload".to_string(), + )); + } + }; + + let args: GrepFilesArgs = parse_arguments(&arguments)?; + if args.limit == 0 { + return Err(FunctionCallError::RespondToModel( + "limit must be greater than zero".to_string(), + )); + } + + let search_root = args + .path + .map(PathBuf::from) + .map(|path| crate::util::resolve_path(turn.cwd.as_path(), &path)) + .unwrap_or_else(|| turn.cwd.to_path_buf()); + + let matches = run_rg_search( + &args.pattern, + args.include.as_deref(), + search_root.as_path(), + args.limit, + turn.cwd.as_path(), + ) + .await?; + + let output = if matches.is_empty() { + "No matching files found".to_string() + } else { + matches.join("\n") + }; + Ok(FunctionToolOutput::from_text(output, Some(true))) + } +} + +async fn run_rg_search( + pattern: &str, + include: Option<&str>, + path: &Path, + limit: usize, + cwd: &Path, +) -> Result, FunctionCallError> { + let mut command = Command::new("rg"); + command + .arg("--files-with-matches") + .arg("--no-messages") + .arg("--color") + .arg("never") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(cwd); + if let Some(include) = include { + command.arg("--glob").arg(include); + } + command.arg(pattern).arg(path); + + let output = command + .output() + .await + .map_err(|err| FunctionCallError::RespondToModel(format!("failed to run rg: {err}")))?; + + match output.status.code() { + Some(0) => { + let mut results = parse_results(&output.stdout, usize::MAX) + .into_iter() + .map(|result| crate::util::resolve_path(cwd, &PathBuf::from(result))) + .collect::>(); + results.sort_by(|left, right| { + let left_modified = modified_time(left.as_path()); + let right_modified = modified_time(right.as_path()); + right_modified + .cmp(&left_modified) + .then_with(|| left.cmp(right)) + }); + results.truncate(limit); + Ok(results + .into_iter() + .map(|path| path.display().to_string()) + .collect()) + } + Some(1) => Ok(Vec::new()), + _ => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let details = if stderr.is_empty() { + format!("rg exited with status {}", output.status) + } else { + stderr + }; + Err(FunctionCallError::RespondToModel(format!( + "grep_files search failed: {details}" + ))) + } + } +} + +fn modified_time(path: &Path) -> SystemTime { + std::fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) +} + +fn parse_results(stdout: &[u8], limit: usize) -> Vec { + String::from_utf8_lossy(stdout) + .lines() + .take(limit) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +#[path = "grep_files_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/grep_files_tests.rs b/codex-rs/core/src/tools/handlers/grep_files_tests.rs index 0cc247c6f..3d3348616 100644 --- a/codex-rs/core/src/tools/handlers/grep_files_tests.rs +++ b/codex-rs/core/src/tools/handlers/grep_files_tests.rs @@ -5,7 +5,7 @@ use tempfile::tempdir; #[test] fn parses_basic_results() { let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n"; - let parsed = parse_results(stdout, 10); + let parsed = parse_results(stdout, /*limit*/ 10); assert_eq!( parsed, vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] @@ -15,7 +15,7 @@ fn parses_basic_results() { #[test] fn parse_truncates_after_limit() { let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n/tmp/file_c.rs\n"; - let parsed = parse_results(stdout, 2); + let parsed = parse_results(stdout, /*limit*/ 2); assert_eq!( parsed, vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] @@ -33,7 +33,7 @@ async fn run_search_returns_results() -> anyhow::Result<()> { std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); std::fs::write(dir.join("other.txt"), "omega").unwrap(); - let results = run_rg_search("alpha", None, dir, 10, dir).await?; + let results = run_rg_search("alpha", /*include*/ None, dir, /*limit*/ 10, dir).await?; assert_eq!(results.len(), 2); assert!(results.iter().any(|path| path.ends_with("match_one.txt"))); assert!(results.iter().any(|path| path.ends_with("match_two.txt"))); @@ -50,7 +50,7 @@ async fn run_search_with_glob_filter() -> anyhow::Result<()> { std::fs::write(dir.join("match_one.rs"), "alpha beta gamma").unwrap(); std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); - let results = run_rg_search("alpha", Some("*.rs"), dir, 10, dir).await?; + let results = run_rg_search("alpha", Some("*.rs"), dir, /*limit*/ 10, dir).await?; assert_eq!(results.len(), 1); assert!(results.iter().all(|path| path.ends_with("match_one.rs"))); Ok(()) @@ -67,7 +67,7 @@ async fn run_search_respects_limit() -> anyhow::Result<()> { std::fs::write(dir.join("two.txt"), "alpha two").unwrap(); std::fs::write(dir.join("three.txt"), "alpha three").unwrap(); - let results = run_rg_search("alpha", None, dir, 2, dir).await?; + let results = run_rg_search("alpha", /*include*/ None, dir, /*limit*/ 2, dir).await?; assert_eq!(results.len(), 2); Ok(()) } @@ -81,7 +81,7 @@ async fn run_search_handles_no_matches() -> anyhow::Result<()> { let dir = temp.path(); std::fs::write(dir.join("one.txt"), "omega").unwrap(); - let results = run_rg_search("alpha", None, dir, 5, dir).await?; + let results = run_rg_search("alpha", /*include*/ None, dir, /*limit*/ 5, dir).await?; assert!(results.is_empty()); Ok(()) } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index f0a62b8c1..5903d2b5c 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod agent_jobs; pub mod apply_patch; mod dynamic; +mod grep_files; mod js_repl; mod list_dir; mod mcp; @@ -9,6 +10,7 @@ pub(crate) mod multi_agents; pub(crate) mod multi_agents_common; pub(crate) mod multi_agents_v2; mod plan; +mod read_file; mod request_permissions; mod request_user_input; mod shell; @@ -36,12 +38,14 @@ pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; +pub use grep_files::GrepFilesHandler; pub use js_repl::JsReplHandler; pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; +pub use read_file::ReadFileHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_user_input::RequestUserInputHandler; pub use shell::ShellCommandHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 8e4bfb5b5..17c3362cd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -73,6 +73,14 @@ impl ToolHandler for Handler { .await .map_err(FunctionCallError::RespondToModel)?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + if let Some(cwd) = resolve_requested_agent_cwd(&turn.cwd, args.cwd.as_deref())? { + config.cwd = + codex_utils_absolute_path::AbsolutePathBuf::try_from(cwd).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "spawn_agent cwd must be absolute: {error}" + )) + })?; + } apply_spawn_agent_overrides(&mut config, child_depth); let result = session @@ -175,6 +183,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, + cwd: Option, #[serde(default)] fork_context: bool, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 2078c229b..e55dd2d18 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -24,6 +24,8 @@ use codex_protocol::user_input::UserInput; use serde::Serialize; use serde_json::Value as JsonValue; use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; /// Minimum wait timeout to prevent tight polling loops from burning CPU. pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; @@ -193,6 +195,44 @@ pub(crate) fn parse_collab_input( } } +pub(crate) fn resolve_requested_agent_cwd( + parent_cwd: &Path, + requested_cwd: Option<&str>, +) -> Result, FunctionCallError> { + let Some(requested_cwd) = requested_cwd else { + return Ok(None); + }; + + let requested_cwd = requested_cwd.trim(); + if requested_cwd.is_empty() { + return Err(FunctionCallError::RespondToModel( + "spawn_agent cwd cannot be empty".to_string(), + )); + } + + let requested_path = PathBuf::from(requested_cwd); + let resolved = if requested_path.is_absolute() { + requested_path + } else { + parent_cwd.join(requested_path) + }; + + if !resolved.exists() { + return Err(FunctionCallError::RespondToModel(format!( + "spawn_agent cwd {} does not exist", + resolved.display() + ))); + } + if !resolved.is_dir() { + return Err(FunctionCallError::RespondToModel(format!( + "spawn_agent cwd {} is not a directory", + resolved.display() + ))); + } + + Ok(Some(resolved)) +} + /// Builds the base config snapshot for a newly spawned sub-agent. /// /// The returned config starts from the parent's effective config and then refreshes the diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8250d84f3..9e90a8974 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -43,6 +43,7 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::user_input::UserInput; +use core_test_support::PathExt; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde::Deserialize; @@ -324,6 +325,104 @@ async fn spawn_agent_returns_agent_id_without_task_name() { assert_eq!(success, Some(true)); } +#[tokio::test] +async fn spawn_agent_applies_requested_cwd() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let child_workspace = temp_dir.path().join("worker-a"); + std::fs::create_dir(&child_workspace).expect("create child workspace"); + turn.cwd = temp_dir.path().abs(); + + let output = SpawnAgentHandler + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "cwd": "worker-a" + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + assert_eq!(snapshot.cwd, child_workspace); +} + +#[tokio::test] +async fn spawn_agent_rejects_missing_requested_cwd() { + let (session, mut turn) = make_session_and_context().await; + let temp_dir = tempfile::tempdir().expect("tempdir"); + turn.cwd = temp_dir.path().abs(); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "cwd": "missing" + })), + ); + let Err(err) = SpawnAgentHandler.handle(invocation).await else { + panic!("missing cwd should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel(format!( + "spawn_agent cwd {} does not exist", + temp_dir.path().join("missing").display() + )) + ); +} + +#[test] +fn resolve_requested_agent_cwd_rejects_empty_path() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + + let err = resolve_requested_agent_cwd(temp_dir.path(), Some(" ")) + .expect_err("empty cwd should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel("spawn_agent cwd cannot be empty".to_string()) + ); +} + +#[test] +fn resolve_requested_agent_cwd_rejects_non_directory() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let file_path = temp_dir.path().join("worker.txt"); + std::fs::write(&file_path, "hello").expect("write file"); + + let err = resolve_requested_agent_cwd(temp_dir.path(), Some("worker.txt")) + .expect_err("non-directory cwd should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel(format!( + "spawn_agent cwd {} is not a directory", + file_path.display() + )) + ); +} + #[tokio::test] async fn multi_agent_v2_spawn_requires_task_name() { let (mut session, mut turn) = make_session_and_context().await; @@ -515,6 +614,70 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat })); } +#[tokio::test] +async fn multi_agent_v2_spawn_applies_requested_cwd() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + task_name: String, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let temp_dir = tempfile::tempdir().expect("tempdir"); + let child_workspace = temp_dir.path().join("worker-b"); + std::fs::create_dir(&child_workspace).expect("create child workspace"); + turn.cwd = temp_dir.path().abs(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let output = SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker", + "cwd": "worker-b" + })), + )) + .await + .expect("spawn_agent should succeed"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + + let child_thread_id = session + .services + .agent_control + .resolve_agent_reference( + session.conversation_id, + &turn.session_source, + &result.task_name, + ) + .await + .expect("spawned task name should resolve"); + let snapshot = manager + .get_thread(child_thread_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + assert_eq!(snapshot.cwd, child_workspace); +} + #[tokio::test] async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { let (mut session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 77c48af9e..5836ce479 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -82,6 +82,14 @@ impl ToolHandler for Handler { .await .map_err(FunctionCallError::RespondToModel)?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + if let Some(cwd) = resolve_requested_agent_cwd(&turn.cwd, args.cwd.as_deref())? { + config.cwd = + codex_utils_absolute_path::AbsolutePathBuf::try_from(cwd).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "spawn_agent cwd must be absolute: {error}" + )) + })?; + } apply_spawn_agent_overrides(&mut config, child_depth); config.developer_instructions = Some( if let Some(existing_instructions) = config.developer_instructions.take() { @@ -222,6 +230,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, + cwd: Option, fork_turns: Option, fork_context: Option, } diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs new file mode 100644 index 000000000..91a0e9a7d --- /dev/null +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -0,0 +1,563 @@ +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_utils_string::take_bytes_at_char_boundary; +use serde::Deserialize; +use std::path::Path; +use std::path::PathBuf; +use tokio::fs; + +pub struct ReadFileHandler; + +const DEFAULT_LIMIT: usize = 200; +const DEFAULT_OFFSET: usize = 1; +const MAX_LINE_LENGTH: usize = 2_000; + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +fn default_offset() -> usize { + DEFAULT_OFFSET +} + +fn default_max_levels() -> usize { + 1 +} + +fn default_include_header() -> bool { + true +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ReadFileMode { + #[default] + Slice, + Indentation, +} + +#[derive(Clone, Debug, Deserialize)] +struct IndentationArgs { + #[serde(default)] + anchor_line: Option, + #[serde(default)] + include_siblings: bool, + #[serde(default = "default_max_levels")] + max_levels: usize, + #[serde(default = "default_include_header")] + include_header: bool, + #[serde(default)] + max_lines: Option, +} + +impl Default for IndentationArgs { + fn default() -> Self { + Self { + anchor_line: None, + include_siblings: false, + max_levels: default_max_levels(), + include_header: default_include_header(), + max_lines: None, + } + } +} + +#[derive(Debug, Deserialize)] +struct ReadFileArgs { + file_path: String, + #[serde(default = "default_offset")] + offset: usize, + #[serde(default = "default_limit")] + limit: usize, + #[serde(default)] + mode: ReadFileMode, + #[serde(default)] + indentation: Option, +} + +impl ToolHandler for ReadFileHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { payload, turn, .. } = invocation; + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "read_file handler received unsupported payload".to_string(), + )); + } + }; + + let args: ReadFileArgs = parse_arguments(&arguments)?; + validate_offset_limit(args.offset, args.limit)?; + + let path = crate::util::resolve_path(turn.cwd.as_path(), &PathBuf::from(args.file_path)); + let lines = match args.mode { + ReadFileMode::Slice => slice::read(path.as_path(), args.offset, args.limit).await?, + ReadFileMode::Indentation => { + indentation::read_block( + path.as_path(), + args.offset, + args.limit, + args.indentation.unwrap_or_default(), + ) + .await? + } + }; + + Ok(FunctionToolOutput::from_text(lines.join("\n"), Some(true))) + } +} + +#[derive(Clone, Debug)] +struct LineRecord { + text: String, + indent: usize, + is_blank: bool, +} + +impl LineRecord { + fn new(text: String) -> Self { + let is_blank = text.trim().is_empty(); + let indent = text + .chars() + .take_while(|ch| matches!(ch, ' ' | '\t')) + .map(|ch| if ch == '\t' { 4 } else { 1 }) + .sum(); + Self { + text, + indent, + is_blank, + } + } + + fn trimmed(&self) -> &str { + self.text.trim() + } +} + +#[derive(Clone, Copy, Debug)] +struct BlockRange { + start: usize, + actual_start: usize, + end: usize, +} + +async fn load_lines(path: &Path) -> Result, FunctionCallError> { + let bytes = fs::read(path) + .await + .map_err(|err| FunctionCallError::RespondToModel(format!("failed to read file: {err}")))?; + let text = String::from_utf8_lossy(&bytes); + let mut lines = text + .split('\n') + .map(|line| LineRecord::new(line.trim_end_matches('\r').to_string())) + .collect::>(); + if matches!(lines.last(), Some(last) if last.text.is_empty()) { + lines.pop(); + } + Ok(lines) +} + +fn validate_offset_limit(offset: usize, limit: usize) -> Result<(), FunctionCallError> { + if offset == 0 { + return Err(FunctionCallError::RespondToModel( + "offset must be a 1-indexed line number".to_string(), + )); + } + if limit == 0 { + return Err(FunctionCallError::RespondToModel( + "limit must be greater than zero".to_string(), + )); + } + Ok(()) +} + +fn format_output( + lines: &[LineRecord], + start: usize, + end_exclusive: usize, + max_lines: usize, +) -> Vec { + lines[start..end_exclusive] + .iter() + .take(max_lines) + .enumerate() + .map(|(offset, line)| format_line(start + offset + 1, line.text.as_str())) + .collect() +} + +fn format_line(line_number: usize, line: &str) -> String { + let truncated = if line.len() > MAX_LINE_LENGTH { + take_bytes_at_char_boundary(line, MAX_LINE_LENGTH).to_string() + } else { + line.to_string() + }; + format!("L{line_number}: {truncated}") +} + +fn resolve_anchor_index( + lines: &[LineRecord], + anchor_line: usize, +) -> Result { + if anchor_line == 0 { + return Err(FunctionCallError::RespondToModel( + "anchor_line must be a 1-indexed line number".to_string(), + )); + } + let requested = anchor_line - 1; + if requested >= lines.len() { + return Err(FunctionCallError::RespondToModel( + "anchor_line exceeds file length".to_string(), + )); + } + if !lines[requested].is_blank { + return Ok(requested); + } + if let Some(previous) = (0..requested).rev().find(|index| !lines[*index].is_blank) { + return Ok(previous); + } + ((requested + 1)..lines.len()) + .find(|index| !lines[*index].is_blank) + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "cannot infer indentation from an empty file".to_string(), + ) + }) +} + +fn find_ancestor_start(lines: &[LineRecord], anchor_index: usize, levels: usize) -> Option { + let mut current_index = anchor_index; + let mut current_indent = lines[anchor_index].indent; + let mut found = None; + for _ in 0..levels { + let next = (0..current_index) + .rev() + .find(|index| !lines[*index].is_blank && lines[*index].indent < current_indent)?; + found = Some(next); + current_index = next; + current_indent = lines[next].indent; + } + found +} + +fn is_closing_line(line: &str) -> bool { + matches!(line.chars().next(), Some('}') | Some(']') | Some(')')) +} + +fn is_header_line(line: &str) -> bool { + line.starts_with("//") + || line.starts_with("/*") + || line.starts_with('*') + || line.starts_with("#[") + || line.starts_with("#!") + || line.starts_with("///") + || line.starts_with("//!") + || line.starts_with('@') +} + +fn extend_header_upwards(lines: &[LineRecord], actual_start: usize) -> usize { + let indent = lines[actual_start].indent; + let mut start = actual_start; + while start > 0 { + let previous = &lines[start - 1]; + if previous.is_blank || previous.indent != indent || !is_header_line(previous.trimmed()) { + break; + } + start -= 1; + } + start +} + +fn block_range( + lines: &[LineRecord], + actual_start: usize, + section_end: usize, + include_header: bool, +) -> BlockRange { + let start = if include_header { + extend_header_upwards(lines, actual_start) + } else { + actual_start + }; + let end = find_block_end(lines, actual_start, section_end); + BlockRange { + start, + actual_start, + end, + } +} + +fn find_block_end(lines: &[LineRecord], actual_start: usize, section_end: usize) -> usize { + let start_indent = lines[actual_start].indent; + let mut saw_nested = false; + let mut last_included = actual_start; + + for (index, line) in lines + .iter() + .enumerate() + .take(section_end + 1) + .skip(actual_start + 1) + { + if line.is_blank { + last_included = index; + continue; + } + if line.indent > start_indent { + saw_nested = true; + last_included = index; + continue; + } + if line.indent == start_indent && is_closing_line(line.trimmed()) { + return index; + } + if saw_nested { + return last_included; + } + return actual_start; + } + + last_included +} + +fn qualifies_as_block(lines: &[LineRecord], start: usize, section_end: usize) -> bool { + let start_indent = lines[start].indent; + for line in lines.iter().take(section_end + 1).skip(start + 1) { + if line.is_blank { + continue; + } + return line.indent > start_indent; + } + false +} + +mod slice { + use super::FunctionCallError; + use super::LineRecord; + use super::Path; + use super::format_output; + use super::load_lines; + use super::validate_offset_limit; + + pub(super) async fn read( + path: &Path, + offset: usize, + limit: usize, + ) -> Result, FunctionCallError> { + let lines = load_lines(path).await?; + read_loaded(lines.as_slice(), offset, limit) + } + + pub(super) fn read_loaded( + lines: &[LineRecord], + offset: usize, + limit: usize, + ) -> Result, FunctionCallError> { + validate_offset_limit(offset, limit)?; + let start = offset - 1; + if start >= lines.len() { + return Err(FunctionCallError::RespondToModel( + "offset exceeds file length".to_string(), + )); + } + let end = (start + limit).min(lines.len()); + Ok(format_output(lines, start, end, limit)) + } +} + +mod indentation { + use super::BlockRange; + use super::FunctionCallError; + use super::IndentationArgs; + use super::LineRecord; + use super::Path; + use super::block_range; + use super::find_ancestor_start; + use super::format_output; + use super::is_header_line; + use super::load_lines; + use super::qualifies_as_block; + use super::resolve_anchor_index; + use super::slice; + use super::validate_offset_limit; + + pub(super) async fn read_block( + path: &Path, + offset: usize, + limit: usize, + options: IndentationArgs, + ) -> Result, FunctionCallError> { + validate_offset_limit(offset, limit)?; + if options.max_levels == 0 { + return Err(FunctionCallError::RespondToModel( + "indentation.max_levels must be greater than zero".to_string(), + )); + } + + let max_lines = options.max_lines.unwrap_or(limit); + if max_lines == 0 { + return Err(FunctionCallError::RespondToModel( + "indentation.max_lines must be greater than zero".to_string(), + )); + } + + let lines = load_lines(path).await?; + if lines.is_empty() || offset > lines.len() { + return Err(FunctionCallError::RespondToModel( + "offset exceeds file length".to_string(), + )); + } + + let anchor_index = resolve_anchor_index(&lines, options.anchor_line.unwrap_or(offset))?; + let Some(actual_start) = find_ancestor_start(&lines, anchor_index, options.max_levels) + else { + return slice::read_loaded(lines.as_slice(), offset, max_lines); + }; + + let section_end = lines.len() - 1; + let mut selection = block_range(&lines, actual_start, section_end, options.include_header); + if options.include_siblings + && let Some(parent_start) = find_ancestor_start(&lines, actual_start, /*levels*/ 1) + { + let parent_scope = block_range( + &lines, + parent_start, + section_end, + /*include_header*/ false, + ); + selection = + expand_with_siblings(&lines, parent_scope, selection, options.include_header); + } + + let end_exclusive = (selection.end + 1).min(lines.len()); + Ok(format_output( + &lines, + selection.start, + end_exclusive, + max_lines, + )) + } + + fn expand_with_siblings( + lines: &[LineRecord], + parent_scope: BlockRange, + selected: BlockRange, + include_header: bool, + ) -> BlockRange { + let items = collect_scope_items( + lines, + parent_scope, + lines[selected.actual_start].indent, + include_header, + ); + let Some(position) = items.iter().position(|item| match item { + ScopeItem::Block(block) => block.actual_start == selected.actual_start, + ScopeItem::Barrier => false, + }) else { + return selected; + }; + + let mut start = selected.start; + let mut end = selected.end; + + let mut left = position; + while left > 0 { + match items[left - 1] { + ScopeItem::Block(block) => { + start = block.start; + left -= 1; + } + ScopeItem::Barrier => break, + } + } + + let mut right = position; + while right + 1 < items.len() { + match items[right + 1] { + ScopeItem::Block(block) => { + end = block.end; + right += 1; + } + ScopeItem::Barrier => break, + } + } + + BlockRange { + start, + actual_start: selected.actual_start, + end, + } + } + + fn collect_scope_items( + lines: &[LineRecord], + parent_scope: BlockRange, + indent: usize, + include_header: bool, + ) -> Vec { + let mut items = Vec::new(); + let mut index = parent_scope.actual_start.saturating_add(1); + while index <= parent_scope.end { + let line = &lines[index]; + if line.is_blank || line.indent != indent { + index += 1; + continue; + } + + if is_header_line(line.trimmed()) { + let header_start = index; + let mut actual_start = index; + while actual_start <= parent_scope.end + && !lines[actual_start].is_blank + && lines[actual_start].indent == indent + && is_header_line(lines[actual_start].trimmed()) + { + actual_start += 1; + } + if actual_start <= parent_scope.end + && lines[actual_start].indent == indent + && qualifies_as_block(lines, actual_start, parent_scope.end) + { + let mut block = + block_range(lines, actual_start, parent_scope.end, include_header); + block.start = header_start; + items.push(ScopeItem::Block(block)); + index = block.end + 1; + continue; + } + items.push(ScopeItem::Barrier); + index = actual_start; + continue; + } + + if qualifies_as_block(lines, index, parent_scope.end) { + let block = block_range(lines, index, parent_scope.end, include_header); + items.push(ScopeItem::Block(block)); + index = block.end + 1; + } else { + items.push(ScopeItem::Barrier); + index += 1; + } + } + items + } + + #[derive(Clone, Copy, Debug)] + enum ScopeItem { + Block(BlockRange), + Barrier, + } +} + +#[cfg(test)] +#[path = "read_file_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/read_file_tests.rs b/codex-rs/core/src/tools/handlers/read_file_tests.rs index 3921a9882..79f16d705 100644 --- a/codex-rs/core/src/tools/handlers/read_file_tests.rs +++ b/codex-rs/core/src/tools/handlers/read_file_tests.rs @@ -16,7 +16,7 @@ gamma " )?; - let lines = read(temp.path(), 2, 2).await?; + let lines = read(temp.path(), /*offset*/ 2, /*limit*/ 2).await?; assert_eq!(lines, vec!["L2: beta".to_string(), "L3: gamma".to_string()]); Ok(()) } @@ -27,7 +27,7 @@ async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> { use std::io::Write as _; writeln!(temp, "only")?; - let err = read(temp.path(), 3, 1) + let err = read(temp.path(), /*offset*/ 3, /*limit*/ 1) .await .expect_err("offset exceeds length"); assert_eq!( @@ -43,7 +43,7 @@ async fn reads_non_utf8_lines() -> anyhow::Result<()> { use std::io::Write as _; temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?; - let lines = read(temp.path(), 1, 2).await?; + let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 2).await?; let expected_first = format!("L1: {}{}", '\u{FFFD}', '\u{FFFD}'); assert_eq!(lines, vec![expected_first, "L2: plain".to_string()]); Ok(()) @@ -55,7 +55,7 @@ async fn trims_crlf_endings() -> anyhow::Result<()> { use std::io::Write as _; write!(temp, "one\r\ntwo\r\n")?; - let lines = read(temp.path(), 1, 2).await?; + let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 2).await?; assert_eq!(lines, vec!["L1: one".to_string(), "L2: two".to_string()]); Ok(()) } @@ -72,7 +72,7 @@ third " )?; - let lines = read(temp.path(), 1, 2).await?; + let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 2).await?; assert_eq!( lines, vec!["L1: first".to_string(), "L2: second".to_string()] @@ -87,7 +87,7 @@ async fn truncates_lines_longer_than_max_length() -> anyhow::Result<()> { let long_line = "x".repeat(MAX_LINE_LENGTH + 50); writeln!(temp, "{long_line}")?; - let lines = read(temp.path(), 1, 1).await?; + let lines = read(temp.path(), /*offset*/ 1, /*limit*/ 1).await?; let expected = "x".repeat(MAX_LINE_LENGTH); assert_eq!(lines, vec![format!("L1: {expected}")]); Ok(()) @@ -115,7 +115,7 @@ async fn indentation_mode_captures_block() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 3, 10, options).await?; + let lines = read_block(temp.path(), /*offset*/ 3, /*limit*/ 10, options).await?; assert_eq!( lines, @@ -150,7 +150,13 @@ async fn indentation_mode_expands_parents() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 4, 50, options.clone()).await?; + let lines = read_block( + temp.path(), + /*offset*/ 4, + /*limit*/ 50, + options.clone(), + ) + .await?; assert_eq!( lines, vec![ @@ -163,7 +169,7 @@ async fn indentation_mode_expands_parents() -> anyhow::Result<()> { ); options.max_levels = 3; - let expanded = read_block(temp.path(), 4, 50, options).await?; + let expanded = read_block(temp.path(), /*offset*/ 4, /*limit*/ 50, options).await?; assert_eq!( expanded, vec![ @@ -203,7 +209,13 @@ async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 3, 50, options.clone()).await?; + let lines = read_block( + temp.path(), + /*offset*/ 3, + /*limit*/ 50, + options.clone(), + ) + .await?; assert_eq!( lines, vec![ @@ -214,7 +226,7 @@ async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> { ); options.include_siblings = true; - let with_siblings = read_block(temp.path(), 3, 50, options).await?; + let with_siblings = read_block(temp.path(), /*offset*/ 3, /*limit*/ 50, options).await?; assert_eq!( with_siblings, vec![ @@ -257,7 +269,7 @@ class Bar: ..Default::default() }; - let lines = read_block(temp.path(), 1, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 1, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -313,7 +325,7 @@ export function other() {{ ..Default::default() }; - let lines = read_block(temp.path(), 15, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 15, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -385,7 +397,7 @@ async fn indentation_mode_handles_cpp_sample_shallow() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 18, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -413,7 +425,7 @@ async fn indentation_mode_handles_cpp_sample() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 18, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -445,7 +457,7 @@ async fn indentation_mode_handles_cpp_sample_no_headers() -> anyhow::Result<()> ..Default::default() }; - let lines = read_block(temp.path(), 18, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ @@ -476,7 +488,7 @@ async fn indentation_mode_handles_cpp_sample_siblings() -> anyhow::Result<()> { ..Default::default() }; - let lines = read_block(temp.path(), 18, 200, options).await?; + let lines = read_block(temp.path(), /*offset*/ 18, /*limit*/ 200, options).await?; assert_eq!( lines, vec![ diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index dcc9445a9..09c9b2fac 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -6,8 +6,9 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use codex_protocol::request_user_input::RequestUserInputArgs; -use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; -use codex_tools::normalize_request_user_input_args; +use codex_tools::QUESTION_TOOL_NAME; +use codex_tools::normalize_request_user_input_args_for_tool; +use codex_tools::question_unavailable_message; use codex_tools::request_user_input_unavailable_message; pub struct RequestUserInputHandler { @@ -26,6 +27,7 @@ impl ToolHandler for RequestUserInputHandler { session, turn, call_id, + tool_name, payload, .. } = invocation; @@ -34,34 +36,34 @@ impl ToolHandler for RequestUserInputHandler { ToolPayload::Function { arguments } => arguments, _ => { return Err(FunctionCallError::RespondToModel(format!( - "{REQUEST_USER_INPUT_TOOL_NAME} handler received unsupported payload" + "{tool_name} handler received unsupported payload" ))); } }; let mode = session.collaboration_mode().await.mode; - if let Some(message) = - request_user_input_unavailable_message(mode, self.default_mode_request_user_input) - { + let unavailable_message = match tool_name.as_str() { + QUESTION_TOOL_NAME => question_unavailable_message(mode), + _ => request_user_input_unavailable_message(mode, self.default_mode_request_user_input), + }; + if let Some(message) = unavailable_message { return Err(FunctionCallError::RespondToModel(message)); } let args: RequestUserInputArgs = parse_arguments(&arguments)?; - let args = - normalize_request_user_input_args(args).map_err(FunctionCallError::RespondToModel)?; + let args = normalize_request_user_input_args_for_tool(&tool_name, args) + .map_err(FunctionCallError::RespondToModel)?; let response = session .request_user_input(turn.as_ref(), call_id, args) .await .ok_or_else(|| { FunctionCallError::RespondToModel(format!( - "{REQUEST_USER_INPUT_TOOL_NAME} was cancelled before receiving a response" + "{tool_name} was cancelled before receiving a response" )) })?; let content = serde_json::to_string(&response).map_err(|err| { - FunctionCallError::Fatal(format!( - "failed to serialize {REQUEST_USER_INPUT_TOOL_NAME} response: {err}" - )) + FunctionCallError::Fatal(format!("failed to serialize {tool_name} response: {err}")) })?; Ok(FunctionToolOutput::from_text(content, Some(true))) diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 90b9a382a..e05117eb2 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -40,12 +40,14 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::DynamicToolHandler; + use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::JsReplHandler; use crate::tools::handlers::JsReplResetHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; + use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; @@ -154,6 +156,9 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolHandlerKind::FollowupTaskV2 => { builder.register_handler(handler.name, Arc::new(FollowupTaskHandlerV2)); } + ToolHandlerKind::GrepFiles => { + builder.register_handler(handler.name, Arc::new(GrepFilesHandler)); + } ToolHandlerKind::JsRepl => { builder.register_handler(handler.name, js_repl_handler.clone()); } @@ -175,6 +180,9 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolHandlerKind::Plan => { builder.register_handler(handler.name, plan_handler.clone()); } + ToolHandlerKind::ReadFile => { + builder.register_handler(handler.name, Arc::new(ReadFileHandler)); + } ToolHandlerKind::RequestPermissions => { builder.register_handler(handler.name, request_permissions_handler.clone()); } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 4615b4531..0e050265e 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -300,6 +300,7 @@ fn test_build_specs_gpt5_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -323,6 +324,7 @@ fn test_build_specs_gpt51_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -336,6 +338,38 @@ fn test_build_specs_gpt51_codex_default() { ); } +#[test] +fn experimental_read_and_grep_tools_register_handlers() { + let config = test_config(); + let mut model_info = construct_model_info_offline("gpt-5-codex", &config); + model_info.experimental_supported_tools = + vec!["read_file".to_string(), "grep_files".to_string()]; + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + let (tools, registry) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*app_tools*/ None, + &[], + ) + .build(); + + assert!(tools.iter().any(|tool| tool.name() == "read_file")); + assert!(tools.iter().any(|tool| tool.name() == "grep_files")); + assert!(registry.has_handler("read_file", /*namespace*/ None)); + assert!(registry.has_handler("grep_files", /*namespace*/ None)); +} + #[test] fn test_build_specs_gpt5_codex_unified_exec_web_search() { let mut features = Features::with_defaults(); @@ -348,6 +382,7 @@ fn test_build_specs_gpt5_codex_unified_exec_web_search() { "exec_command", "write_stdin", "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -373,6 +408,7 @@ fn test_build_specs_gpt51_codex_unified_exec_web_search() { "exec_command", "write_stdin", "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -396,6 +432,7 @@ fn test_gpt_5_1_codex_max_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -419,6 +456,7 @@ fn test_codex_5_1_mini_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -442,6 +480,7 @@ fn test_gpt_5_defaults() { "shell", &[ "update_plan", + "question", "request_user_input", "web_search", "view_image", @@ -464,6 +503,7 @@ fn test_gpt_5_1_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -489,6 +529,7 @@ fn test_gpt_5_1_codex_max_unified_exec_web_search() { "exec_command", "write_stdin", "update_plan", + "question", "request_user_input", "apply_patch", "web_search", diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 8e30b37c2..181a1fe24 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -30,6 +30,9 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +const QUESTION_TOOL_NAME: &str = "question"; +const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input"; + fn call_output(req: &ResponsesRequest, call_id: &str) -> String { let raw = req.function_call_output(call_id); assert_eq!( @@ -74,6 +77,34 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> } async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Result<()> { + interactive_question_round_trip_for_mode( + REQUEST_USER_INPUT_TOOL_NAME, + mode, + multiple_choice_request_args(), + /*expect_is_other*/ true, + mode == ModeKind::Default, + ) + .await +} + +async fn question_round_trip_for_mode(mode: ModeKind) -> anyhow::Result<()> { + interactive_question_round_trip_for_mode( + QUESTION_TOOL_NAME, + mode, + freeform_question_request_args(), + /*expect_is_other*/ false, + /*enable_default_mode_request_user_input*/ false, + ) + .await +} + +async fn interactive_question_round_trip_for_mode( + tool_name: &str, + mode: ModeKind, + request_args: Value, + expect_is_other: bool, + enable_default_mode_request_user_input: bool, +) -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -87,36 +118,25 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul .. } = builder .with_config(move |config| { - if mode == ModeKind::Default { - config - .features - .enable(Feature::DefaultModeRequestUserInput) - .expect("test config should allow feature update"); + if enable_default_mode_request_user_input { + assert!( + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .is_ok(), + "test config should allow feature update" + ); } }) .build(&server) .await?; - let call_id = "user-input-call"; - let request_args = json!({ - "questions": [{ - "id": "confirm_path", - "header": "Confirm", - "question": "Proceed with the plan?", - "options": [{ - "label": "Yes (Recommended)", - "description": "Continue the current plan." - }, { - "label": "No", - "description": "Stop and revisit the approach." - }] - }] - }) - .to_string(); + let call_id = format!("{tool_name}-call"); + let request_args = request_args.to_string(); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "request_user_input", &request_args), + ev_function_call(&call_id, tool_name, &request_args), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; @@ -163,7 +183,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul .await; assert_eq!(request.call_id, call_id); assert_eq!(request.questions.len(), 1); - assert_eq!(request.questions[0].is_other, true); + assert_eq!(request.questions[0].is_other, expect_is_other); let mut answers = HashMap::new(); answers.insert( @@ -183,7 +203,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let req = second_mock.single_request(); - let output_text = call_output(&req, call_id); + let output_text = call_output(&req, &call_id); let output_json: Value = serde_json::from_str(&output_text)?; assert_eq!( output_json, @@ -198,6 +218,43 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul } async fn assert_request_user_input_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()> +where + F: FnOnce(String) -> CollaborationMode, +{ + assert_interactive_question_rejected( + REQUEST_USER_INPUT_TOOL_NAME, + mode_name, + multiple_choice_request_args(), + format!("request_user_input is unavailable in {mode_name} mode"), + /*enable_default_mode_request_user_input*/ false, + build_mode, + ) + .await +} + +async fn assert_question_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()> +where + F: FnOnce(String) -> CollaborationMode, +{ + assert_interactive_question_rejected( + QUESTION_TOOL_NAME, + mode_name, + freeform_question_request_args(), + format!("question is unavailable in {mode_name} mode"), + /*enable_default_mode_request_user_input*/ false, + build_mode, + ) + .await +} + +async fn assert_interactive_question_rejected( + tool_name: &str, + mode_name: &str, + request_args: Value, + expected_output: String, + enable_default_mode_request_user_input: bool, + build_mode: F, +) -> anyhow::Result<()> where F: FnOnce(String) -> CollaborationMode, { @@ -205,35 +262,34 @@ where let server = start_mock_server().await; - let mut builder = test_codex(); + let builder = test_codex(); let TestCodex { codex, cwd, session_configured, .. - } = builder.build(&server).await?; + } = builder + .with_config(move |config| { + if enable_default_mode_request_user_input { + assert!( + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .is_ok(), + "test config should allow feature update" + ); + } + }) + .build(&server) + .await?; let mode_slug = mode_name.to_lowercase().replace(' ', "-"); - let call_id = format!("user-input-{mode_slug}-call"); - let request_args = json!({ - "questions": [{ - "id": "confirm_path", - "header": "Confirm", - "question": "Proceed with the plan?", - "options": [{ - "label": "Yes (Recommended)", - "description": "Continue the current plan." - }, { - "label": "No", - "description": "Stop and revisit the approach." - }] - }] - }) - .to_string(); + let call_id = format!("{tool_name}-{mode_slug}-call"); + let request_args = request_args.to_string(); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_function_call(&call_id, "request_user_input", &request_args), + ev_function_call(&call_id, tool_name, &request_args), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; @@ -272,14 +328,38 @@ where let req = second_mock.single_request(); let (output, success) = call_output_content_and_success(&req, &call_id); assert_eq!(success, None); - assert_eq!( - output, - format!("request_user_input is unavailable in {mode_name} mode") - ); + assert_eq!(output, expected_output); Ok(()) } +fn multiple_choice_request_args() -> Value { + json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) +} + +fn freeform_question_request_args() -> Value { + json!({ + "questions": [{ + "id": "details", + "header": "Details", + "question": "What should we keep?" + }] + }) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_rejected_in_execute_mode_alias() -> anyhow::Result<()> { assert_request_user_input_rejected("Execute", |model| CollaborationMode { @@ -323,3 +403,39 @@ async fn request_user_input_rejected_in_pair_mode_alias() -> anyhow::Result<()> }) .await } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn question_round_trip_resolves_pending_in_plan_mode() -> anyhow::Result<()> { + question_round_trip_for_mode(ModeKind::Plan).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn question_round_trip_resolves_pending_in_default_mode() -> anyhow::Result<()> { + question_round_trip_for_mode(ModeKind::Default).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn question_rejected_in_execute_mode_alias() -> anyhow::Result<()> { + assert_question_rejected("Execute", |model| CollaborationMode { + mode: ModeKind::Execute, + settings: Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn question_rejected_in_pair_mode_alias() -> anyhow::Result<()> { + assert_question_rejected("Pair Programming", |model| CollaborationMode { + mode: ModeKind::PairProgramming, + settings: Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }, + }) + .await +} diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 3dd27c8f9..1fe4f3627 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -83,6 +83,7 @@ ignore = [ # TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities. { id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, { id = "RUSTSEC-2025-0141", reason = "bincode is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is pulled in only by PR5's clawbot/feishu tail via openlark; upstream stable openlark has not removed it yet, so this advisory is temporarily ignored until that dependency can be upgraded separately" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. diff --git a/codex-rs/models-manager/src/collaboration_mode_presets.rs b/codex-rs/models-manager/src/collaboration_mode_presets.rs index f67ab91db..5404374c1 100644 --- a/codex-rs/models-manager/src/collaboration_mode_presets.rs +++ b/codex-rs/models-manager/src/collaboration_mode_presets.rs @@ -55,7 +55,6 @@ fn default_preset(collaboration_modes_config: CollaborationModesConfig) -> Colla fn default_mode_instructions(collaboration_modes_config: CollaborationModesConfig) -> String { let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES); let request_user_input_availability = request_user_input_availability_message( - ModeKind::Default, collaboration_modes_config.default_mode_request_user_input, ); let asking_questions_guidance = asking_questions_guidance_message( @@ -86,27 +85,22 @@ fn format_mode_names(modes: &[ModeKind]) -> String { } } -fn request_user_input_availability_message( - mode: ModeKind, - default_mode_request_user_input: bool, -) -> String { - let mode_name = mode.display_name(); - if mode.allows_request_user_input() - || (default_mode_request_user_input && mode == ModeKind::Default) - { - format!("The `request_user_input` tool is available in {mode_name} mode.") +fn request_user_input_availability_message(default_mode_request_user_input: bool) -> String { + let mode_name = ModeKind::Default.display_name(); + if default_mode_request_user_input { + format!("The `question` and `request_user_input` tools are available in {mode_name} mode.") } else { format!( - "The `request_user_input` tool is unavailable in {mode_name} mode. If you call it while in {mode_name} mode, it will return an error." + "The `question` tool is available in {mode_name} mode. The legacy `request_user_input` tool is unavailable in {mode_name} mode and will return an error." ) } } fn asking_questions_guidance_message(default_mode_request_user_input: bool) -> String { if default_mode_request_user_input { - "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `request_user_input` tool rather than writing a multiple choice question as a textual assistant message. Never write a multiple choice question as a textual assistant message.".to_string() + "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `question` tool rather than writing a multiple choice question as a textual assistant message. The legacy `request_user_input` tool is still available in Default mode. Never write a multiple choice question as a textual assistant message.".to_string() } else { - "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.".to_string() + "In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `question` tool rather than writing a multiple choice question as a textual assistant message. For a single simple clarification, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.".to_string() } } diff --git a/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs b/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs index 361c51762..cc4542133 100644 --- a/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs +++ b/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs @@ -31,12 +31,11 @@ fn default_mode_instructions_replace_mode_names_placeholder() { let expected_snippet = format!("Known mode names are {known_mode_names}."); assert!(default_instructions.contains(&expected_snippet)); - let expected_availability_message = request_user_input_availability_message( - ModeKind::Default, - /*default_mode_request_user_input*/ true, - ); + let expected_availability_message = + request_user_input_availability_message(/*default_mode_request_user_input*/ true); assert!(default_instructions.contains(&expected_availability_message)); - assert!(default_instructions.contains("prefer using the `request_user_input` tool")); + assert!(default_instructions.contains("prefer using the `question` tool")); + assert!(default_instructions.contains("legacy `request_user_input` tool is still available")); } #[test] @@ -47,6 +46,7 @@ fn default_mode_instructions_use_plain_text_questions_when_feature_disabled() { .expect("default instructions should be set"); assert!(!default_instructions.contains("prefer using the `request_user_input` tool")); + assert!(default_instructions.contains("prefer using the `question` tool")); assert!( default_instructions.contains("ask the user directly with a concise plain-text question") ); diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 3ffdeb48e..e11abca1e 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -220,6 +220,16 @@ impl CodexErr { CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code: self.http_status_code_value(), }, + CodexErr::UnexpectedStatus(err) => match err.status.as_u16() { + 401 | 403 => CodexErrorInfo::Unauthorized, + 429 => CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(429), + }, + 500..=599 => CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(err.status.as_u16()), + }, + _ => CodexErrorInfo::Other, + }, CodexErr::ConnectionFailed(_) => CodexErrorInfo::HttpConnectionFailed { http_status_code: self.http_status_code_value(), }, diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 0b1d18972..3ccaf6a06 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -71,6 +71,41 @@ fn server_overloaded_maps_to_protocol() { ); } +#[test] +fn unexpected_status_503_maps_to_response_too_many_failed_attempts() { + let err = CodexErr::UnexpectedStatus(UnexpectedResponseError { + status: StatusCode::SERVICE_UNAVAILABLE, + body: "Service temporarily unavailable".to_string(), + url: Some("https://example.com/v1/responses".to_string()), + cf_ray: None, + request_id: None, + identity_authorization_error: None, + identity_error_code: None, + }); + + assert_eq!( + err.to_codex_protocol_error(), + CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(503), + } + ); +} + +#[test] +fn unexpected_status_401_maps_to_unauthorized() { + let err = CodexErr::UnexpectedStatus(UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: "Unauthorized".to_string(), + url: Some("https://example.com/v1/responses".to_string()), + cf_ray: None, + request_id: None, + identity_authorization_error: None, + identity_error_code: None, + }); + + assert_eq!(err.to_codex_protocol_error(), CodexErrorInfo::Unauthorized); +} + #[test] fn sandbox_denied_uses_aggregated_output_when_stderr_empty() { let output = ExecToolCallOutput { diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index cafb4b89a..419cd0424 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -26,7 +26,7 @@ schema and Responses API tool primitives that no longer need to live in - MCP resource, `list_dir`, and `test_sync_tool` spec builders - local host tool spec builders for shell/exec/request-permissions/view-image - collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close, - `request_user_input`, and CSV fanout/reporting + `question`, `request_user_input`, and CSV fanout/reporting - discoverable-tool models, client filtering, and `ToolSpec` builders for `tool_search` and `tool_suggest` - `parse_tool_input_schema()` diff --git a/codex-rs/tools/src/agent_tool.rs b/codex-rs/tools/src/agent_tool.rs index 017cc6e5e..1570206ac 100644 --- a/codex-rs/tools/src/agent_tool.rs +++ b/codex-rs/tools/src/agent_tool.rs @@ -590,6 +590,15 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap ToolSpec { + create_interactive_question_tool( + QUESTION_TOOL_NAME, + QuestionToolSchema { + questions_description: "Questions to show the user. There is no fixed maximum; use as many as needed for the form.", + prompt_description: "Prompt shown to the user for this field.", + options_description: "Optional mutually exclusive choices for this question. Omit this field for a freeform text answer. When provided, put the recommended option first and do not include an \"Other\" option; the client can collect additional notes separately.", + options_required: false, + }, + description, + ) +} + pub fn create_request_user_input_tool(description: String) -> ToolSpec { + create_interactive_question_tool( + REQUEST_USER_INPUT_TOOL_NAME, + QuestionToolSchema { + questions_description: "Questions to show the user. Prefer 1 and do not exceed 3", + prompt_description: "Single-sentence prompt shown to the user.", + options_description: "Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically.", + options_required: true, + }, + description, + ) +} + +struct QuestionToolSchema { + questions_description: &'static str, + prompt_description: &'static str, + options_description: &'static str, + options_required: bool, +} + +fn create_interactive_question_tool( + name: &str, + schema: QuestionToolSchema, + description: String, +) -> ToolSpec { let option_props = BTreeMap::from([ ( "label".to_string(), @@ -27,10 +71,7 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { ]); let options_schema = JsonSchema::Array { - description: Some( - "Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically." - .to_string(), - ), + description: Some(schema.options_description.to_string()), items: Box::new(JsonSchema::Object { properties: option_props, required: Some(vec!["label".to_string(), "description".to_string()]), @@ -58,22 +99,27 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { ( "question".to_string(), JsonSchema::String { - description: Some("Single-sentence prompt shown to the user.".to_string()), + description: Some(schema.prompt_description.to_string()), }, ), ("options".to_string(), options_schema), ]); let questions_schema = JsonSchema::Array { - description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()), + description: Some(schema.questions_description.to_string()), items: Box::new(JsonSchema::Object { properties: question_props, - required: Some(vec![ - "id".to_string(), - "header".to_string(), - "question".to_string(), - "options".to_string(), - ]), + required: Some({ + let mut required = vec![ + "id".to_string(), + "header".to_string(), + "question".to_string(), + ]; + if schema.options_required { + required.push("options".to_string()); + } + required + }), additional_properties: Some(false.into()), }), }; @@ -81,7 +127,7 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { let properties = BTreeMap::from([("questions".to_string(), questions_schema)]); ToolSpec::Function(ResponsesApiTool { - name: REQUEST_USER_INPUT_TOOL_NAME.to_string(), + name: name.to_string(), description, strict: false, defer_loading: None, @@ -94,6 +140,10 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec { }) } +fn question_is_available(mode: ModeKind) -> bool { + mode.allows_request_user_input() || mode == ModeKind::Default +} + pub fn request_user_input_unavailable_message( mode: ModeKind, default_mode_request_user_input: bool, @@ -108,28 +158,81 @@ pub fn request_user_input_unavailable_message( } } +pub fn question_unavailable_message(mode: ModeKind) -> Option { + if question_is_available(mode) { + None + } else { + let mode_name = mode.display_name(); + Some(format!("question is unavailable in {mode_name} mode")) + } +} + +fn tool_is_available( + tool_name: &str, + mode: ModeKind, + default_mode_request_user_input: bool, +) -> bool { + match tool_name { + QUESTION_TOOL_NAME => question_is_available(mode), + _ => request_user_input_is_available(mode, default_mode_request_user_input), + } +} + +fn question_options_policy(tool_name: &str) -> QuestionOptionsPolicy { + match tool_name { + QUESTION_TOOL_NAME => QuestionOptionsPolicy::AllowFreeform, + _ => QuestionOptionsPolicy::RequireOptions, + } +} + pub fn normalize_request_user_input_args( + args: RequestUserInputArgs, +) -> Result { + normalize_request_user_input_args_for_tool(REQUEST_USER_INPUT_TOOL_NAME, args) +} + +pub fn normalize_request_user_input_args_for_tool( + tool_name: &str, mut args: RequestUserInputArgs, ) -> Result { - let missing_options = args - .questions - .iter() - .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)); - if missing_options { - return Err("request_user_input requires non-empty options for every question".to_string()); + for question in &mut args.questions { + if question.options.as_ref().is_some_and(Vec::is_empty) { + question.options = None; + } + if question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()) + { + question.is_other = true; + } } - for question in &mut args.questions { - question.is_other = true; + if question_options_policy(tool_name) == QuestionOptionsPolicy::RequireOptions + && args + .questions + .iter() + .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)) + { + return Err("request_user_input requires non-empty options for every question".to_string()); } Ok(args) } pub fn request_user_input_tool_description(default_mode_request_user_input: bool) -> String { - let allowed_modes = format_allowed_modes(default_mode_request_user_input); - format!( - "Request user input for one to three short questions and wait for the response. This tool is only available in {allowed_modes}." + interactive_question_tool_description( + REQUEST_USER_INPUT_TOOL_NAME, + "Request user input for one to three short questions and wait for the response.", + default_mode_request_user_input, + ) +} + +pub fn question_tool_description(default_mode_request_user_input: bool) -> String { + interactive_question_tool_description( + QUESTION_TOOL_NAME, + "Ask the user a structured form with as many questions as needed and wait for the response. The client will render choices and/or text fields automatically.", + default_mode_request_user_input, ) } @@ -138,10 +241,19 @@ fn request_user_input_is_available(mode: ModeKind, default_mode_request_user_inp || (default_mode_request_user_input && mode == ModeKind::Default) } -fn format_allowed_modes(default_mode_request_user_input: bool) -> String { +fn interactive_question_tool_description( + tool_name: &str, + tool_description: &str, + default_mode_request_user_input: bool, +) -> String { + let allowed_modes = format_allowed_modes(tool_name, default_mode_request_user_input); + format!("{tool_description} This tool is only available in {allowed_modes}.") +} + +fn format_allowed_modes(tool_name: &str, default_mode_request_user_input: bool) -> String { let mode_names: Vec<&str> = TUI_VISIBLE_COLLABORATION_MODES .into_iter() - .filter(|mode| request_user_input_is_available(*mode, default_mode_request_user_input)) + .filter(|mode| tool_is_available(tool_name, *mode, default_mode_request_user_input)) .map(ModeKind::display_name) .collect(); diff --git a/codex-rs/tools/src/request_user_input_tool_tests.rs b/codex-rs/tools/src/request_user_input_tool_tests.rs index e7a305f86..17379d774 100644 --- a/codex-rs/tools/src/request_user_input_tool_tests.rs +++ b/codex-rs/tools/src/request_user_input_tool_tests.rs @@ -1,14 +1,116 @@ use super::*; use codex_protocol::config_types::ModeKind; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputQuestion; +use codex_protocol::request_user_input::RequestUserInputQuestionOption; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +#[test] +fn question_tool_includes_questions_schema() { + assert_eq!( + create_question_tool("Ask the user for details.".to_string()), + ToolSpec::Function(ResponsesApiTool { + name: QUESTION_TOOL_NAME.to_string(), + description: "Ask the user for details.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([( + "questions".to_string(), + JsonSchema::Array { + description: Some( + "Questions to show the user. There is no fixed maximum; use as many as needed for the form." + .to_string(), + ), + items: Box::new(JsonSchema::Object { + properties: BTreeMap::from([ + ( + "header".to_string(), + JsonSchema::String { + description: Some( + "Short header label shown in the UI (12 or fewer chars)." + .to_string(), + ), + }, + ), + ( + "id".to_string(), + JsonSchema::String { + description: Some( + "Stable identifier for mapping answers (snake_case)." + .to_string(), + ), + }, + ), + ( + "options".to_string(), + JsonSchema::Array { + description: Some( + "Optional mutually exclusive choices for this question. Omit this field for a freeform text answer. When provided, put the recommended option first and do not include an \"Other\" option; the client can collect additional notes separately." + .to_string(), + ), + items: Box::new(JsonSchema::Object { + properties: BTreeMap::from([ + ( + "description".to_string(), + JsonSchema::String { + description: Some( + "One short sentence explaining impact/tradeoff if selected." + .to_string(), + ), + }, + ), + ( + "label".to_string(), + JsonSchema::String { + description: Some( + "User-facing label (1-5 words)." + .to_string(), + ), + }, + ), + ]), + required: Some(vec![ + "label".to_string(), + "description".to_string(), + ]), + additional_properties: Some(false.into()), + }), + }, + ), + ( + "question".to_string(), + JsonSchema::String { + description: Some( + "Prompt shown to the user for this field.".to_string(), + ), + }, + ), + ]), + required: Some(vec![ + "id".to_string(), + "header".to_string(), + "question".to_string(), + ]), + additional_properties: Some(false.into()), + }), + }, + )]), + required: Some(vec!["questions".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) + ); +} + #[test] fn request_user_input_tool_includes_questions_schema() { assert_eq!( create_request_user_input_tool("Ask the user to choose.".to_string()), ToolSpec::Function(ResponsesApiTool { - name: "request_user_input".to_string(), + name: REQUEST_USER_INPUT_TOOL_NAME.to_string(), description: "Ask the user to choose.".to_string(), strict: false, defer_loading: None, @@ -102,6 +204,20 @@ fn request_user_input_tool_includes_questions_schema() { ); } +#[test] +fn question_unavailable_messages_respect_mode_rules() { + assert_eq!(question_unavailable_message(ModeKind::Plan), None); + assert_eq!(question_unavailable_message(ModeKind::Default), None); + assert_eq!( + question_unavailable_message(ModeKind::Execute), + Some("question is unavailable in Execute mode".to_string()) + ); + assert_eq!( + question_unavailable_message(ModeKind::PairProgramming), + Some("question is unavailable in Pair Programming mode".to_string()) + ); +} + #[test] fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() { assert_eq!( @@ -141,6 +257,18 @@ fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() { ); } +#[test] +fn question_tool_description_mentions_available_modes() { + assert_eq!( + question_tool_description(/*default_mode_request_user_input*/ false), + "Ask the user a structured form with as many questions as needed and wait for the response. The client will render choices and/or text fields automatically. This tool is only available in Default or Plan mode.".to_string() + ); + assert_eq!( + question_tool_description(/*default_mode_request_user_input*/ true), + "Ask the user a structured form with as many questions as needed and wait for the response. The client will render choices and/or text fields automatically. This tool is only available in Default or Plan mode.".to_string() + ); +} + #[test] fn request_user_input_tool_description_mentions_available_modes() { assert_eq!( @@ -152,3 +280,95 @@ fn request_user_input_tool_description_mentions_available_modes() { "Request user input for one to three short questions and wait for the response. This tool is only available in Default or Plan mode.".to_string() ); } + +#[test] +fn normalize_question_args_allows_freeform_questions() { + let args = RequestUserInputArgs { + questions: vec![RequestUserInputQuestion { + id: "details".to_string(), + header: "Details".to_string(), + question: "What changed?".to_string(), + is_other: false, + is_secret: false, + options: None, + }], + }; + + assert_eq!( + normalize_request_user_input_args_for_tool(QUESTION_TOOL_NAME, args.clone()), + Ok(args) + ); +} + +#[test] +fn normalize_question_args_marks_multiple_choice_entries_as_other() { + let args = RequestUserInputArgs { + questions: vec![ + RequestUserInputQuestion { + id: "details".to_string(), + header: "Details".to_string(), + question: "What changed?".to_string(), + is_other: false, + is_secret: false, + options: Some(Vec::new()), + }, + RequestUserInputQuestion { + id: "confirm".to_string(), + header: "Confirm".to_string(), + question: "Continue?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![RequestUserInputQuestionOption { + label: "Yes".to_string(), + description: "Keep going.".to_string(), + }]), + }, + ], + }; + + assert_eq!( + normalize_request_user_input_args_for_tool(QUESTION_TOOL_NAME, args), + Ok(RequestUserInputArgs { + questions: vec![ + RequestUserInputQuestion { + id: "details".to_string(), + header: "Details".to_string(), + question: "What changed?".to_string(), + is_other: false, + is_secret: false, + options: None, + }, + RequestUserInputQuestion { + id: "confirm".to_string(), + header: "Confirm".to_string(), + question: "Continue?".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![RequestUserInputQuestionOption { + label: "Yes".to_string(), + description: "Keep going.".to_string(), + }]), + }, + ], + }) + ); +} + +#[test] +fn normalize_request_user_input_args_requires_options() { + let args = RequestUserInputArgs { + questions: vec![RequestUserInputQuestion { + id: "confirm".to_string(), + header: "Confirm".to_string(), + question: "Continue?".to_string(), + is_other: false, + is_secret: false, + options: None, + }], + }; + + assert_eq!( + normalize_request_user_input_args(args), + Err("request_user_input requires non-empty options for every question".to_string()) + ); +} diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index cc706f24f..def3718c6 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -1,4 +1,5 @@ use crate::CommandToolOptions; +use crate::QUESTION_TOOL_NAME; use crate::REQUEST_USER_INPUT_TOOL_NAME; use crate::ShellToolOptions; use crate::SpawnAgentToolOptions; @@ -23,6 +24,7 @@ use crate::create_close_agent_tool_v2; use crate::create_code_mode_tool; use crate::create_exec_command_tool; use crate::create_followup_task_tool; +use crate::create_grep_files_tool; use crate::create_image_generation_tool; use crate::create_js_repl_reset_tool; use crate::create_js_repl_tool; @@ -31,6 +33,8 @@ use crate::create_list_dir_tool; use crate::create_list_mcp_resource_templates_tool; use crate::create_list_mcp_resources_tool; use crate::create_local_shell_tool; +use crate::create_question_tool; +use crate::create_read_file_tool; use crate::create_read_mcp_resource_tool; use crate::create_report_agent_job_result_tool; use crate::create_request_permissions_tool; @@ -55,6 +59,7 @@ use crate::create_web_search_tool; use crate::create_write_stdin_tool; use crate::dynamic_tool_to_responses_api_tool; use crate::mcp_tool_to_responses_api_tool; +use crate::question_tool_description; use crate::request_permissions_tool_description; use crate::request_user_input_tool_description; use crate::tool_registry_plan_types::agent_type_description; @@ -205,6 +210,14 @@ pub fn build_tool_registry_plan( } if config.request_user_input { + plan.push_spec( + create_question_tool(question_tool_description( + config.default_mode_request_user_input, + )), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler(QUESTION_TOOL_NAME, ToolHandlerKind::RequestUserInput); plan.push_spec( create_request_user_input_tool(request_user_input_tool_description( config.default_mode_request_user_input, @@ -285,6 +298,32 @@ pub fn build_tool_registry_plan( plan.register_handler("apply_patch", ToolHandlerKind::ApplyPatch); } + if config + .experimental_supported_tools + .iter() + .any(|tool| tool == "grep_files") + { + plan.push_spec( + create_grep_files_tool(), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + plan.register_handler("grep_files", ToolHandlerKind::GrepFiles); + } + + if config + .experimental_supported_tools + .iter() + .any(|tool| tool == "read_file") + { + plan.push_spec( + create_read_file_tool(), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + plan.register_handler("read_file", ToolHandlerKind::ReadFile); + } + if config .experimental_supported_tools .iter() diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index fd7ab1007..7aeb60853 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -81,6 +81,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { }), create_write_stdin_tool(), create_update_plan_tool(), + question_tool_spec(/*default_mode_request_user_input*/ false), request_user_input_tool_spec(/*default_mode_request_user_input*/ false), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { @@ -176,6 +177,7 @@ fn test_build_specs_collab_tools_enabled() { panic!("spawn_agent should use object params"); }; assert!(properties.contains_key("fork_context")); + assert!(properties.contains_key("cwd")); assert!(!properties.contains_key("fork_turns")); } @@ -234,6 +236,7 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { assert!(properties.contains_key("task_name")); assert!(properties.contains_key("message")); assert!(properties.contains_key("fork_turns")); + assert!(properties.contains_key("cwd")); assert!(!properties.contains_key("items")); assert!(!properties.contains_key("fork_context")); assert_eq!( @@ -491,11 +494,12 @@ fn test_build_specs_agent_job_worker_tools_enabled() { "report_agent_job_result", ], ); + assert_lacks_tool_name(&tools, QUESTION_TOOL_NAME); assert_lacks_tool_name(&tools, "request_user_input"); } #[test] -fn request_user_input_description_reflects_default_mode_feature_flag() { +fn interactive_question_tools_reflect_default_mode_feature_flag() { let model_info = model_info(); let mut features = Features::with_defaults(); let available_models = Vec::new(); @@ -514,6 +518,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { /*app_tools*/ None, &[], ); + let question_tool = find_tool(&tools, QUESTION_TOOL_NAME); + assert_eq!( + question_tool.spec, + question_tool_spec(/*default_mode_request_user_input*/ false) + ); let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); assert_eq!( request_user_input_tool.spec, @@ -536,6 +545,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { /*app_tools*/ None, &[], ); + let question_tool = find_tool(&tools, QUESTION_TOOL_NAME); + assert_eq!( + question_tool.spec, + question_tool_spec(/*default_mode_request_user_input*/ true) + ); let request_user_input_tool = find_tool(&tools, REQUEST_USER_INPUT_TOOL_NAME); assert_eq!( request_user_input_tool.spec, @@ -1024,6 +1038,33 @@ fn test_test_model_info_includes_sync_tool() { assert!(tools.iter().any(|tool| tool.name() == "test_sync_tool")); } +#[test] +fn test_model_info_includes_read_and_grep_tools() { + let mut model_info = model_info(); + model_info.experimental_supported_tools = + vec!["read_file".to_string(), "grep_files".to_string()]; + let features = Features::with_defaults(); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*app_tools*/ None, + &[], + ); + + assert!(tools.iter().any(|tool| tool.name() == "read_file")); + assert!(tools.iter().any(|tool| tool.name() == "grep_files")); +} + #[test] fn test_build_specs_mcp_tools_converted() { let model_info = model_info(); @@ -1810,6 +1851,10 @@ fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) { ); } +fn question_tool_spec(default_mode_request_user_input: bool) -> ToolSpec { + create_question_tool(question_tool_description(default_mode_request_user_input)) +} + fn request_user_input_tool_spec(default_mode_request_user_input: bool) -> ToolSpec { create_request_user_input_tool(request_user_input_tool_description( default_mode_request_user_input, diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index d15cf15d5..7fed3b9e9 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -18,6 +18,7 @@ pub enum ToolHandlerKind { CodeModeWait, DynamicTool, FollowupTaskV2, + GrepFiles, JsRepl, JsReplReset, ListAgentsV2, @@ -25,6 +26,7 @@ pub enum ToolHandlerKind { Mcp, McpResource, Plan, + ReadFile, RequestPermissions, RequestUserInput, ResumeAgentV1, diff --git a/codex-rs/tools/src/utility_tool.rs b/codex-rs/tools/src/utility_tool.rs index dca9d312f..c64d8b9c5 100644 --- a/codex-rs/tools/src/utility_tool.rs +++ b/codex-rs/tools/src/utility_tool.rs @@ -51,6 +51,164 @@ pub fn create_list_dir_tool() -> ToolSpec { }) } +pub fn create_grep_files_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "pattern".to_string(), + JsonSchema::String { + description: Some("Regular expression pattern to search for.".to_string()), + }, + ), + ( + "include".to_string(), + JsonSchema::String { + description: Some( + "Optional glob that limits which files are searched (e.g. \"*.rs\" or \ + \"*.{ts,tsx}\")." + .to_string(), + ), + }, + ), + ( + "path".to_string(), + JsonSchema::String { + description: Some( + "Directory or file path to search. Defaults to the session's working directory." + .to_string(), + ), + }, + ), + ( + "limit".to_string(), + JsonSchema::Number { + description: Some( + "Maximum number of file paths to return (defaults to 100).".to_string(), + ), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "grep_files".to_string(), + description: "Finds files whose contents match the pattern and lists them by modification \ + time." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["pattern".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) +} + +pub fn create_read_file_tool() -> ToolSpec { + let indentation_properties = BTreeMap::from([ + ( + "anchor_line".to_string(), + JsonSchema::Number { + description: Some( + "Anchor line to center the indentation lookup on (defaults to offset)." + .to_string(), + ), + }, + ), + ( + "max_levels".to_string(), + JsonSchema::Number { + description: Some( + "How many parent indentation levels (smaller indents) to include.".to_string(), + ), + }, + ), + ( + "include_siblings".to_string(), + JsonSchema::Boolean { + description: Some( + "When true, include additional blocks that share the anchor indentation." + .to_string(), + ), + }, + ), + ( + "include_header".to_string(), + JsonSchema::Boolean { + description: Some( + "Include doc comments or attributes directly above the selected block." + .to_string(), + ), + }, + ), + ( + "max_lines".to_string(), + JsonSchema::Number { + description: Some( + "Hard cap on the number of lines returned when using indentation mode." + .to_string(), + ), + }, + ), + ]); + + let properties = BTreeMap::from([ + ( + "file_path".to_string(), + JsonSchema::String { + description: Some("Absolute path to the file".to_string()), + }, + ), + ( + "offset".to_string(), + JsonSchema::Number { + description: Some( + "The line number to start reading from. Must be 1 or greater.".to_string(), + ), + }, + ), + ( + "limit".to_string(), + JsonSchema::Number { + description: Some("The maximum number of lines to return.".to_string()), + }, + ), + ( + "mode".to_string(), + JsonSchema::String { + description: Some( + "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \ + to expand around an anchor line." + .to_string(), + ), + }, + ), + ( + "indentation".to_string(), + JsonSchema::Object { + properties: indentation_properties, + required: None, + additional_properties: Some(false.into()), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "read_file".to_string(), + description: + "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["file_path".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) +} + pub fn create_test_sync_tool() -> ToolSpec { let barrier_properties = BTreeMap::from([ ( diff --git a/codex-rs/tools/src/utility_tool_tests.rs b/codex-rs/tools/src/utility_tool_tests.rs index a8ea6777f..9e116b189 100644 --- a/codex-rs/tools/src/utility_tool_tests.rs +++ b/codex-rs/tools/src/utility_tool_tests.rs @@ -58,6 +58,173 @@ fn list_dir_tool_matches_expected_spec() { ); } +#[test] +fn grep_files_tool_matches_expected_spec() { + assert_eq!( + create_grep_files_tool(), + ToolSpec::Function(ResponsesApiTool { + name: "grep_files".to_string(), + description: "Finds files whose contents match the pattern and lists them by modification \ + time." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([ + ( + "include".to_string(), + JsonSchema::String { + description: Some( + "Optional glob that limits which files are searched (e.g. \"*.rs\" or \ + \"*.{ts,tsx}\")." + .to_string(), + ), + }, + ), + ( + "limit".to_string(), + JsonSchema::Number { + description: Some( + "Maximum number of file paths to return (defaults to 100)." + .to_string(), + ), + }, + ), + ( + "path".to_string(), + JsonSchema::String { + description: Some( + "Directory or file path to search. Defaults to the session's working directory." + .to_string(), + ), + }, + ), + ( + "pattern".to_string(), + JsonSchema::String { + description: Some( + "Regular expression pattern to search for.".to_string(), + ), + }, + ), + ]), + required: Some(vec!["pattern".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) + ); +} + +#[test] +fn read_file_tool_matches_expected_spec() { + assert_eq!( + create_read_file_tool(), + ToolSpec::Function(ResponsesApiTool { + name: "read_file".to_string(), + description: + "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties: BTreeMap::from([ + ( + "file_path".to_string(), + JsonSchema::String { + description: Some("Absolute path to the file".to_string()), + }, + ), + ( + "indentation".to_string(), + JsonSchema::Object { + properties: BTreeMap::from([ + ( + "anchor_line".to_string(), + JsonSchema::Number { + description: Some( + "Anchor line to center the indentation lookup on (defaults to offset)." + .to_string(), + ), + }, + ), + ( + "include_header".to_string(), + JsonSchema::Boolean { + description: Some( + "Include doc comments or attributes directly above the selected block." + .to_string(), + ), + }, + ), + ( + "include_siblings".to_string(), + JsonSchema::Boolean { + description: Some( + "When true, include additional blocks that share the anchor indentation." + .to_string(), + ), + }, + ), + ( + "max_levels".to_string(), + JsonSchema::Number { + description: Some( + "How many parent indentation levels (smaller indents) to include." + .to_string(), + ), + }, + ), + ( + "max_lines".to_string(), + JsonSchema::Number { + description: Some( + "Hard cap on the number of lines returned when using indentation mode." + .to_string(), + ), + }, + ), + ]), + required: None, + additional_properties: Some(false.into()), + }, + ), + ( + "limit".to_string(), + JsonSchema::Number { + description: Some( + "The maximum number of lines to return.".to_string(), + ), + }, + ), + ( + "mode".to_string(), + JsonSchema::String { + description: Some( + "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \ + to expand around an anchor line." + .to_string(), + ), + }, + ), + ( + "offset".to_string(), + JsonSchema::Number { + description: Some( + "The line number to start reading from. Must be 1 or greater." + .to_string(), + ), + }, + ), + ]), + required: Some(vec!["file_path".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: None, + }) + ); +} + #[test] fn test_sync_tool_matches_expected_spec() { assert_eq!( diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 696ab503b..3ace51038 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -30,6 +30,7 @@ codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } +codex-clawbot = { workspace = true } codex-cloud-requirements = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } @@ -52,6 +53,7 @@ codex-utils-cli = { workspace = true } codex-utils-elapsed = { workspace = true } codex-utils-fuzzy-match = { workspace = true } codex-utils-oss = { workspace = true } +codex-utils-rustls-provider = { workspace = true } codex-utils-sandbox-summary = { workspace = true } codex-utils-sleep-inhibitor = { workspace = true } codex-utils-string = { workspace = true } @@ -62,8 +64,11 @@ diffy = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] } +humantime = "2" +indexmap = { workspace = true, features = ["serde"] } itertools = { workspace = true } lazy_static = { workspace = true } +notify = { workspace = true } pathdiff = { workspace = true } pulldown-cmark = { workspace = true } rand = { workspace = true } @@ -79,6 +84,7 @@ reqwest = { workspace = true, features = ["json"] } rmcp = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } +serde_yaml = { workspace = true } shlex = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4feefcac9..146d929fe 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5,6 +5,7 @@ use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event::FeedbackCategory; use crate::app_event::RealtimeAudioDeviceKind; +use crate::app_event::RuntimeProfileTarget; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; @@ -25,6 +26,11 @@ use crate::chatwidget::ReplayKind; use crate::chatwidget::ThreadInputState; use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; +use crate::display_preferences::DisplayPreferences; +use crate::display_preferences::display_preference_edit; +use crate::display_preferences::set_display_preference_in_config; +use crate::display_preferences_menu::DISPLAY_PREFERENCES_SELECTION_VIEW_ID; +use crate::display_preferences_menu::display_preferences_panel_params; use crate::exec_command::split_command_string; use crate::exec_command::strip_bash_lc_and_escape; use crate::external_editor; @@ -42,6 +48,7 @@ use crate::multi_agents::format_agent_picker_item_name; use crate::multi_agents::next_agent_shortcut_matches; use crate::multi_agents::previous_agent_shortcut_matches; use crate::pager_overlay::Overlay; +use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH; use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -82,6 +89,11 @@ use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; +use codex_clawbot::PendingClawbotTurn; +#[cfg(test)] +use codex_clawbot::ProviderOutboundReaction; +#[cfg(test)] +use codex_clawbot::ProviderOutboundTextMessage; use codex_config::types::ApprovalsReviewer; use codex_config::types::ModelAvailabilityNuxConfig; use codex_core::config::Config; @@ -132,6 +144,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; @@ -148,22 +161,53 @@ use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::unbounded_channel; use tokio::task::JoinHandle; +#[cfg(test)] +use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; use uuid::Uuid; mod agent_navigation; mod app_server_adapter; mod app_server_requests; +mod btw; +mod clawbot; +mod clawbot_controls; +mod jump_navigation; +mod key_chord; mod loaded_threads; mod pending_interactive_replay; +mod profile_management; +mod thread_menu; +mod workflow_controls; +mod workflow_definition; +mod workflow_editor; +mod workflow_file_watch; +mod workflow_history; +pub(crate) mod workflow_runtime; +mod workflow_scheduler; +mod workflow_yaml; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; +use self::btw::BtwSessionState; +use self::key_chord::KeyChordAction; +use self::key_chord::KeyChordResolution; +use self::key_chord::KeyChordState; use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; +use self::workflow_file_watch::WorkflowFileWatchState; +use self::workflow_history::WorkflowHistoryState; +use self::workflow_scheduler::WorkflowSchedulerState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; +const PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Copy)] +enum ThreadLivenessRefreshMode { + Picker, + Selection, +} enum ThreadInteractiveRequest { Approval(ApprovalRequest), @@ -236,6 +280,36 @@ fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[Str } } +fn workflow_after_turn_last_agent_message( + primary_thread_id: Option, + thread_id: ThreadId, + notification: &ServerNotification, +) -> Option> { + if primary_thread_id != Some(thread_id) { + return None; + } + let ServerNotification::TurnCompleted(notification) = notification else { + return None; + }; + if !matches!( + notification.turn.status, + TurnStatus::Completed | TurnStatus::Failed + ) { + return None; + } + Some(last_agent_message_for_turn(¬ification.turn)) +} + +fn last_agent_message_for_turn(turn: &Turn) -> Option { + turn.items.iter().fold(None, |_, item| match item { + ThreadItem::AgentMessage { text, .. } => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + _ => None, + }) +} + fn default_exec_approval_decisions( network_approval_context: Option<&codex_protocol::protocol::NetworkApprovalContext>, proposed_execpolicy_amendment: Option<&codex_protocol::approvals::ExecPolicyAmendment>, @@ -270,6 +344,30 @@ fn guardian_approvals_mode() -> GuardianApprovalsMode { sandbox_policy: SandboxPolicy::new_workspace_write_policy(), } } + +fn should_respawn_with_yolo(config: &Config) -> bool { + config.permissions.approval_policy.value() == AskForApproval::Never + && *config.permissions.sandbox_policy.get() == SandboxPolicy::DangerFullAccess +} + +#[cfg(unix)] +fn spawn_respawn_signal_listener(app_event_tx: AppEventSender) -> std::io::Result<()> { + use tokio::signal::unix::SignalKind; + + let mut signal = tokio::signal::unix::signal(SignalKind::user_defined1())?; + tokio::spawn(async move { + if signal.recv().await.is_some() { + app_event_tx.send(AppEvent::Exit(ExitMode::RespawnImmediate)); + } + }); + Ok(()) +} + +#[cfg(not(unix))] +fn spawn_respawn_signal_listener(_app_event_tx: AppEventSender) -> std::io::Result<()> { + Ok(()) +} + /// Baseline cadence for periodic stream commit animation ticks. /// /// Smooth-mode streaming drains one line per tick, so this interval controls @@ -282,6 +380,7 @@ pub struct AppExitInfo { pub thread_id: Option, pub thread_name: Option, pub update_action: Option, + pub respawn_with_yolo: bool, pub exit_reason: ExitReason, } @@ -292,6 +391,7 @@ impl AppExitInfo { thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::Fatal(message.into()), } } @@ -306,6 +406,7 @@ pub(crate) enum AppRunControl { #[derive(Debug, Clone)] pub enum ExitReason { UserRequested, + RespawnRequested, Fatal(String), } @@ -581,10 +682,10 @@ impl ThreadEventStore { ServerNotification::TurnStarted(turn) => { self.active_turn_id = Some(turn.turn.id.clone()); } - ServerNotification::TurnCompleted(turn) => { - if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) { - self.active_turn_id = None; - } + ServerNotification::TurnCompleted(turn) + if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) => + { + self.active_turn_id = None; } ServerNotification::ThreadClosed(_) => { self.active_turn_id = None; @@ -926,6 +1027,7 @@ async fn handle_model_migration_prompt_if_needed( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -968,6 +1070,8 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + key_chord: KeyChordState, + display_preferences: DisplayPreferences, /// When set, the next draw re-renders the transcript into terminal scrollback once. /// /// This is used after a confirmed thread rollback to ensure scrollback reflects the trimmed @@ -1002,6 +1106,19 @@ pub(crate) struct App { primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, + workflow_thread_notification_channels: workflow_runtime::WorkflowThreadNotificationChannels, + workflow_file_watch: Option, + workflow_scheduler: WorkflowSchedulerState, + workflow_history: WorkflowHistoryState, + btw_session: Option, + btw_closing_thread_ids: HashSet, + clawbot_workspace_root: Option, + clawbot_provider_task: Option>, + clawbot_pending_turns: HashMap>, + #[cfg(test)] + clawbot_outbound_messages: Vec, + #[cfg(test)] + clawbot_outbound_reactions: Vec, } #[derive(Default)] @@ -1079,6 +1196,7 @@ impl App { ) -> crate::chatwidget::ChatWidgetInit { crate::chatwidget::ChatWidgetInit { config: cfg, + display_preferences: self.display_preferences.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), // Fork/resume bootstraps here don't carry any prefilled message content. @@ -1116,7 +1234,9 @@ impl App { .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; self.apply_runtime_policy_overrides(&mut config); + self.active_profile = config.active_profile.clone(); self.config = config; + self.display_preferences.sync_from_config(&self.config); self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) } @@ -1131,6 +1251,99 @@ impl App { } } + async fn apply_runtime_config_change( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + next_config: Config, + reload_live_thread: bool, + ) -> std::result::Result<(), String> { + if !reload_live_thread || self.chat_widget.thread_id().is_none() { + self.active_profile = next_config.active_profile.clone(); + self.config = next_config; + self.display_preferences.sync_from_config(&self.config); + self.chat_widget + .sync_config_for_profile_switch(&self.config); + tui.set_notification_method(self.config.tui_notification_method); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + return Ok(()); + } + + if self.chat_widget.is_task_running() { + return Err("Cannot switch API profile while a task is in progress.".to_string()); + } + + let input_state = self.chat_widget.capture_thread_input_state(); + let can_resume_live_thread = self + .chat_widget + .rollout_path() + .as_ref() + .is_some_and(|path| path.exists()); + let thread_id = self + .chat_widget + .thread_id() + .ok_or_else(|| "No active thread to reload after switching profiles.".to_string())?; + self.close_active_thread_for_profile_reload(app_server, thread_id) + .await?; + let next_thread = if can_resume_live_thread { + app_server + .resume_thread(next_config.clone(), thread_id) + .await + .map_err(|err| { + format!("Failed to reload current session after switching profiles: {err}") + })? + } else { + app_server.start_thread(&next_config).await.map_err(|err| { + format!("Failed to start a fresh session after switching profiles: {err}") + })? + }; + self.active_profile = next_config.active_profile.clone(); + self.config = next_config; + self.display_preferences.sync_from_config(&self.config); + tui.set_notification_method(self.config.tui_notification_method); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + self.replace_chat_widget_with_app_server_thread(tui, app_server, next_thread) + .await + .map_err(|err| err.to_string())?; + self.chat_widget.restore_thread_input_state(input_state); + Ok(()) + } + + async fn reload_user_config_for_app_server_runtime( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + ) -> Result<()> { + let current_cwd = self.chat_widget.config_ref().cwd.to_path_buf(); + let mut next_config = self.rebuild_config_for_cwd(current_cwd).await?; + self.apply_runtime_policy_overrides(&mut next_config); + + let reload_live_thread = Self::routed_profile_runtime_changed(&self.config, &next_config); + if reload_live_thread { + self.apply_runtime_config_change( + tui, + app_server, + next_config, + /*reload_live_thread*/ true, + ) + .await + .map_err(|err| color_eyre::eyre::eyre!(err))?; + app_server.reload_user_config().await?; + } else { + app_server.reload_user_config().await?; + self.apply_runtime_config_change( + tui, + app_server, + next_config, + /*reload_live_thread*/ false, + ) + .await + .map_err(|err| color_eyre::eyre::eyre!(err))?; + } + Ok(()) + } async fn rebuild_config_for_resume_or_fallback( &mut self, current_cwd: &Path, @@ -1539,13 +1752,141 @@ impl App { async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) { if let Some(thread_id) = self.chat_widget.thread_id() { + let shutting_down_primary = self.primary_thread_id == Some(thread_id); // Clear any in-flight rollback guard when switching threads. self.backtrack.pending_rollback = None; if let Err(err) = app_server.thread_unsubscribe(thread_id).await { tracing::warn!("failed to unsubscribe thread {thread_id}: {err}"); } self.abort_thread_event_listener(thread_id); + if shutting_down_primary { + let stopped_count = self.workflow_scheduler.stop_active_workflow_runs().await; + if stopped_count > 0 { + self.sync_background_workflow_status(); + } + } + } + } + + fn background_workflow_labels(&self) -> Vec { + self.workflow_scheduler.background_workflow_labels() + } + + fn queued_trigger_labels(&self) -> Vec { + self.workflow_scheduler.queued_trigger_labels() + } + + fn sync_background_workflow_status(&mut self) { + self.chat_widget.sync_background_workflow_status( + self.background_workflow_labels(), + self.queued_trigger_labels(), + ); + self.refresh_workflow_controls_if_active(); + } + + fn insert_visible_history_cell(&mut self, tui: &mut tui::Tui, cell: Arc) { + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(cell.clone()); + tui.frame_requester().schedule_frame(); + } + self.transcript_cells.push(cell.clone()); + let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); + if display.is_empty() { + return; + } + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + + #[cfg(test)] + fn start_test_background_workflow_run( + &mut self, + workflow_name: String, + target_name: String, + is_trigger: bool, + ) -> String { + let run_id = self + .workflow_scheduler + .next_background_run_id(&workflow_name, &target_name); + let handle = tokio::spawn(async { + std::future::pending::<()>().await; + }); + let target = if is_trigger { + workflow_runtime::BackgroundWorkflowRunTarget::Trigger { + workflow_name, + trigger_id: target_name, + phase_context: workflow_runtime::OwnedWorkflowPhaseContext::default(), + overlap_behavior: workflow_runtime::WorkflowTriggerOverlapBehavior::Queue, + } + } else { + workflow_runtime::BackgroundWorkflowRunTarget::Job { + workflow_name, + job_name: target_name, + } + }; + self.workflow_scheduler.register_background_workflow_run( + run_id.clone(), + target, + CancellationToken::new(), + handle, + ); + self.sync_background_workflow_status(); + run_id + } + + #[cfg(test)] + fn start_test_manual_workflow_trigger_run( + &mut self, + workflow_name: String, + trigger_id: String, + ) -> Option { + if self.workflow_scheduler.has_running_trigger_run() { + self.workflow_scheduler.enqueue_trigger_run( + workflow_name, + trigger_id, + workflow_runtime::OwnedWorkflowPhaseContext::default(), + ); + self.sync_background_workflow_status(); + None + } else { + Some(self.start_test_background_workflow_run( + workflow_name, + trigger_id, + /*is_trigger*/ true, + )) + } + } + + #[cfg(test)] + async fn finish_test_background_workflow_run(&mut self, run_id: String) { + let Some(run) = self + .workflow_scheduler + .take_background_workflow_run(&run_id) + else { + return; + }; + run.handle.abort(); + let _ = run.handle.await; + if run.is_trigger + && let Some(next) = self.workflow_scheduler.dequeue_trigger_run() + { + self.start_test_background_workflow_run( + next.workflow_name, + next.trigger_id, + /*is_trigger*/ true, + ); } + self.sync_background_workflow_status(); } fn abort_thread_event_listener(&mut self, thread_id: ThreadId) { @@ -1824,6 +2165,7 @@ impl App { async fn submit_active_thread_op( &mut self, + tui: &mut tui::Tui, app_server: &mut AppServerSession, op: AppCommand, ) -> Result<()> { @@ -1833,15 +2175,19 @@ impl App { return Ok(()); }; - self.submit_thread_op(app_server, thread_id, op).await + self.submit_thread_op(tui, app_server, thread_id, op).await } async fn submit_thread_op( &mut self, + tui: &mut tui::Tui, app_server: &mut AppServerSession, thread_id: ThreadId, op: AppCommand, ) -> Result<()> { + let (op, workflow_cells) = self + .augment_primary_thread_op_with_before_turn_workflows(app_server, thread_id, op) + .await; crate::session_log::log_outbound_op(&op); if self.try_handle_local_history_op(thread_id, &op).await? { @@ -1855,6 +2201,12 @@ impl App { return Ok(()); } + if matches!(op.view(), AppCommandView::ReloadUserConfig) { + self.reload_user_config_for_app_server_runtime(tui, app_server) + .await?; + return Ok(()); + } + if self .try_submit_active_thread_op_via_app_server(app_server, thread_id, &op) .await? @@ -1863,6 +2215,11 @@ impl App { self.note_thread_outbound_op(thread_id, &op).await; self.refresh_pending_thread_approvals().await; } + for cell in workflow_cells { + if let Some(visible_cell) = self.record_workflow_history_cell(thread_id, cell) { + self.insert_visible_history_cell(tui, visible_cell); + } + } return Ok(()); } @@ -2362,6 +2719,11 @@ impl App { app_server .thread_background_terminals_clean(thread_id) .await?; + let stopped_workflow_runs = + self.workflow_scheduler.stop_active_workflow_runs().await; + if stopped_workflow_runs > 0 { + self.sync_background_workflow_status(); + } Ok(true) } AppCommandView::RealtimeConversationStart(params) => { @@ -2718,6 +3080,7 @@ impl App { self.chat_widget.handle_thread_session(session); self.chat_widget .replay_thread_turns(turns, ReplayKind::ResumeInitialMessages); + self.queue_workflow_history_replay_for_thread(thread_id); let pending = std::mem::take(&mut self.pending_primary_events); for pending_event in pending { match pending_event { @@ -2833,7 +3196,11 @@ impl App { } for thread_id in thread_ids { if !self - .refresh_agent_picker_thread_liveness(app_server, thread_id) + .refresh_agent_picker_thread_liveness( + app_server, + thread_id, + ThreadLivenessRefreshMode::Picker, + ) .await { continue; @@ -2897,6 +3264,22 @@ impl App { }); } + fn open_display_preferences_panel(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(DISPLAY_PREFERENCES_SELECTION_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + DISPLAY_PREFERENCES_SELECTION_VIEW_ID, + display_preferences_panel_params(&self.display_preferences, initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(display_preferences_panel_params( + &self.display_preferences, + initial_selected_idx, + )); + } + } + fn is_terminal_thread_read_error(err: &color_eyre::Report) -> bool { err.chain() .any(|cause| cause.to_string().contains("thread not loaded:")) @@ -2909,6 +3292,18 @@ impl App { Self::is_terminal_thread_read_error(err) || existing_is_closed.unwrap_or(false) } + fn closed_state_for_thread_read_status( + status: &codex_app_server_protocol::ThreadStatus, + mode: ThreadLivenessRefreshMode, + ) -> bool { + match mode { + ThreadLivenessRefreshMode::Picker => { + matches!(status, codex_app_server_protocol::ThreadStatus::NotLoaded) + } + ThreadLivenessRefreshMode::Selection => false, + } + } + fn can_fallback_from_include_turns_error(err: &color_eyre::Report) -> bool { err.chain().any(|cause| { let message = cause.to_string(); @@ -2951,6 +3346,7 @@ impl App { &mut self, app_server: &mut AppServerSession, thread_id: ThreadId, + mode: ThreadLivenessRefreshMode, ) -> bool { let existing_entry = self.agent_navigation.get(&thread_id).cloned(); let has_replay_channel = self.thread_event_channels.contains_key(&thread_id); @@ -2971,17 +3367,29 @@ impl App { .as_ref() .and_then(|entry| entry.agent_role.clone()) }), - matches!( - thread.status, - codex_app_server_protocol::ThreadStatus::NotLoaded - ), + Self::closed_state_for_thread_read_status(&thread.status, mode), ); true } Err(err) => { if Self::is_terminal_thread_read_error(&err) && !has_replay_channel { - self.agent_navigation.remove(thread_id); - return false; + match mode { + ThreadLivenessRefreshMode::Picker => { + self.agent_navigation.remove(thread_id); + return false; + } + ThreadLivenessRefreshMode::Selection => { + if let Some(entry) = existing_entry { + self.upsert_agent_picker_thread( + thread_id, + entry.agent_nickname, + entry.agent_role, + /*is_closed*/ false, + ); + } + return true; + } + } } let is_closed = Self::closed_state_for_thread_read_error( &err, @@ -3146,7 +3554,11 @@ impl App { } if !self - .refresh_agent_picker_thread_liveness(app_server, thread_id) + .refresh_agent_picker_thread_liveness( + app_server, + thread_id, + ThreadLivenessRefreshMode::Selection, + ) .await { self.chat_widget @@ -3222,7 +3634,7 @@ impl App { }; self.chat_widget.add_info_message(message, /*hint*/ None); } - self.drain_active_thread_events(tui).await?; + self.drain_active_thread_events(tui, app_server).await?; self.refresh_pending_thread_approvals().await; Ok(()) @@ -3328,6 +3740,7 @@ impl App { self.enqueue_primary_thread_session(started.session, started.turns) .await?; self.backfill_loaded_subagent_threads(app_server).await; + self.sync_clawbot_workspace(app_server).await; Ok(()) } @@ -3439,7 +3852,11 @@ impl App { config } - async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { + async fn drain_active_thread_events( + &mut self, + tui: &mut tui::Tui, + app_server: &AppServerSession, + ) -> Result<()> { let Some(mut rx) = self.active_thread_rx.take() else { return Ok(()); }; @@ -3447,7 +3864,32 @@ impl App { let mut disconnected = false; loop { match rx.try_recv() { - Ok(event) => self.handle_thread_event_now(event), + Ok(event) => { + let after_turn = + self.active_thread_id + .and_then(|active_thread_id| match &event { + ThreadBufferedEvent::Notification(notification) => { + workflow_after_turn_last_agent_message( + self.primary_thread_id, + active_thread_id, + notification, + ) + } + _ => None, + }); + self.handle_thread_event_now(event); + if let Some(last_agent_message) = after_turn { + for cell in self + .handle_primary_thread_turn_complete_for_workflows( + app_server, + last_agent_message, + ) + .await + { + self.insert_visible_history_cell(tui, cell); + } + } + } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { disconnected = true; @@ -3513,6 +3955,9 @@ impl App { for event in snapshot.events { self.handle_thread_event_replay(event); } + if let Some(thread_id) = self.active_thread_id { + self.queue_workflow_history_replay_for_thread(thread_id); + } self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); self.chat_widget @@ -3565,6 +4010,7 @@ impl App { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); + spawn_respawn_signal_listener(app_event_tx.clone())?; emit_project_config_warnings(&app_event_tx, &config); emit_system_bwrap_warning(&app_event_tx, &config); tui.set_notification_method(config.tui_notification_method); @@ -3631,6 +4077,7 @@ impl App { let status_line_invalid_items_warned = Arc::new(AtomicBool::new(false)); let terminal_title_invalid_items_warned = Arc::new(AtomicBool::new(false)); + let display_preferences = DisplayPreferences::from_config(&config); let enhanced_keys_supported = tui.enhanced_keys_supported(); let wait_for_initial_session_configured = @@ -3643,6 +4090,7 @@ impl App { .await; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), + display_preferences: display_preferences.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), initial_user_message: crate::chatwidget::create_initial_user_message( @@ -3677,6 +4125,7 @@ impl App { })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), + display_preferences: display_preferences.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), initial_user_message: crate::chatwidget::create_initial_user_message( @@ -3716,6 +4165,7 @@ impl App { })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), + display_preferences: display_preferences.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), initial_user_message: crate::chatwidget::create_initial_user_message( @@ -3758,6 +4208,7 @@ impl App { app_event_tx, chat_widget, config, + display_preferences, active_profile, cli_kv_overrides, harness_overrides, @@ -3773,6 +4224,7 @@ impl App { status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), backtrack: BacktrackState::default(), + key_chord: KeyChordState::default(), backtrack_render_pending: false, feedback: feedback.clone(), feedback_audience, @@ -3791,11 +4243,35 @@ impl App { primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_thread_notification_channels: Arc::new( + tokio::sync::Mutex::new(HashMap::new()), + ), + workflow_file_watch: None, + workflow_scheduler: WorkflowSchedulerState::default(), + workflow_history: WorkflowHistoryState::default(), + btw_session: None, + btw_closing_thread_ids: HashSet::new(), + clawbot_workspace_root: None, + clawbot_provider_task: None, + clawbot_pending_turns: HashMap::new(), + #[cfg(test)] + clawbot_outbound_messages: Vec::new(), + #[cfg(test)] + clawbot_outbound_reactions: Vec::new(), }; + match WorkflowFileWatchState::new(app.config.cwd.as_path(), app.app_event_tx.clone()) { + Ok(state) => { + app.workflow_file_watch = Some(state); + } + Err(err) => { + tracing::warn!("failed to start workflow file watcher: {err}"); + } + } if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) .await?; } + app.sync_clawbot_workspace(&mut app_server).await; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. #[cfg(target_os = "windows")] @@ -3917,6 +4393,7 @@ impl App { } } }; + app.abort_clawbot_provider_runtime(); if let Err(err) = app_server.shutdown().await { tracing::warn!(error = %err, "failed to shut down embedded app server"); } @@ -3938,6 +4415,7 @@ impl App { thread_id: app.chat_widget.thread_id(), thread_name: app.chat_widget.thread_name(), update_action: app.pending_update_action, + respawn_with_yolo: should_respawn_with_yolo(&app.config), exit_reason, }) } @@ -4046,8 +4524,9 @@ impl App { match crate::resume_picker::run_resume_picker_with_app_server( tui, &self.config, - /*show_all*/ false, - /*include_non_interactive*/ false, + crate::resume_picker::SessionPickerOrder::LocalGroupFirst, + crate::resume_picker::SessionPickerProviderScope::AllProviders, + /*include_non_interactive*/ true, picker_app_server, ) .await? @@ -4146,6 +4625,34 @@ impl App { // Leaving alt-screen may blank the inline viewport; force a redraw either way. tui.frame_requester().schedule_frame(); } + AppEvent::OpenDisplayPreferencesPanel => { + self.open_display_preferences_panel(); + } + AppEvent::OpenProfileManagementPanel => { + self.open_profile_management_panel(); + } + AppEvent::EditProfileFallbackConfig => { + self.edit_profile_fallback_config_from_ui(tui).await; + } + AppEvent::OpenThreadPanel => { + self.open_thread_panel(); + } + AppEvent::OpenJumpToMessagePanel => { + self.open_jump_to_message_panel(); + } + AppEvent::JumpToTranscriptCell { cell_index } => { + self.reset_backtrack_state(); + self.backtrack.overlay_preview_active = false; + if !matches!(self.overlay, Some(Overlay::Transcript(_))) { + self.open_transcript_overlay(tui); + } + if let Some(Overlay::Transcript(overlay)) = &mut self.overlay + && cell_index < self.transcript_cells.len() + { + overlay.set_highlight_cell(Some(cell_index)); + tui.frame_requester().schedule_frame(); + } + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", @@ -4205,93 +4712,347 @@ impl App { tui.frame_requester().schedule_frame(); } + AppEvent::UndoLastUserMessage => { + self.undo_last_user_message(); + } + AppEvent::SwitchRuntimeProfile { target } => { + let is_default_target = matches!(&target, RuntimeProfileTarget::Default); + let target_profile = match &target { + RuntimeProfileTarget::Default => None, + RuntimeProfileTarget::Named(profile_id) => Some(profile_id.as_str()), + }; + let target_label = target_profile.unwrap_or("default"); + if let Err(err) = self + .switch_runtime_profile(tui, app_server, target_profile) + .await + { + self.chat_widget.add_error_message(format!( + "Failed to switch to profile `{target_label}`: {err}" + )); + } else if let Err(err) = self.profile_router_store().update(|state| { + state.set_runtime_active_profile(target_profile); + }) { + self.chat_widget.add_error_message(format!( + "Switched to profile `{target_label}`, but failed to persist {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + } else if is_default_target { + self.chat_widget.add_info_message( + "Switched to the default config profile.".to_string(), + /*hint*/ None, + ); + } else { + self.chat_widget.add_info_message( + format!("Switched to profile `{target_label}`."), + /*hint*/ None, + ); + } + } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_cell(cell.clone()); - tui.frame_requester().schedule_frame(); - } - self.transcript_cells.push(cell.clone()); - let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); - if !display.is_empty() { - // Only insert a separating blank line for new cells that are not - // part of an ongoing stream. Streaming continuations should not - // accrue extra blank lines between chunks. - if !cell.is_stream_continuation() { - if self.has_emitted_history_lines { - display.insert(0, Line::from("")); - } else { - self.has_emitted_history_lines = true; - } - } + self.insert_visible_history_cell(tui, cell); + } + AppEvent::ReplayWorkflowHistory { thread_id } => { + if self.active_thread_id == Some(thread_id) { + let lines = self.replay_workflow_history_cells_for_thread( + thread_id, + tui.terminal.last_known_screen_size.width, + ); if self.overlay.is_some() { - self.deferred_history_lines.extend(display); - } else { - tui.insert_history_lines(display); + self.deferred_history_lines.extend(lines); + } else if !lines.is_empty() { + tui.insert_history_lines(lines); } } } - AppEvent::ApplyThreadRollback { num_turns } => { - if self.apply_non_pending_thread_rollback(num_turns) { - tui.frame_requester().schedule_frame(); + AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => { + let cells = self + .finish_background_workflow_run(app_server, run_id, *result) + .await; + for cell in cells { + self.insert_visible_history_cell(tui, cell); } } - AppEvent::StartCommitAnimation => { - if self - .commit_anim_running - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_ok() - { - let tx = self.app_event_tx.clone(); - let running = self.commit_anim_running.clone(); - thread::spawn(move || { - while running.load(Ordering::Relaxed) { - thread::sleep(COMMIT_ANIMATION_TICK); - tx.send(AppEvent::CommitTick); - } - }); + AppEvent::StartBtwDiscussion { prompt } => { + self.start_btw_discussion(app_server, prompt).await; + } + AppEvent::BtwCompleted { thread_id, result } => { + let is_error = result.is_err(); + self.finish_btw_discussion(thread_id, result); + if is_error { + self.close_btw_session(app_server).await; } } - AppEvent::StopCommitAnimation => { - self.commit_anim_running.store(false, Ordering::Release); + AppEvent::BtwInsertSummary => { + self.insert_btw_summary(app_server).await; } - AppEvent::CommitTick => { - self.chat_widget.on_commit_tick(); + AppEvent::BtwInsertFull => { + self.insert_btw_full(app_server).await; } - AppEvent::Exit(mode) => { - return Ok(self.handle_exit_mode(app_server, mode).await); + AppEvent::BtwDiscard => { + self.discard_btw_session(app_server).await; } - AppEvent::FatalExitRequest(message) => { - return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); + AppEvent::RetryLastUserTurnWithProfileFallback { + action, + error_message, + } => { + self.retry_last_user_turn_with_profile_fallback( + tui, + app_server, + action, + error_message, + ) + .await; } - AppEvent::CodexOp(op) => { - self.submit_active_thread_op(app_server, op.into()).await?; + AppEvent::OpenWorkflowControls => { + self.open_workflow_controls_popup(); } - AppEvent::SubmitThreadOp { thread_id, op } => { - self.submit_thread_op(app_server, thread_id, op.into()) - .await?; + AppEvent::OpenWorkflowControlView { destination } => { + self.open_workflow_control_view(destination); } - AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { - self.enqueue_thread_history_entry_response(thread_id, event) - .await?; + AppEvent::CreateDefaultWorkflowTemplate => { + self.create_default_workflow_template_from_ui(tui).await; } - AppEvent::DiffResult(text) => { - // Clear the in-progress state in the bottom pane - self.chat_widget.on_diff_complete(); - // Enter alternate screen using TUI helper and build pager lines - let _ = tui.enter_alt_screen(); - let pager_lines: Vec> = if text.trim().is_empty() { - vec!["No changes detected.".italic().into()] - } else { - text.lines().map(ansi_escape_line).collect() - }; - self.overlay = Some(Overlay::new_static_with_lines( - pager_lines, - "D I F F".to_string(), - )); - tui.frame_requester().schedule_frame(); + AppEvent::EditWorkflowFile { + workflow_path, + reopen, + } => { + self.edit_workflow_file_from_ui(tui, workflow_path, reopen) + .await; } - AppEvent::OpenAppLink { + AppEvent::ToggleWorkflowTriggerEnabled { + workflow_path, + trigger_id, + } => { + self.toggle_workflow_trigger_enabled_from_ui(workflow_path, trigger_id); + } + AppEvent::ToggleWorkflowJobEnabled { + workflow_path, + job_name, + } => { + self.toggle_workflow_job_enabled_from_ui(workflow_path, job_name); + } + AppEvent::CycleWorkflowJobContext { + workflow_path, + job_name, + } => { + self.cycle_workflow_job_context_from_ui(workflow_path, job_name); + } + AppEvent::CycleWorkflowJobResponse { + workflow_path, + job_name, + } => { + self.cycle_workflow_job_response_from_ui(workflow_path, job_name); + } + AppEvent::EditWorkflowJobField { + workflow_path, + job_name, + field, + } => { + self.edit_workflow_job_field_from_ui(tui, workflow_path, job_name, field) + .await; + } + AppEvent::SetWorkflowTriggerType { + workflow_path, + trigger_id, + trigger_type, + } => { + self.set_workflow_trigger_type_from_ui(workflow_path, trigger_id, trigger_type); + } + AppEvent::EditWorkflowTriggerField { + workflow_path, + trigger_id, + field, + } => { + self.edit_workflow_trigger_field_from_ui(tui, workflow_path, trigger_id, field) + .await; + } + AppEvent::WorkflowWorkspaceFilesChanged { changed_paths } => { + let relevant_paths = changed_paths + .into_iter() + .filter(|path| { + workflow_file_watch::is_relevant_workspace_change( + self.config.cwd.as_path(), + path.as_path(), + ) + }) + .collect::>(); + if !relevant_paths.is_empty() { + let cells = self.handle_workspace_file_changes_for_workflows( + app_server, + relevant_paths.as_slice(), + ); + for cell in cells { + self.insert_visible_history_cell(tui, cell); + } + } + } + AppEvent::StartManualWorkflowTrigger { + workflow_name, + trigger_id, + } => { + let cell = self.start_manual_workflow_trigger_from_ui( + app_server, + workflow_name, + trigger_id, + ); + self.insert_visible_history_cell(tui, cell); + } + AppEvent::StartManualWorkflowJob { + workflow_name, + job_name, + } => { + let cell = + self.start_manual_workflow_job_from_ui(app_server, workflow_name, job_name); + self.insert_visible_history_cell(tui, cell); + } + AppEvent::ShowWorkflowBackgroundTasks => { + self.chat_widget.add_ps_output(); + } + AppEvent::ClawbotProviderEvent { event } => { + if let Err(err) = self.handle_clawbot_provider_event(app_server, event).await { + tracing::warn!(error = %err, "failed to handle clawbot provider event"); + self.chat_widget + .add_error_message(format!("Clawbot provider event failed: {err}")); + } + } + AppEvent::ClawbotTurnCompleted { thread_id, turn } => { + if let Err(err) = self + .handle_clawbot_turn_completed(app_server, thread_id, turn) + .await + { + tracing::warn!(error = %err, "failed to handle clawbot turn completion"); + self.chat_widget + .add_error_message(format!("Clawbot turn forwarding failed: {err}")); + } + } + AppEvent::OpenClawbotManagement => { + self.open_clawbot_management_popup(); + } + AppEvent::OpenClawbotFeishuConfigPrompt { field } => { + self.open_clawbot_feishu_config_prompt(field); + } + AppEvent::SaveClawbotFeishuConfigValue { field, value } => { + if let Err(err) = self.save_clawbot_feishu_config_value(field, value) { + self.chat_widget + .add_error_message(format!("Failed to save Clawbot config: {err}")); + } + } + AppEvent::OpenClawbotManualBindPrompt => { + self.open_clawbot_manual_bind_prompt(); + } + AppEvent::SaveClawbotManualBindSessionId { session_id } => { + if let Err(err) = self + .bind_clawbot_session_to_current_thread(app_server, session_id) + .await + { + self.chat_widget + .add_error_message(format!("Failed to bind Clawbot session: {err}")); + } + } + AppEvent::ClawbotSetTurnMode { mode } => { + if let Err(err) = self.save_clawbot_turn_mode(mode) { + self.chat_widget + .add_error_message(format!("Failed to save Clawbot turn mode: {err}")); + } + } + AppEvent::ClawbotDisconnectCurrentThread => { + if let Err(err) = self.clawbot_disconnect_current_thread() { + self.chat_widget + .add_error_message(format!("Failed to disconnect Clawbot binding: {err}")); + } + } + AppEvent::ClawbotSetCurrentThreadForwarding { channel, enabled } => { + if let Err(err) = self.clawbot_set_current_thread_forwarding(channel, enabled) { + self.chat_widget + .add_error_message(format!("Failed to update Clawbot forwarding: {err}")); + } + } + AppEvent::ScanClawbotFeishuSessions => { + if let Err(err) = self.scan_clawbot_feishu_sessions().await { + self.chat_widget + .add_error_message(format!("Failed to scan Feishu sessions: {err}")); + } + } + AppEvent::ClearClawbotFeishuSessions => { + if let Err(err) = self.clear_clawbot_feishu_sessions() { + self.chat_widget.add_error_message(format!( + "Failed to clear unbound Feishu sessions: {err}" + )); + } + } + AppEvent::RetryClawbotFeishuConnection => { + if let Err(err) = self.retry_clawbot_feishu_connection() { + self.chat_widget.add_error_message(format!( + "Failed to restart Clawbot Feishu runtime: {err}" + )); + } + } + AppEvent::ApplyThreadRollback { num_turns } => { + if self.apply_non_pending_thread_rollback(num_turns) { + tui.frame_requester().schedule_frame(); + } + } + AppEvent::StartCommitAnimation => { + if self + .commit_anim_running + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok() + { + let tx = self.app_event_tx.clone(); + let running = self.commit_anim_running.clone(); + thread::spawn(move || { + while running.load(Ordering::Relaxed) { + thread::sleep(COMMIT_ANIMATION_TICK); + tx.send(AppEvent::CommitTick); + } + }); + } + } + AppEvent::StopCommitAnimation => { + self.commit_anim_running.store(false, Ordering::Release); + } + AppEvent::CommitTick => { + self.chat_widget.on_commit_tick(); + } + AppEvent::Exit(mode) => { + return Ok(self.handle_exit_mode(app_server, mode).await); + } + AppEvent::FatalExitRequest(message) => { + return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); + } + AppEvent::CodexOp(op) => { + self.submit_active_thread_op(tui, app_server, op.into()) + .await?; + } + AppEvent::SubmitThreadOp { thread_id, op } => { + self.submit_thread_op(tui, app_server, thread_id, op.into()) + .await?; + } + AppEvent::SubmitWorkflowFollowup { thread_id, op } => { + self.submit_thread_op(tui, app_server, thread_id, op.into()) + .await?; + } + AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { + self.enqueue_thread_history_entry_response(thread_id, event) + .await?; + } + AppEvent::DiffResult(text) => { + // Clear the in-progress state in the bottom pane + self.chat_widget.on_diff_complete(); + // Enter alternate screen using TUI helper and build pager lines + let _ = tui.enter_alt_screen(); + let pager_lines: Vec> = if text.trim().is_empty() { + vec!["No changes detected.".italic().into()] + } else { + text.lines().map(ansi_escape_line).collect() + }; + self.overlay = Some(Overlay::new_static_with_lines( + pager_lines, + "D I F F".to_string(), + )); + tui.frame_requester().schedule_frame(); + } + AppEvent::OpenAppLink { app_id, title, description, @@ -5136,6 +5897,27 @@ impl App { AppEvent::UpdateFeatureFlags { updates } => { self.update_feature_flags(updates).await; } + AppEvent::ToggleDisplayPreference(key) => { + let enabled = !self.display_preferences.is_enabled(key); + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(self.active_profile.as_deref()) + .with_edits([display_preference_edit(key, enabled)]) + .apply() + .await + { + tracing::error!( + error = %err, + ?key, + "failed to persist display preference update" + ); + self.chat_widget + .add_error_message(format!("Failed to save UI preference: {err}")); + } else { + self.display_preferences.set_enabled(key, enabled); + set_display_preference_in_config(&mut self.config, key, enabled); + self.open_display_preferences_panel(); + } + } AppEvent::SkipNextWorldWritableScan => { self.windows_sandbox.skip_world_writable_scan_once = true; } @@ -5546,6 +6328,10 @@ impl App { self.pending_shutdown_exit_thread_id = None; AppRunControl::Exit(ExitReason::UserRequested) } + ExitMode::RespawnImmediate => { + self.pending_shutdown_exit_thread_id = None; + AppRunControl::Exit(ExitReason::RespawnRequested) + } } } @@ -5696,7 +6482,27 @@ impl App { .await; } + let after_turn = self + .active_thread_id + .and_then(|active_thread_id| match &event { + ThreadBufferedEvent::Notification(notification) => { + workflow_after_turn_last_agent_message( + self.primary_thread_id, + active_thread_id, + notification, + ) + } + _ => None, + }); self.handle_thread_event_now(event); + if let Some(last_agent_message) = after_turn { + for cell in self + .handle_primary_thread_turn_complete_for_workflows(app_server, last_agent_message) + .await + { + self.insert_visible_history_cell(tui, cell); + } + } if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); } @@ -5769,12 +6575,12 @@ impl App { } async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { - let editor_cmd = match external_editor::resolve_editor_command() { - Ok(cmd) => cmd, + let editor_cmds = match external_editor::resolve_editor_commands() { + Ok(cmds) => cmds, Err(external_editor::EditorError::MissingEditor) => { self.chat_widget .add_to_history(history_cell::new_error_event( - "Cannot open external editor: set $VISUAL or $EDITOR before starting Codex." + "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`." .to_string(), )); self.reset_external_editor_state(tui); @@ -5793,7 +6599,7 @@ impl App { let seed = self.chat_widget.composer_text_with_pending(); let editor_result = tui .with_restored(tui::RestoreMode::KeepRaw, || async { - external_editor::run_editor(&seed, &editor_cmd).await + external_editor::run_editor(&seed, &editor_cmds).await }) .await; self.reset_external_editor_state(tui); @@ -5831,12 +6637,57 @@ impl App { tui.frame_requester().schedule_frame(); } + fn handle_key_chord_key_event(&mut self, key_event: KeyEvent) -> Option { + if self.overlay.is_some() + || !self.chat_widget.no_modal_or_popup_active() + || self.chat_widget.external_editor_state() != ExternalEditorState::Closed + { + self.key_chord.clear(); + return Some(key_event); + } + + match self.key_chord.handle_key_event(key_event) { + KeyChordResolution::NoMatch => Some(key_event), + KeyChordResolution::AwaitingSecondKey | KeyChordResolution::Cancelled => None, + KeyChordResolution::Forward(forwarded_key_event) => Some(forwarded_key_event), + KeyChordResolution::Matched(action) => { + match action { + KeyChordAction::UndoLastUserMessage => { + self.undo_last_user_message(); + } + KeyChordAction::CopyLatestOutput => { + self.chat_widget.copy_latest_output_to_clipboard(); + } + KeyChordAction::RespawnCurrentSession => { + if self.chat_widget.can_run_respawn_now() { + self.app_event_tx + .send(AppEvent::Exit(ExitMode::RespawnImmediate)); + } + } + } + None + } + } + } + async fn handle_key_event( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, key_event: KeyEvent, ) { + let mut key_event = key_event; + if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && key_event.code != KeyCode::Esc + && self.backtrack.primed + { + self.reset_backtrack_state(); + } + let Some(forwarded_key_event) = self.handle_key_chord_key_event(key_event) else { + return; + }; + key_event = forwarded_key_event; + // Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless // enhanced keyboard reporting is available. We only treat those word-motion fallbacks as // agent-switch shortcuts when the composer is empty so we never steal the expected @@ -5929,14 +6780,10 @@ impl App { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. - } => { - if self.chat_widget.is_normal_backtrack_mode() - && self.chat_widget.composer_is_empty() - { - self.handle_backtrack_esc_key(tui); - } else { - self.chat_widget.handle_key_event(key_event); - } + } if self.chat_widget.is_normal_backtrack_mode() + && self.chat_widget.composer_is_empty() => + { + self.handle_backtrack_esc_key(tui); } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { @@ -5955,12 +6802,6 @@ impl App { kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { - // Any non-Esc key press should cancel a primed backtrack. - // This avoids stale "Esc-primed" state after the user starts typing - // (even if they later backspace to empty). - if key_event.code != KeyCode::Esc && self.backtrack.primed { - self.reset_backtrack_state(); - } self.chat_widget.handle_key_event(key_event); } _ => { @@ -6199,6 +7040,9 @@ impl Drop for App { #[cfg(test)] mod tests { use super::*; + mod btw_tests; + mod clawbot_tests; + use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; @@ -6206,6 +7050,7 @@ mod tests { use crate::chatwidget::ChatWidgetInit; use crate::chatwidget::create_initial_user_message; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; + use crate::chatwidget::tests::render_bottom_popup; use crate::chatwidget::tests::set_chatgpt_auth; use crate::file_search::FileSearchManager; use crate::history_cell::AgentMessageCell; @@ -6214,6 +7059,7 @@ mod tests { use crate::history_cell::new_session_info; use crate::multi_agents::AgentPickerThreadEntry; use assert_matches::assert_matches; + use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; @@ -6231,6 +7077,7 @@ mod tests { use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary; use codex_app_server_protocol::HookScope as AppServerHookScope; use codex_app_server_protocol::HookStartedNotification; + use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprovalProtocol; @@ -6248,6 +7095,7 @@ mod tests { use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; use codex_app_server_protocol::TokenUsageBreakdown; + use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -6288,6 +7136,7 @@ mod tests { use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; + use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -6542,6 +7391,7 @@ mod tests { let model = codex_core::test_support::get_model_offline(config.model.as_deref()); app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit { config, + display_preferences: app.display_preferences.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), initial_user_message: create_initial_user_message( @@ -7525,6 +8375,22 @@ mod tests { )); } + #[test] + fn closed_state_for_thread_read_status_marks_not_loaded_picker_threads_closed() { + assert!(App::closed_state_for_thread_read_status( + &codex_app_server_protocol::ThreadStatus::NotLoaded, + ThreadLivenessRefreshMode::Picker, + )); + } + + #[test] + fn closed_state_for_thread_read_status_keeps_not_loaded_selection_threads_open() { + assert!(!App::closed_state_for_thread_read_status( + &codex_app_server_protocol::ThreadStatus::NotLoaded, + ThreadLivenessRefreshMode::Selection, + )); + } + #[test] fn include_turns_fallback_detection_handles_unmaterialized_and_ephemeral_threads() { let unmaterialized = color_eyre::eyre::eyre!( @@ -7672,7 +8538,11 @@ mod tests { ); let is_available = app - .refresh_agent_picker_thread_liveness(&mut app_server, thread_id) + .refresh_agent_picker_thread_liveness( + &mut app_server, + thread_id, + ThreadLivenessRefreshMode::Picker, + ) .await; assert!(!is_available); @@ -7681,6 +8551,34 @@ mod tests { Ok(()) } + #[tokio::test] + async fn refresh_agent_picker_thread_liveness_keeps_unloaded_selection_targets_attachable() + -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let thread_id = started.session.thread_id; + app_server.thread_unsubscribe(thread_id).await?; + + let is_available = app + .refresh_agent_picker_thread_liveness( + &mut app_server, + thread_id, + ThreadLivenessRefreshMode::Selection, + ) + .await; + + assert!(is_available); + assert!(app.agent_navigation.get(&thread_id).is_none()); + assert!(app.should_attach_live_thread_for_selection(thread_id)); + Ok(()) + } + #[tokio::test] async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -9054,9 +9952,10 @@ guardian_approval = true assert_snapshot!("clear_ui_header_fast_status_gpt54_only", rendered); } - async fn make_test_app() -> App { + pub(super) async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); + let display_preferences = DisplayPreferences::from_config(&config); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -9067,6 +9966,7 @@ guardian_approval = true app_event_tx, chat_widget, config, + display_preferences, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), @@ -9082,6 +9982,7 @@ guardian_approval = true status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + key_chord: KeyChordState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, @@ -9100,6 +10001,21 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_thread_notification_channels: Arc::new( + tokio::sync::Mutex::new(HashMap::new()), + ), + workflow_file_watch: None, + workflow_scheduler: WorkflowSchedulerState::default(), + workflow_history: WorkflowHistoryState::default(), + btw_session: None, + btw_closing_thread_ids: HashSet::new(), + clawbot_workspace_root: None, + clawbot_provider_task: None, + clawbot_pending_turns: HashMap::new(), + #[cfg(test)] + clawbot_outbound_messages: Vec::new(), + #[cfg(test)] + clawbot_outbound_reactions: Vec::new(), } } @@ -9110,6 +10026,7 @@ guardian_approval = true ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); + let display_preferences = DisplayPreferences::from_config(&config); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -9121,6 +10038,7 @@ guardian_approval = true app_event_tx, chat_widget, config, + display_preferences, active_profile: None, cli_kv_overrides: Vec::new(), harness_overrides: ConfigOverrides::default(), @@ -9136,6 +10054,7 @@ guardian_approval = true status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + key_chord: KeyChordState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), feedback_audience: FeedbackAudience::External, @@ -9154,12 +10073,129 @@ guardian_approval = true primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), + workflow_thread_notification_channels: Arc::new(tokio::sync::Mutex::new( + HashMap::new(), + )), + workflow_file_watch: None, + workflow_scheduler: WorkflowSchedulerState::default(), + workflow_history: WorkflowHistoryState::default(), + btw_session: None, + btw_closing_thread_ids: HashSet::new(), + clawbot_workspace_root: None, + clawbot_provider_task: None, + clawbot_pending_turns: HashMap::new(), + #[cfg(test)] + clawbot_outbound_messages: Vec::new(), + #[cfg(test)] + clawbot_outbound_reactions: Vec::new(), }, rx, op_rx, ) } + fn write_test_before_turn_workflow(workspace_cwd: &Path) -> Result<()> { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir)?; + std::fs::write( + workflows_dir.join("before_turn.yaml"), + r#"name: director + +triggers: + - type: before_turn + jobs: [augment] + +jobs: + augment: + context: embed + steps: + - prompt: | + added by before_turn +"#, + )?; + Ok(()) + } + + fn write_test_after_turn_workflow(workspace_cwd: &Path) -> Result<()> { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir)?; + std::fs::write( + workflows_dir.join("after_turn.yaml"), + r#"name: director + +triggers: + - type: after_turn + id: followup + jobs: [followup] + +jobs: + followup: + context: embed + steps: + - prompt: | + follow up from workflow +"#, + )?; + Ok(()) + } + + fn write_test_manual_workflow(workspace_cwd: &Path) -> Result<()> { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir)?; + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: manual + id: review_backlog + jobs: [summarize] + - type: manual + id: triage + jobs: [notify] + - type: after_turn + id: followup + jobs: [notify] + +jobs: + summarize: + context: embed + steps: + - prompt: | + summarize the backlog + notify: + context: embed + response: user + steps: + - prompt: | + send workflow update +"#, + )?; + Ok(()) + } + + fn write_test_file_watch_workflow(workspace_cwd: &Path) -> Result<()> { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir)?; + std::fs::write( + workflows_dir.join("file_watch.yaml"), + r#"name: watcher + +triggers: + - type: file_watch + id: refresh + jobs: [summarize] + +jobs: + summarize: + context: embed + steps: + - prompt: | + summarize the latest file changes +"#, + )?; + Ok(()) + } fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { thread_id, @@ -9207,6 +10243,27 @@ guardian_approval = true }) } + fn turn_completed_notification_with_agent_message( + thread_id: ThreadId, + turn_id: &str, + status: TurnStatus, + message: &str, + ) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: test_turn( + turn_id, + status, + vec![ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: message.to_string(), + phase: None, + memory_citation: None, + }], + ), + }) + } + fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification { ServerNotification::ThreadClosed(ThreadClosedNotification { thread_id: thread_id.to_string(), @@ -9334,6 +10391,22 @@ guardian_approval = true } } + fn request_user_input_request( + thread_id: ThreadId, + turn_id: &str, + item_id: &str, + ) -> ServerRequest { + ServerRequest::ToolRequestUserInput { + request_id: AppServerRequestId::Integer(99), + params: ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + questions: Vec::new(), + }, + } + } + #[test] fn thread_event_store_tracks_active_turn_lifecycle() { let mut store = ThreadEventStore::new(/*capacity*/ 8); @@ -10101,14 +11174,71 @@ guardian_approval = true } #[tokio::test] - async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() - -> Result<()> { + async fn refresh_in_memory_config_from_disk_syncs_active_profile() -> Result<()> { let mut app = make_test_app().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - std::fs::write(codex_home.path().join("config.toml"), "[broken")?; - let current_config = app.config.clone(); - let current_cwd = current_config.cwd.clone(); + app.active_profile = Some("stale".to_string()); + + std::fs::write( + codex_home.path().join("config.toml"), + r#" +profile = "fresh" + +[profiles.fresh] +model = "gpt-5.2" +"#, + )?; + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!(app.config.active_profile.as_deref(), Some("fresh")); + assert_eq!(app.active_profile.as_deref(), Some("fresh")); + Ok(()) + } + + #[tokio::test] + async fn routed_profile_runtime_changed_detects_profile_and_provider_reload_inputs() { + let app = make_test_app().await; + let current_config = app.config.clone(); + + let mut next_profile = current_config.clone(); + next_profile.active_profile = Some("secondary".to_string()); + assert!(App::routed_profile_runtime_changed( + ¤t_config, + &next_profile + )); + + let mut next_provider = current_config.clone(); + next_provider.model_provider.base_url = Some("https://example.com/v1".to_string()); + assert!(App::routed_profile_runtime_changed( + ¤t_config, + &next_provider + )); + + let mut next_chatgpt_base_url = current_config.clone(); + next_chatgpt_base_url.chatgpt_base_url = + "https://chatgpt.example.com/backend-api/".to_string(); + assert!(App::routed_profile_runtime_changed( + ¤t_config, + &next_chatgpt_base_url + )); + + assert!(!App::routed_profile_runtime_changed( + ¤t_config, + ¤t_config + )); + } + + #[tokio::test] + async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() + -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf(); + std::fs::write(codex_home.path().join("config.toml"), "[broken")?; + let current_config = app.config.clone(); + let current_cwd = current_config.cwd.clone(); let resume_config = app .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) @@ -10402,6 +11532,155 @@ guardian_approval = true })); } + #[tokio::test] + async fn undo_last_user_message_restores_latest_user_input_and_rolls_back_one_turn() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + + let remote_image_url = "https://example.com/latest.png".to_string(); + app.transcript_cells = vec![ + Arc::new(UserHistoryCell { + message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(UserHistoryCell { + message: "latest".to_string(), + text_elements: vec![TextElement::new( + codex_protocol::user_input::ByteRange { start: 0, end: 6 }, + Some("latest".to_string()), + )], + local_image_paths: Vec::new(), + remote_image_urls: vec![remote_image_url.clone()], + }) as Arc, + ]; + app.chat_widget + .set_composer_text("stale draft".to_string(), Vec::new(), Vec::new()); + + assert!(app.undo_last_user_message()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "latest"); + assert_eq!(app.chat_widget.remote_image_urls(), vec![remote_image_url]); + + let mut rollback_turns = None; + while let Ok(op) = op_rx.try_recv() { + if let Op::ThreadRollback { num_turns } = op { + rollback_turns = Some(num_turns); + } + } + + assert_eq!(rollback_turns, Some(1)); + } + + #[tokio::test] + async fn ctrl_x_ctrl_u_triggers_undo_last_user_message() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + + let thread_id = ThreadId::new(); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }); + + app.transcript_cells = vec![ + Arc::new(UserHistoryCell { + message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(UserHistoryCell { + message: "latest".to_string(), + text_elements: vec![TextElement::new( + codex_protocol::user_input::ByteRange { start: 0, end: 6 }, + Some("latest".to_string()), + )], + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + ]; + + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL, + )), + None + ); + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('u'), + KeyModifiers::CONTROL, + )), + None + ); + + let mut rollback_turns = None; + while let Ok(op) = op_rx.try_recv() { + if let Op::ThreadRollback { num_turns } = op { + rollback_turns = Some(num_turns); + } + } + + assert_eq!(rollback_turns, Some(1)); + } + + #[tokio::test] + async fn ctrl_x_unknown_second_key_falls_through_to_composer_input() { + let mut app = make_test_app().await; + + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL, + )), + None + ); + let forwarded = + app.handle_key_chord_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert_eq!( + forwarded, + Some(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) + ); + } + + #[tokio::test] + async fn ctrl_x_ctrl_y_runs_copy_action_without_inserting_y_into_the_composer() { + let mut app = make_test_app().await; + + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL, + )), + None + ); + assert_eq!( + app.handle_key_chord_key_event(KeyEvent::new( + KeyCode::Char('y'), + KeyModifiers::CONTROL, + )), + None + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + } + #[tokio::test] async fn replay_thread_snapshot_replays_turn_history_in_order() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -10474,6 +11753,130 @@ guardian_approval = true ); } + #[tokio::test] + async fn replay_thread_snapshot_queues_workflow_history_after_turn_replay() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + let stored_cell: Arc = Arc::new(history_cell::new_info_event( + "Workflow reply".to_string(), + Some("director/review_backlog".to_string()), + )); + let _ = app.record_workflow_history_cell(thread_id, stored_cell); + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: Some(test_thread_session( + thread_id, + PathBuf::from("/home/user/project"), + )), + turns: vec![test_turn( + "turn-1", + TurnStatus::Completed, + vec![ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![AppServerUserInput::Text { + text: "first prompt".to_string(), + text_elements: Vec::new(), + }], + }], + )], + events: Vec::new(), + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + + let mut replay_order = Vec::new(); + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => { + let cell: Arc = cell.into(); + let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); + if transcript.contains("first prompt") { + replay_order.push("turn"); + } + app.transcript_cells.push(cell); + } + AppEvent::ReplayWorkflowHistory { + thread_id: replay_thread_id, + } => { + assert_eq!(replay_thread_id, thread_id); + replay_order.push("workflow"); + let lines = app.replay_workflow_history_cells_for_thread( + replay_thread_id, + /*width*/ 80, + ); + assert!(lines_to_single_string(&lines).contains("Workflow reply")); + } + _ => {} + } + } + + assert_eq!(replay_order, vec!["turn", "workflow"]); + let transcript: Vec = app + .transcript_cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert!(transcript.iter().any(|cell| cell.contains("first prompt"))); + assert!( + transcript + .last() + .is_some_and(|cell| cell.contains("Workflow reply")) + ); + } + + #[tokio::test] + async fn enqueue_primary_thread_session_queues_workflow_history_after_turn_replay() -> Result<()> + { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let stored_cell: Arc = Arc::new(history_cell::new_info_event( + "Workflow reply".to_string(), + Some("director/review_backlog".to_string()), + )); + let _ = app.record_workflow_history_cell(thread_id, stored_cell); + + app.enqueue_primary_thread_session( + test_thread_session(thread_id, PathBuf::from("/tmp/project")), + vec![test_turn( + "turn-1", + TurnStatus::Completed, + vec![ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![AppServerUserInput::Text { + text: "earlier prompt".to_string(), + text_elements: Vec::new(), + }], + }], + )], + ) + .await?; + + let mut replay_order = Vec::new(); + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => { + let transcript = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); + if transcript.contains("earlier prompt") { + replay_order.push("turn"); + } + } + AppEvent::ReplayWorkflowHistory { + thread_id: replay_thread_id, + } => { + assert_eq!(replay_thread_id, thread_id); + replay_order.push("workflow"); + } + _ => {} + } + } + + assert_eq!(replay_order, vec!["turn", "workflow"]); + Ok(()) + } + #[tokio::test] async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; @@ -10488,6 +11891,7 @@ guardian_approval = true let replacement = ChatWidget::new_with_app_event(ChatWidgetInit { config: app.config.clone(), + display_preferences: app.display_preferences.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), initial_user_message: None, @@ -10757,29 +12161,728 @@ guardian_approval = true } #[tokio::test] - async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { + async fn shutting_down_primary_thread_stops_background_workflow_runs() { let mut app = make_test_app().await; - let thread_id = ThreadId::new(); - app.active_thread_id = Some(thread_id); - let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) .await .expect("embedded app server"); - let control = app - .handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst) - .await; + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: started.session.forked_from_id, + thread_name: started.session.thread_name.clone(), + model: started.session.model.clone(), + model_provider_id: started.session.model_provider_id.clone(), + service_tier: started.session.service_tier, + approval_policy: started.session.approval_policy, + approvals_reviewer: started.session.approvals_reviewer, + sandbox_policy: started.session.sandbox_policy.clone(), + cwd: started.session.cwd.clone(), + reasoning_effort: started.session.reasoning_effort, + history_log_id: started.session.history_log_id, + history_entry_count: usize::try_from(started.session.history_entry_count) + .expect("history entry count fits usize"), + initial_messages: None, + network_proxy: started.session.network_proxy.clone(), + rollout_path: started.session.rollout_path.clone(), + }), + }); - assert_eq!(app.pending_shutdown_exit_thread_id, None); - assert!(matches!( - control, - AppRunControl::Exit(ExitReason::UserRequested) - )); + let _run_id = app.start_test_background_workflow_run( + "director".to_string(), + "review_backlog".to_string(), + /*is_trigger*/ false, + ); + assert_eq!( + app.background_workflow_labels(), + vec!["director · review_backlog".to_string()] + ); + + app.shutdown_current_thread(&mut app_server).await; + + assert!(app.background_workflow_labels().is_empty()); + assert!(app.queued_trigger_labels().is_empty()); } #[tokio::test] - async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() { - let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + async fn before_turn_workflow_augments_primary_user_turn() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_before_turn_workflow(app.config.cwd.as_path())?; + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + + let (op, cells) = app + .augment_primary_thread_op_with_before_turn_workflows( + &app_server, + thread_id, + AppCommand::from_core(Op::UserTurn { + items: vec![UserInput::Text { + text: "original prompt".to_string(), + text_elements: Vec::new(), + }], + cwd: app.config.cwd.to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: SandboxPolicy::new_read_only_policy(), + model: "gpt-test".to_string(), + effort: None, + summary: None, + service_tier: None, + final_output_json_schema: None, + collaboration_mode: None, + personality: None, + }), + ) + .await; + + let AppCommandView::UserTurn { items, .. } = op.view() else { + panic!("expected user turn"); + }; + assert_eq!( + items, + &[ + UserInput::Text { + text: "original prompt".to_string(), + text_elements: Vec::new(), + }, + UserInput::Text { + text: "added by before_turn".to_string(), + text_elements: Vec::new(), + } + ] + ); + let rendered_cells: Vec = cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert_eq!(rendered_cells.len(), 1); + assert!(rendered_cells[0].contains("Workflow job completed")); + Ok(()) + } + #[tokio::test] + async fn active_primary_turn_complete_waits_for_consumption_before_after_turn() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_after_turn_workflow(app.config.cwd.as_path())?; + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, Vec::new()) + .await?; + while app_event_rx.try_recv().is_ok() {} + + app.enqueue_thread_notification( + thread_id, + turn_completed_notification_with_agent_message( + thread_id, + "turn-1", + TurnStatus::Completed, + "final reply", + ), + ) + .await?; + + assert!( + app_event_rx.try_recv().is_err(), + "after_turn should not run before the active thread consumes TurnCompleted" + ); + + let queued_event = app + .active_thread_rx + .as_mut() + .expect("active thread receiver") + .recv() + .await + .expect("queued active-thread event"); + app.handle_thread_event_now(queued_event); + + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::ClawbotTurnCompleted { .. } + | AppEvent::InsertHistoryCell(_) + | AppEvent::ReplayWorkflowHistory { .. } => { + continue; + } + other => { + panic!( + "after_turn should not run before event consumption finishes, got {other:?}" + ); + } + } + } + + let visible_cells = app + .handle_primary_thread_turn_complete_for_workflows( + &app_server, + Some("final reply".to_string()), + ) + .await; + assert_eq!(visible_cells.len(), 1); + let rendered_cells: Vec = visible_cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert!(rendered_cells[0].contains("Workflow trigger started")); + assert_eq!( + app.background_workflow_labels(), + vec!["director · followup".to_string()] + ); + + let (run_id, result) = loop { + match tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) + .await? + .expect("expected background workflow event") + { + AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => { + break (run_id, result); + } + other => panic!("expected background workflow completion event, got {other:?}"), + } + }; + let completion_cells = app + .finish_background_workflow_run(&app_server, run_id, *result) + .await; + let rendered_completion: Vec = completion_cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert_eq!(rendered_completion.len(), 2); + assert!(rendered_completion[0].contains("Workflow trigger completed")); + assert!(rendered_completion[1].contains("Workflow reply")); + + match app_event_rx + .try_recv() + .expect("expected workflow follow-up submission") + { + AppEvent::SubmitWorkflowFollowup { + thread_id: submit_thread_id, + op: Op::UserTurn { items, .. }, + } => { + assert_eq!(submit_thread_id, thread_id); + assert_eq!( + items, + vec![UserInput::Text { + text: "follow up from workflow".to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected workflow follow-up submission, got {other:?}"), + } + Ok(()) + } + + #[tokio::test] + async fn inactive_primary_turn_complete_still_runs_after_turn_continuity() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_after_turn_workflow(app.config.cwd.as_path())?; + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, Vec::new()) + .await?; + let agent_thread_id = ThreadId::new(); + app.active_thread_id = Some(agent_thread_id); + while app_event_rx.try_recv().is_ok() {} + + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(turn_completed_notification_with_agent_message( + thread_id, + "turn-1", + TurnStatus::Completed, + "final reply", + )), + ) + .await; + + let (run_id, result) = loop { + match tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) + .await? + .expect("expected inactive primary background workflow event") + { + AppEvent::BackgroundWorkflowRunCompleted { run_id, result } => { + break (run_id, result); + } + AppEvent::ClawbotTurnCompleted { .. } + | AppEvent::InsertHistoryCell(_) + | AppEvent::ReplayWorkflowHistory { .. } => { + continue; + } + other => panic!("expected background workflow completion event, got {other:?}"), + } + }; + let visible_cells = app + .finish_background_workflow_run(&app_server, run_id, *result) + .await; + assert!( + visible_cells.is_empty(), + "inactive primary thread should record workflow cells without rendering them immediately" + ); + + match app_event_rx + .try_recv() + .expect("expected inactive primary workflow follow-up submission") + { + AppEvent::SubmitWorkflowFollowup { + thread_id: submit_thread_id, + op: Op::UserTurn { items, .. }, + } => { + assert_eq!(submit_thread_id, thread_id); + assert_eq!( + items, + vec![UserInput::Text { + text: "follow up from workflow".to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected workflow follow-up submission, got {other:?}"), + } + Ok(()) + } + + #[tokio::test] + async fn workflow_ui_popup_snapshot() -> Result<()> { + let mut app = make_test_app().await; + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path())?; + + let slow_run = app + .start_test_manual_workflow_trigger_run( + "director".to_string(), + "review_backlog".to_string(), + ) + .expect("running manual trigger"); + let queued_run = app + .start_test_manual_workflow_trigger_run("director".to_string(), "triage".to_string()); + assert!(queued_run.is_none()); + + app.open_workflow_controls_popup(); + + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert_snapshot!("workflow_controls_popup", popup); + + app.finish_test_background_workflow_run(slow_run).await; + Ok(()) + } + + #[tokio::test] + async fn workflow_ui_manual_trigger_action_updates_scheduler_status() -> Result<()> { + let mut app = make_test_app().await; + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path())?; + + let started_app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let cell = app.start_manual_workflow_trigger_from_ui( + &started_app_server, + "director".to_string(), + "review_backlog".to_string(), + ); + let rendered = cell + .display_lines(/*width*/ 100) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(rendered.contains("Workflow trigger started")); + assert_eq!( + app.background_workflow_labels(), + vec!["director · review_backlog".to_string()] + ); + + let stopped = app.workflow_scheduler.stop_active_workflow_runs().await; + assert_eq!(stopped, 1); + app.sync_background_workflow_status(); + Ok(()) + } + + #[tokio::test] + async fn clean_background_terminals_stops_background_workflows() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.primary_session_configured = Some(started.session); + app.active_thread_id = Some(thread_id); + + let run_id = app + .workflow_scheduler + .next_background_run_id("director", "review_backlog"); + let cancellation = CancellationToken::new(); + let cancellation_for_task = cancellation.clone(); + app.workflow_scheduler.register_background_workflow_run( + run_id, + workflow_runtime::BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + cancellation, + tokio::spawn(async move { + cancellation_for_task.cancelled().await; + }), + ); + app.workflow_scheduler.enqueue_trigger_run( + "director".to_string(), + "triage".to_string(), + workflow_runtime::OwnedWorkflowPhaseContext::default(), + ); + app.sync_background_workflow_status(); + + assert_eq!( + app.background_workflow_labels(), + vec!["director · review_backlog".to_string()] + ); + assert_eq!( + app.queued_trigger_labels(), + vec!["director · triage".to_string()] + ); + + let handled = app + .try_submit_active_thread_op_via_app_server( + &mut app_server, + thread_id, + &AppCommand::clean_background_terminals(), + ) + .await?; + + assert!(handled); + assert!(app.background_workflow_labels().is_empty()); + assert!(app.queued_trigger_labels().is_empty()); + Ok(()) + } + + #[tokio::test] + async fn manual_triggers_use_a_global_fifo_queue() { + let mut app = make_test_app().await; + + let slow_run = + app.start_test_manual_workflow_trigger_run("director".to_string(), "slow".to_string()); + let fast_run = + app.start_test_manual_workflow_trigger_run("director".to_string(), "fast".to_string()); + + assert!(slow_run.is_some()); + assert!(fast_run.is_none()); + assert_eq!( + app.background_workflow_labels(), + vec!["director · slow".to_string()] + ); + assert_eq!( + app.queued_trigger_labels(), + vec!["director · fast".to_string()] + ); + + app.finish_test_background_workflow_run(slow_run.expect("slow run id")) + .await; + + assert_eq!( + app.background_workflow_labels(), + vec!["director · fast".to_string()] + ); + assert!(app.queued_trigger_labels().is_empty()); + } + + #[tokio::test] + async fn file_watch_triggers_share_the_global_queue_and_skip_duplicates() -> Result<()> { + let mut app = make_test_app().await; + write_test_file_watch_workflow(app.config.cwd.as_path())?; + + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.primary_session_configured = Some(started.session); + app.active_thread_id = Some(thread_id); + + let running = + app.start_test_manual_workflow_trigger_run("director".to_string(), "slow".to_string()); + assert!(running.is_some()); + + let first = app.handle_workspace_file_changes_for_workflows( + &app_server, + &[app.config.cwd.as_path().join("src")], + ); + let second = app.handle_workspace_file_changes_for_workflows( + &app_server, + &[app.config.cwd.as_path().join("src/lib.rs")], + ); + + assert_eq!(first.len(), 1); + assert!(second.is_empty()); + assert_eq!( + app.background_workflow_labels(), + vec!["director · slow".to_string()] + ); + assert_eq!( + app.queued_trigger_labels(), + vec!["watcher · refresh".to_string()] + ); + Ok(()) + } + + #[tokio::test] + async fn workflow_follow_up_completion_submits_to_primary_thread() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.primary_session_configured = Some(started.session); + app.active_thread_id = Some(thread_id); + + let run_id = app + .workflow_scheduler + .next_background_run_id("director", "review_backlog"); + let target = workflow_runtime::BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }; + app.workflow_scheduler.register_background_workflow_run( + run_id.clone(), + target.clone(), + CancellationToken::new(), + tokio::spawn(async {}), + ); + + let cells = app + .finish_background_workflow_run( + &app_server, + run_id, + workflow_runtime::BackgroundWorkflowRunResult { + target, + outcome: workflow_runtime::BackgroundWorkflowRunOutcome::Completed(vec![ + workflow_runtime::WorkflowJobRunResult { + delivery: workflow_runtime::WorkflowOutputDelivery::UserFollowup, + workflow_name: "director".to_string(), + trigger_id: "job:review_backlog".to_string(), + job_name: "review_backlog".to_string(), + message: Some("workflow follow-up".to_string()), + }, + ]), + }, + ) + .await; + + let rendered_cells: Vec = cells + .iter() + .map(|cell| lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + .collect(); + assert_eq!(rendered_cells.len(), 2); + assert!(rendered_cells[0].contains("Workflow job completed")); + assert!(rendered_cells[1].contains("Workflow reply")); + + match app_event_rx + .try_recv() + .expect("expected queued workflow follow-up") + { + AppEvent::SubmitWorkflowFollowup { + thread_id: submit_thread_id, + op: Op::UserTurn { items, .. }, + } => { + assert_eq!(submit_thread_id, thread_id); + assert_eq!( + items, + vec![UserInput::Text { + text: "workflow follow-up".to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected workflow follow-up submission, got {other:?}"), + } + } + + #[tokio::test] + async fn workflow_followup_completion_retriggers_after_turn() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + write_test_after_turn_workflow(app.config.cwd.as_path())?; + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, Vec::new()) + .await?; + app.active_thread_id = Some(ThreadId::new()); + while app_event_rx.try_recv().is_ok() {} + + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(turn_completed_notification_with_agent_message( + thread_id, + "turn-followup", + TurnStatus::Completed, + "workflow-originated follow-up reply", + )), + ) + .await; + + assert_eq!( + app.background_workflow_labels(), + vec!["director · followup".to_string()] + ); + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::ClawbotTurnCompleted { .. } + | AppEvent::InsertHistoryCell(_) + | AppEvent::ReplayWorkflowHistory { .. } => {} + other => panic!("unexpected event after workflow follow-up completion: {other:?}"), + } + } + + Ok(()) + } + + #[tokio::test] + async fn app_server_notifications_forward_to_workflow_thread_receivers() -> Result<()> { + let mut app = make_test_app().await; + let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); + app.workflow_thread_notification_channels + .lock() + .await + .insert(thread_id, sender); + + let notification = ServerNotification::ItemCompleted(ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "workflow reply".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + }); + + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(notification.clone()), + ) + .await; + + match receiver.recv().await { + Some(ServerNotification::ItemCompleted(received)) => { + assert_eq!(received.thread_id, thread_id.to_string()); + assert_eq!(received.turn_id, "turn-1"); + let ThreadItem::AgentMessage { text, .. } = received.item else { + panic!("expected forwarded workflow agent message"); + }; + assert_eq!(text, "workflow reply"); + } + other => panic!("expected forwarded workflow notification, got {other:?}"), + } + Ok(()) + } + + #[tokio::test] + async fn btw_insert_summary_appends_to_existing_composer() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: Some("First point.\n\nSecond point.".to_string()), + last_status: None, + }); + app.chat_widget + .set_composer_text("Existing draft".to_string(), Vec::new(), Vec::new()); + + app.insert_btw_summary(&mut app_server).await; + + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Existing draft\n\nBTW summary:\nFirst point.\nSecond point." + ); + assert!(app.btw_session.is_none()); + Ok(()) + } + #[tokio::test] + async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let control = app + .handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst) + .await; + + assert_eq!(app.pending_shutdown_exit_thread_id, None); + assert!(matches!( + control, + AppRunControl::Exit(ExitReason::UserRequested) + )); + } + + #[tokio::test] + async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); app.active_thread_id = Some(thread_id); @@ -10802,6 +12905,25 @@ guardian_approval = true ); } + #[tokio::test] + async fn respawn_immediate_exit_returns_respawn_requested() { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + + let control = app + .handle_exit_mode(&mut app_server, ExitMode::RespawnImmediate) + .await; + + assert_eq!(app.pending_shutdown_exit_thread_id, None); + assert!(matches!( + control, + AppRunControl::Exit(ExitReason::RespawnRequested) + )); + } + #[tokio::test] async fn interrupt_without_active_turn_is_treated_as_handled() { let mut app = make_test_app().await; @@ -10820,6 +12942,34 @@ guardian_approval = true assert_eq!(handled, true); } + #[tokio::test] + async fn closing_active_thread_for_profile_reload_allows_fresh_start() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + + app.close_active_thread_for_profile_reload(&mut app_server, thread_id) + .await + .map_err(|err| color_eyre::eyre::eyre!(err))?; + + assert_eq!(app.active_thread_id, None); + + let restarted = app_server + .start_thread(app.chat_widget.config_ref()) + .await?; + assert_ne!(restarted.session.thread_id, thread_id); + Ok(()) + } + #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index ef5a061e3..3b9807247 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -153,7 +153,7 @@ impl App { async fn handle_server_notification_event( &mut self, - _app_server_client: &AppServerSession, + app_server_client: &AppServerSession, notification: ServerNotification, ) { match ¬ification { @@ -189,17 +189,58 @@ impl App { match server_notification_thread_target(¬ification) { ServerNotificationThreadTarget::Thread(thread_id) => { + let clawbot_turn = match ¬ification { + ServerNotification::TurnCompleted(notification) => { + Some(notification.turn.clone()) + } + _ => None, + }; + if self.handle_btw_notification(thread_id, ¬ification) { + return; + } let result = if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() { - self.enqueue_primary_thread_notification(notification).await + self.enqueue_primary_thread_notification(notification.clone()) + .await } else { - self.enqueue_thread_notification(thread_id, notification) + self.enqueue_thread_notification(thread_id, notification.clone()) .await }; if let Err(err) = result { tracing::warn!("failed to enqueue app-server notification: {err}"); + } else if self.active_thread_id != Some(thread_id) + && let Some(last_agent_message) = super::workflow_after_turn_last_agent_message( + self.primary_thread_id, + thread_id, + ¬ification, + ) + { + let _ = self + .handle_primary_thread_turn_complete_for_workflows( + app_server_client, + last_agent_message, + ) + .await; + } + let workflow_sender = self + .workflow_thread_notification_channels + .lock() + .await + .get(&thread_id) + .cloned(); + if let Some(sender) = workflow_sender + && sender.send(notification.clone()).is_err() + { + self.workflow_thread_notification_channels + .lock() + .await + .remove(&thread_id); + } + if let Some(turn) = clawbot_turn { + self.app_event_tx + .send(AppEvent::ClawbotTurnCompleted { thread_id, turn }); } return; } @@ -251,6 +292,23 @@ impl App { return; }; + if let Some(reason) = self.reject_btw_request(thread_id, &request) { + if let Err(err) = self + .reject_app_server_request(app_server_client, request.id().clone(), reason) + .await + { + tracing::warn!("{err}"); + } + return; + } + + if self + .maybe_auto_resolve_clawbot_server_request(app_server_client, thread_id, &request) + .await + { + return; + } + let result = if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() { self.enqueue_primary_thread_request(request).await diff --git a/codex-rs/tui/src/app/btw.rs b/codex-rs/tui/src/app/btw.rs new file mode 100644 index 000000000..b962e8045 --- /dev/null +++ b/codex-rs/tui/src/app/btw.rs @@ -0,0 +1,785 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::HookEventName as AppServerHookEventName; +use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_protocol::ThreadId; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use uuid::Uuid; + +use super::App; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::SideContentWidth; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +const BTW_DEVELOPER_INSTRUCTIONS: &str = concat!( + "This is a hidden `/btw` discussion thread. ", + "Treat it as a temporary scratchpad that must not mutate the workspace or persistent state. ", + "Do not write files, apply patches, spawn agents, or perform side-effectful actions. ", + "If you need to inspect local context, keep it read-only and concise. ", + "Your answer will be shown to the user in a temporary confirmation view and may be inserted ", + "back into the main composer." +); +const BTW_DISCUSSION_VIEW_ID: &str = "btw-discussion"; +const PREVIEW_CHAR_LIMIT: usize = 1_200; +const SUMMARY_MAX_LINES: usize = 4; +const SUMMARY_MAX_CHARS: usize = 500; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BtwSessionState { + pub(crate) thread_id: ThreadId, + pub(crate) final_message: Option, + pub(crate) last_status: Option, +} + +impl App { + pub(crate) async fn start_btw_discussion( + &mut self, + app_server: &mut AppServerSession, + prompt: String, + ) { + if self.btw_session.is_some() { + self.chat_widget.add_info_message( + "A `/btw` discussion is already active.".to_string(), + Some("Finish or discard it before starting another one.".to_string()), + ); + return; + } + + let trimmed_prompt = prompt.trim(); + if trimmed_prompt.is_empty() { + self.chat_widget + .add_error_message("Usage: /btw ".to_string()); + return; + } + + self.open_btw_loading_popup(/*status_message*/ None); + + let thread_id = match self.start_btw_thread(app_server).await { + Ok(thread_id) => thread_id, + Err(err) => { + self.open_btw_failure_popup(&format!("Failed to start `/btw`: {err}")); + return; + } + }; + self.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + last_status: None, + }); + + let turn_result = app_server + .turn_start( + thread_id, + btw_turn_input(trimmed_prompt), + self.btw_turn_cwd_path(app_server), + AskForApproval::Never, + self.config.approvals_reviewer, + SandboxPolicy::new_read_only_policy(), + self.chat_widget.current_model().to_string(), + self.chat_widget.current_reasoning_effort(), + /*summary*/ None, + self.chat_widget.current_service_tier().map(Some), + /*collaboration_mode*/ None, + self.config.personality, + /*output_schema*/ None, + ) + .await; + if let Err(err) = turn_result { + self.close_btw_session(app_server).await; + self.open_btw_failure_popup(&format!("Failed to submit `/btw`: {err}")); + } + } + + pub(crate) fn finish_btw_discussion( + &mut self, + thread_id: ThreadId, + result: Result, + ) { + let Some(session) = self.btw_session.as_mut() else { + return; + }; + if session.thread_id != thread_id { + return; + } + + match result { + Ok(message) => { + session.final_message = Some(message.clone()); + self.open_btw_result_popup(&message); + } + Err(err) => { + let message = btw_failure_message(&err, session.last_status.as_deref()); + self.open_btw_failure_popup(&message); + } + } + } + + pub(crate) fn reject_btw_request( + &mut self, + thread_id: ThreadId, + request: &ServerRequest, + ) -> Option { + let session = self.btw_session.as_mut()?; + if session.thread_id != thread_id { + return None; + } + + let reason = btw_request_rejection_reason(request); + session.last_status = Some(reason.clone()); + self.open_btw_failure_popup(&format!("`/btw` cannot continue: {reason}")); + Some(reason) + } + + pub(crate) fn handle_btw_notification( + &mut self, + thread_id: ThreadId, + notification: &ServerNotification, + ) -> bool { + if self.btw_closing_thread_ids.contains(&thread_id) { + if matches!(notification, ServerNotification::ThreadClosed(_)) { + self.btw_closing_thread_ids.remove(&thread_id); + } + return true; + } + + let Some(session) = self.btw_session.as_mut() else { + return false; + }; + if session.thread_id != thread_id { + return false; + } + + let status_message = if let Some(status) = btw_status_message(notification) + && session.final_message.is_none() + && session.last_status.as_deref() != Some(status.as_str()) + { + session.last_status = Some(status.clone()); + Some(status) + } else { + None + }; + let final_message_recorded = session.final_message.is_some(); + + if let Some(status_message) = status_message.as_deref() { + self.open_btw_loading_popup(Some(status_message)); + } + + if final_message_recorded { + return true; + } + + match notification { + ServerNotification::Error(notification) if !notification.will_retry => { + self.app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err(notification.error.message.clone()), + }); + } + ServerNotification::TurnCompleted(notification) => { + let result = match notification.turn.status { + TurnStatus::Completed => last_agent_message_or_error(¬ification.turn), + TurnStatus::Failed => last_agent_message_or_error(¬ification.turn) + .or_else(|_| turn_failed_error(¬ification.turn)), + TurnStatus::Interrupted => { + Err("Temporary discussion was interrupted.".to_string()) + } + TurnStatus::InProgress => return true, + }; + self.app_event_tx + .send(AppEvent::BtwCompleted { thread_id, result }); + } + ServerNotification::ThreadClosed(_) => { + self.app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err("Temporary discussion closed before a final answer.".to_string()), + }); + } + _ => {} + } + + true + } + + pub(crate) async fn insert_btw_summary(&mut self, app_server: &mut AppServerSession) { + let Some(message) = self + .btw_session + .as_ref() + .and_then(|session| session.final_message.as_deref()) + else { + self.open_btw_failure_popup("`/btw` summary is unavailable."); + return; + }; + + self.insert_btw_text(summarize_message(message)); + self.chat_widget.add_info_message( + "Inserted `/btw` summary into the composer.".to_string(), + /*hint*/ None, + ); + self.close_btw_session(app_server).await; + } + + pub(crate) async fn insert_btw_full(&mut self, app_server: &mut AppServerSession) { + let Some(message) = self + .btw_session + .as_ref() + .and_then(|session| session.final_message.as_deref()) + else { + self.open_btw_failure_popup("`/btw` answer is unavailable."); + return; + }; + + self.insert_btw_text(full_insert_text(message)); + self.chat_widget.add_info_message( + "Inserted `/btw` answer into the composer.".to_string(), + /*hint*/ None, + ); + self.close_btw_session(app_server).await; + } + + pub(crate) async fn discard_btw_session(&mut self, app_server: &mut AppServerSession) { + let had_session = self.btw_session.is_some(); + self.close_btw_session(app_server).await; + if had_session { + self.chat_widget.add_info_message( + "Discarded `/btw` discussion.".to_string(), + /*hint*/ None, + ); + } + } + + fn insert_btw_text(&mut self, text: String) { + if !self + .chat_widget + .composer_text_with_pending() + .trim() + .is_empty() + { + self.chat_widget.insert_str("\n\n"); + } + self.chat_widget.insert_str(&text); + } + + pub(crate) async fn close_btw_session(&mut self, app_server: &mut AppServerSession) { + let Some(session) = self.btw_session.take() else { + return; + }; + self.close_btw_thread(app_server, session.thread_id).await; + } + + async fn close_btw_thread(&mut self, app_server: &mut AppServerSession, thread_id: ThreadId) { + if !self.btw_closing_thread_ids.insert(thread_id) { + return; + } + if let Err(err) = app_server.thread_unsubscribe(thread_id).await { + tracing::warn!(thread_id = %thread_id, error = %err, "failed to close `/btw` thread"); + } + } + + async fn start_btw_thread(&self, app_server: &AppServerSession) -> Result { + let request_handle = app_server.request_handle(); + if let Some(thread_id) = self.chat_widget.thread_id() { + let response: ThreadForkResponse = request_handle + .request_typed(ClientRequest::ThreadFork { + request_id: request_id(), + params: btw_thread_fork_params(self, thread_id, app_server), + }) + .await + .map_err(|err| format!("failed to fork temporary thread: {err}"))?; + ThreadId::from_string(&response.thread.id) + .map_err(|err| format!("invalid `/btw` thread id: {err}")) + } else { + let response: ThreadStartResponse = request_handle + .request_typed(ClientRequest::ThreadStart { + request_id: request_id(), + params: btw_thread_start_params(self, app_server), + }) + .await + .map_err(|err| format!("failed to start temporary thread: {err}"))?; + ThreadId::from_string(&response.thread.id) + .map_err(|err| format!("invalid `/btw` thread id: {err}")) + } + } + + fn open_btw_loading_popup(&mut self, status_message: Option<&str>) { + let status_message = status_message.map(ToOwned::to_owned); + self.show_btw_popup(move || btw_loading_view_params(status_message.as_deref())); + } + + fn open_btw_result_popup(&mut self, message: &str) { + self.show_btw_popup(|| btw_result_view_params(message)); + } + + fn open_btw_failure_popup(&mut self, error: &str) { + self.show_btw_popup(|| btw_failure_view_params(error)); + } + + fn show_btw_popup(&mut self, build: F) + where + F: Fn() -> SelectionViewParams, + { + if self + .chat_widget + .selected_index_for_active_view(BTW_DISCUSSION_VIEW_ID) + .is_some() + { + let _ = self + .chat_widget + .replace_selection_view_if_active(BTW_DISCUSSION_VIEW_ID, build()); + } else { + self.chat_widget.show_selection_view(build()); + } + } + + fn btw_thread_cwd(&self, app_server: &AppServerSession) -> Option { + if app_server.is_remote() { + app_server + .remote_cwd_override() + .map(|cwd| cwd.to_string_lossy().to_string()) + } else { + Some(self.config.cwd.to_string_lossy().to_string()) + } + } + + fn btw_turn_cwd_path(&self, app_server: &AppServerSession) -> PathBuf { + if app_server.is_remote() { + app_server + .remote_cwd_override() + .map(PathBuf::from) + .unwrap_or_else(|| self.config.cwd.to_path_buf()) + } else { + self.config.cwd.to_path_buf() + } + } +} + +fn request_id() -> RequestId { + RequestId::String(format!("btw-{}", Uuid::new_v4())) +} + +fn btw_turn_input(prompt: &str) -> Vec { + vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }] +} + +fn btw_thread_start_params(app: &App, app_server: &AppServerSession) -> ThreadStartParams { + ThreadStartParams { + model: Some(app.chat_widget.current_model().to_string()), + model_provider: (!app_server.is_remote()).then_some(app.config.model_provider_id.clone()), + cwd: app.btw_thread_cwd(app_server), + approval_policy: Some(AskForApproval::Never.into()), + approvals_reviewer: Some(AppServerApprovalsReviewer::from( + app.config.approvals_reviewer, + )), + sandbox: Some(SandboxMode::ReadOnly), + config: config_overrides(app.active_profile.as_deref()), + developer_instructions: Some(merge_developer_instructions( + app.config.developer_instructions.as_deref(), + )), + personality: app.config.personality, + ephemeral: Some(true), + persist_extended_history: true, + ..ThreadStartParams::default() + } +} + +fn btw_thread_fork_params( + app: &App, + thread_id: ThreadId, + app_server: &AppServerSession, +) -> ThreadForkParams { + ThreadForkParams { + thread_id: thread_id.to_string(), + model: Some(app.chat_widget.current_model().to_string()), + model_provider: (!app_server.is_remote()).then_some(app.config.model_provider_id.clone()), + cwd: app.btw_thread_cwd(app_server), + approval_policy: Some(AskForApproval::Never.into()), + approvals_reviewer: Some(AppServerApprovalsReviewer::from( + app.config.approvals_reviewer, + )), + sandbox: Some(SandboxMode::ReadOnly), + config: config_overrides(app.active_profile.as_deref()), + developer_instructions: Some(merge_developer_instructions( + app.config.developer_instructions.as_deref(), + )), + ephemeral: true, + persist_extended_history: true, + ..ThreadForkParams::default() + } +} + +fn config_overrides(active_profile: Option<&str>) -> Option> { + active_profile.map(|profile| { + HashMap::from([( + "profile".to_string(), + serde_json::Value::String(profile.to_string()), + )]) + }) +} + +fn merge_developer_instructions(existing: Option<&str>) -> String { + match existing { + Some(existing) if !existing.trim().is_empty() => { + format!("{existing}\n\n{BTW_DEVELOPER_INSTRUCTIONS}") + } + _ => BTW_DEVELOPER_INSTRUCTIONS.to_string(), + } +} + +fn preview_text(message: &str) -> String { + let trimmed = message.trim(); + if trimmed.chars().count() <= PREVIEW_CHAR_LIMIT { + return trimmed.to_string(); + } + + let preview: String = trimmed.chars().take(PREVIEW_CHAR_LIMIT).collect(); + format!("{preview}\n\n...preview truncated...") +} + +fn summarize_message(message: &str) -> String { + let mut kept = Vec::new(); + let mut used_chars = 0usize; + for line in message + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let next_len = used_chars.saturating_add(line.chars().count()); + if !kept.is_empty() && (kept.len() >= SUMMARY_MAX_LINES || next_len > SUMMARY_MAX_CHARS) { + break; + } + kept.push(line.to_string()); + used_chars = next_len; + } + + if kept.is_empty() { + "BTW summary:\n(Empty answer)".to_string() + } else { + format!("BTW summary:\n{}", kept.join("\n")) + } +} + +fn full_insert_text(message: &str) -> String { + format!("BTW discussion:\n{message}") +} + +fn last_agent_message_for_turn(turn: &codex_app_server_protocol::Turn) -> Option { + turn.items.iter().rev().find_map(|item| match item { + codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + _ => None, + }) +} + +fn last_agent_message_or_error(turn: &codex_app_server_protocol::Turn) -> Result { + last_agent_message_for_turn(turn) + .ok_or_else(|| "Temporary discussion finished without a final answer.".to_string()) +} + +fn turn_failed_error(turn: &codex_app_server_protocol::Turn) -> Result { + Err(turn + .error + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or_else(|| "Temporary discussion failed without a final error.".to_string())) +} + +fn btw_loading_view_params(status_message: Option<&str>) -> SelectionViewParams { + let mut body = String::new(); + if let Some(status_message) = status_message.filter(|status| !status.trim().is_empty()) { + body.push_str("Current hidden status:\n"); + body.push_str(status_message); + body.push_str("\n\n"); + } + body.push_str( + "Codex is answering in a hidden ephemeral thread. Nothing will be written back to the \ + main composer unless you explicitly choose an insert action.", + ); + SelectionViewParams { + view_id: Some(BTW_DISCUSSION_VIEW_ID), + title: Some("Temporary BTW discussion".to_string()), + subtitle: Some("Running a hidden temporary discussion thread.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: "Discard".to_string(), + description: Some("Cancel and destroy the temporary discussion.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))], + dismiss_on_select: true, + ..Default::default() + }], + side_content: Paragraph::new(body).wrap(Wrap { trim: false }).into(), + side_content_width: SideContentWidth::Half, + side_content_min_width: 28, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +fn btw_failure_message(error: &str, last_status: Option<&str>) -> String { + let mut message = format!("`/btw` failed: {error}"); + if let Some(last_status) = last_status.filter(|status| !status.trim().is_empty()) + && !message.contains(last_status) + { + message.push_str("\n\nLast hidden status:\n"); + message.push_str(last_status); + } + message +} + +fn btw_request_rejection_reason(request: &ServerRequest) -> String { + match request { + ServerRequest::ToolRequestUserInput { .. } => { + "the hidden temporary discussion asked for interactive user input. Run the prompt in the main thread instead.".to_string() + } + ServerRequest::CommandExecutionRequestApproval { .. } => { + "the hidden temporary discussion requested command approval, which `/btw` does not support.".to_string() + } + ServerRequest::FileChangeRequestApproval { .. } => { + "the hidden temporary discussion tried to change files, which `/btw` does not allow.".to_string() + } + ServerRequest::PermissionsRequestApproval { .. } => { + "the hidden temporary discussion requested extra permissions, which `/btw` does not allow.".to_string() + } + ServerRequest::McpServerElicitationRequest { .. } => { + "the hidden temporary discussion requested MCP interaction, which `/btw` cannot surface.".to_string() + } + ServerRequest::DynamicToolCall { .. } => { + "the hidden temporary discussion required an unsupported client-side tool call.".to_string() + } + ServerRequest::ChatgptAuthTokensRefresh { .. } + | ServerRequest::ApplyPatchApproval { .. } + | ServerRequest::ExecCommandApproval { .. } => { + "the hidden temporary discussion required an unsupported client-side request.".to_string() + } + } +} + +fn btw_status_message(notification: &ServerNotification) -> Option { + match notification { + ServerNotification::HookStarted(notification) => { + let label = hook_event_label(notification.run.event_name); + Some(match notification.run.status_message.as_deref() { + Some(status_message) if !status_message.is_empty() => { + format!("Running {label} hook: {status_message}") + } + _ => format!("Running {label} hook"), + }) + } + ServerNotification::HookCompleted(notification) => { + let label = hook_event_label(notification.run.event_name); + let status = format!("{:?}", notification.run.status).to_lowercase(); + let mut message = format!("{label} hook ({status})"); + if let Some(entry) = notification.run.entries.iter().find(|entry| { + matches!( + entry.kind, + AppServerHookOutputEntryKind::Warning + | AppServerHookOutputEntryKind::Stop + | AppServerHookOutputEntryKind::Error + ) + }) { + message.push_str(": "); + message.push_str(&entry.text); + } + Some(message) + } + ServerNotification::Error(notification) => Some(notification.error.message.clone()), + _ => None, + } +} + +fn hook_event_label(event_name: AppServerHookEventName) -> &'static str { + match event_name { + AppServerHookEventName::PreToolUse => "PreToolUse", + AppServerHookEventName::PostToolUse => "PostToolUse", + AppServerHookEventName::SessionStart => "SessionStart", + AppServerHookEventName::UserPromptSubmit => "UserPromptSubmit", + AppServerHookEventName::Stop => "Stop", + } +} + +fn btw_result_view_params(message: &str) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(BTW_DISCUSSION_VIEW_ID), + title: Some("Temporary BTW answer".to_string()), + subtitle: Some("Choose what to do with the temporary answer.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Insert Summary".to_string(), + description: Some("Insert a short summary into the main composer.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwInsertSummary))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Insert Full".to_string(), + description: Some("Insert the full answer into the main composer.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwInsertFull))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Discard".to_string(), + description: Some( + "Destroy the temporary discussion and keep the main composer untouched." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))], + dismiss_on_select: true, + ..Default::default() + }, + ], + side_content: Paragraph::new(preview_text(message)) + .wrap(Wrap { trim: false }) + .into(), + side_content_width: SideContentWidth::Half, + side_content_min_width: 28, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +fn btw_failure_view_params(error: &str) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(BTW_DISCUSSION_VIEW_ID), + title: Some("Temporary BTW failed".to_string()), + subtitle: Some("The hidden temporary discussion did not complete.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: "Close".to_string(), + description: Some("Dismiss this temporary discussion.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))], + dismiss_on_select: true, + ..Default::default() + }], + side_content: Paragraph::new(error.to_string()) + .wrap(Wrap { trim: false }) + .into(), + side_content_width: SideContentWidth::Half, + side_content_min_width: 28, + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + use super::btw_failure_view_params; + use super::btw_loading_view_params; + use super::btw_result_view_params; + use super::merge_developer_instructions; + use super::preview_text; + use super::summarize_message; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::render::renderable::Renderable; + + fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, width, height); + view.render(area, frame.buffer_mut()); + }) + .expect("draw popup"); + format!("{:?}", terminal.backend()) + } + + #[test] + fn btw_loading_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new(btw_loading_view_params(/*status_message*/ None), tx); + + assert_snapshot!( + "btw_loading_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 20) + ); + } + + #[test] + fn btw_result_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + btw_result_view_params( + "Use a hidden thread to brainstorm tradeoffs, then choose whether to insert the \ + summary or the full answer back into the main composer.", + ), + tx, + ); + + assert_snapshot!( + "btw_result_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 28) + ); + } + + #[test] + fn btw_failure_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + btw_failure_view_params("`/btw` failed: upstream unavailable"), + tx, + ); + + assert_snapshot!( + "btw_failure_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 20) + ); + } + + #[test] + fn summarize_btw_message_keeps_short_prefix_for_insertion() { + let summary = summarize_message( + "First point.\n\nSecond point.\nThird point.\nFourth point.\nFifth point.", + ); + + assert_eq!( + summary, + "BTW summary:\nFirst point.\nSecond point.\nThird point.\nFourth point." + ); + } + + #[test] + fn preview_text_truncates_long_messages() { + let preview = preview_text(&"a".repeat(1_250)); + assert!(preview.contains("preview truncated")); + } + + #[test] + fn merge_developer_instructions_appends_btw_guardrail() { + assert_eq!( + merge_developer_instructions(Some("Stay focused.")), + format!("Stay focused.\n\n{}", super::BTW_DEVELOPER_INSTRUCTIONS) + ); + } +} diff --git a/codex-rs/tui/src/app/clawbot.rs b/codex-rs/tui/src/app/clawbot.rs new file mode 100644 index 000000000..bd3f648a5 --- /dev/null +++ b/codex-rs/tui/src/app/clawbot.rs @@ -0,0 +1,914 @@ +use std::collections::HashMap; +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::TurnStatus; +use codex_clawbot::CachedUnreadMessage; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotStore; +use codex_clawbot::ClawbotTurnMode; +use codex_clawbot::FeishuConfig; +use codex_clawbot::FeishuProviderRuntime; +use codex_clawbot::PendingClawbotTurn; +use codex_clawbot::ProviderEvent; +use codex_clawbot::ProviderMessageRef; +use codex_clawbot::ProviderOutboundReaction; +use codex_clawbot::ProviderOutboundTextMessage; +use codex_clawbot::ProviderSessionRef; +use codex_clawbot::append_diagnostic_event; +use codex_clawbot::feishu_failure_reply_text; +use codex_protocol::ThreadId; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::GranularApprovalConfig; +use codex_protocol::protocol::Op; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; +use tokio::sync::mpsc; + +use super::App; +use crate::app_command::AppCommand; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::app_server_session::ThreadSessionState; + +const FEISHU_AUTO_ACK_EMOJI_TYPE: &str = "TONGUE"; + +impl App { + pub(super) async fn sync_clawbot_workspace(&mut self, app_server: &mut AppServerSession) { + if let Err(err) = self.sync_clawbot_workspace_inner(app_server).await { + tracing::warn!(error = %err, "failed to sync clawbot workspace"); + self.chat_widget + .add_error_message(format!("Clawbot workspace sync failed: {err}")); + } + } + + async fn sync_clawbot_workspace_inner( + &mut self, + app_server: &mut AppServerSession, + ) -> Result<()> { + let workspace_root = self.config.cwd.to_path_buf(); + let workspace_changed = self.clawbot_workspace_root.as_ref() != Some(&workspace_root); + if workspace_changed { + self.abort_clawbot_provider_runtime(); + self.clawbot_workspace_root = Some(workspace_root.clone()); + self.clawbot_pending_turns.clear(); + self.refresh_clawbot_provider_runtime()?; + if let Ok(mut runtime) = ClawbotRuntime::load(workspace_root.clone()) + && runtime.snapshot().config.feishu.is_some() + && let Err(err) = runtime.scan_feishu_sessions().await + { + tracing::warn!(error = %err, "failed to refresh clawbot Feishu sessions"); + } + } + self.restore_clawbot_pending_turns()?; + self.reconcile_clawbot_pending_turns(app_server).await?; + + let runtime = ClawbotRuntime::load(workspace_root)?; + let sessions_to_drain = runtime + .snapshot() + .sessions + .iter() + .filter(|session| session.bound_thread_id.is_some()) + .filter(|session| session.unread_count > 0) + .map(codex_clawbot::ProviderSession::session_ref) + .collect::>(); + for session in sessions_to_drain { + self.dispatch_next_clawbot_message(app_server, &session) + .await?; + } + Ok(()) + } + + fn clawbot_store(&self) -> Result { + let workspace_root = self + .clawbot_workspace_root + .clone() + .or_else(|| Some(self.config.cwd.to_path_buf())) + .context("missing clawbot workspace root")?; + Ok(ClawbotStore::new(workspace_root)) + } + + fn restore_clawbot_pending_turns(&mut self) -> Result<()> { + let pending_turns = self.clawbot_store()?.load_pending_turns()?; + self.clawbot_pending_turns.clear(); + for pending_turn in pending_turns { + let thread_id = ThreadId::from_string(&pending_turn.thread_id).with_context(|| { + format!("invalid clawbot thread id `{}`", pending_turn.thread_id) + })?; + self.clawbot_pending_turns + .entry(thread_id) + .or_default() + .push_back(pending_turn); + } + Ok(()) + } + + async fn reconcile_clawbot_pending_turns( + &mut self, + app_server: &mut AppServerSession, + ) -> Result<()> { + let pending_turns = self + .clawbot_pending_turns + .values() + .flat_map(|queue| queue.iter().cloned()) + .collect::>(); + for pending_turn in pending_turns { + let thread_id = ThreadId::from_string(&pending_turn.thread_id).with_context(|| { + format!("invalid clawbot thread id `{}`", pending_turn.thread_id) + })?; + let thread = match app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + { + Ok(thread) => thread, + Err(err) => { + tracing::warn!( + thread_id = pending_turn.thread_id, + turn_id = pending_turn.turn_id, + error = %err, + "failed to reconcile clawbot pending turn; clearing stale entry" + ); + let _ = self.remove_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?; + continue; + } + }; + let Some(turn) = thread + .turns + .iter() + .find(|turn| turn.id == pending_turn.turn_id) + .cloned() + else { + if !matches!(thread.status, ThreadStatus::Active { .. }) { + let _ = self.remove_pending_clawbot_turn(thread_id, &pending_turn.turn_id)?; + } + continue; + }; + match turn.status { + TurnStatus::Completed | TurnStatus::Failed | TurnStatus::Interrupted => { + self.handle_clawbot_turn_completed(app_server, thread_id, turn) + .await?; + } + TurnStatus::InProgress => { + self.attach_clawbot_bound_thread_if_needed(app_server, thread_id) + .await?; + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.active_turn_id = Some(turn.id); + } + } + } + } + Ok(()) + } + + fn start_clawbot_provider_runtime(&mut self, workspace_root: &Path, config: FeishuConfig) { + self.abort_clawbot_provider_runtime(); + let app_event_tx = self.app_event_tx.clone(); + let workspace = workspace_root.display().to_string(); + let workspace_root = workspace_root.to_path_buf(); + self.clawbot_provider_task = Some(tokio::spawn(async move { + let (provider_event_tx, mut provider_event_rx) = mpsc::unbounded_channel(); + let forward_task = tokio::spawn(async move { + while let Some(event) = provider_event_rx.recv().await { + app_event_tx.send(AppEvent::ClawbotProviderEvent { event }); + } + }); + if let Err(err) = FeishuProviderRuntime::new(workspace_root.to_path_buf(), config) + .run(provider_event_tx) + .await + { + tracing::warn!(workspace, error = %err, "clawbot provider runtime exited"); + } + forward_task.abort(); + let _ = forward_task.await; + })); + } + + pub(super) fn abort_clawbot_provider_runtime(&mut self) { + if let Some(handle) = self.clawbot_provider_task.take() { + handle.abort(); + } + } + + pub(super) fn refresh_clawbot_provider_runtime(&mut self) -> Result<()> { + let workspace_root = self.config.cwd.to_path_buf(); + self.clawbot_workspace_root = Some(workspace_root.clone()); + let runtime = ClawbotRuntime::load(workspace_root.clone())?; + if let Some(feishu) = runtime.snapshot().config.feishu.clone() + && feishu.has_api_credentials() + { + self.start_clawbot_provider_runtime(workspace_root.as_path(), feishu); + } else { + self.abort_clawbot_provider_runtime(); + } + Ok(()) + } + + pub(super) async fn handle_clawbot_provider_event( + &mut self, + app_server: &mut AppServerSession, + event: ProviderEvent, + ) -> Result<()> { + let Some(workspace_root) = self.clawbot_workspace_root.clone() else { + return Ok(()); + }; + let session_to_drain = match &event { + ProviderEvent::InboundMessage(message) => { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.inbound_message_received", + serde_json::json!({ + "session_id": message.session.session_id, + "message_id": message.message_id, + "text": message.text, + }), + ); + Some(message.session.clone()) + } + _ => None, + }; + let mut runtime = ClawbotRuntime::load(workspace_root)?; + runtime.apply_provider_event(event)?; + if let Some(session) = session_to_drain { + self.dispatch_next_clawbot_message(app_server, &session) + .await?; + } + Ok(()) + } + + pub(super) async fn handle_clawbot_turn_completed( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + turn: codex_app_server_protocol::Turn, + ) -> Result<()> { + let Some(workspace_root) = self.clawbot_workspace_root.clone() else { + return Ok(()); + }; + let next_session = if let Some(pending) = + self.take_pending_clawbot_turn(thread_id, &turn.id)? + { + let reply_text = if let Some(text) = clawbot_outbound_text_for_turn(&turn) { + Some(text) + } else if matches!( + turn.status, + codex_app_server_protocol::TurnStatus::Completed + | codex_app_server_protocol::TurnStatus::Failed + | codex_app_server_protocol::TurnStatus::Interrupted + ) { + match app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + { + Ok(thread) => thread + .turns + .iter() + .find(|thread_turn| thread_turn.id == turn.id) + .and_then(clawbot_outbound_text_for_turn), + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + turn_id = turn.id, + error = %err, + "failed to load full turn for clawbot outbound reply" + ); + None + } + } + } else { + None + }; + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.turn_completed", + serde_json::json!({ + "session_id": pending.session.session_id.clone(), + "thread_id": thread_id, + "turn_id": turn.id, + "status": format!("{:?}", turn.status), + "reply_text": reply_text.clone(), + }), + ); + if let Some(text) = reply_text { + self.send_clawbot_thread_reply(workspace_root.as_path(), &pending.session, text) + .await?; + } + let mut runtime = ClawbotRuntime::load(workspace_root.clone())?; + let removed = runtime.take_next_unread_message(&pending.session)?; + if removed.as_ref().map(|entry| entry.message_id.as_str()) + != Some(pending.message_id.as_str()) + { + tracing::warn!( + session = pending.session.session_id, + expected = pending.message_id, + actual = removed.as_ref().map(|entry| entry.message_id.as_str()), + "clawbot unread FIFO state drifted while completing turn" + ); + } + Some(pending.session) + } else { + let runtime = ClawbotRuntime::load(workspace_root)?; + runtime.bound_session_for_thread(&thread_id.to_string())? + }; + + if let Some(session) = next_session { + self.dispatch_next_clawbot_message(app_server, &session) + .await?; + } + Ok(()) + } + + pub(super) async fn dispatch_next_clawbot_message( + &mut self, + app_server: &mut AppServerSession, + session: &ProviderSessionRef, + ) -> Result<()> { + let Some(workspace_root) = self.clawbot_workspace_root.clone() else { + return Ok(()); + }; + let runtime = ClawbotRuntime::load(workspace_root.clone())?; + let Some(binding) = runtime.load_binding_for_session(session)? else { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "missing_binding", + "session_id": session.session_id, + }), + ); + return Ok(()); + }; + if !binding.inbound_forwarding_enabled { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "inbound_forwarding_disabled", + "session_id": session.session_id, + "thread_id": binding.thread_id, + }), + ); + return Ok(()); + } + let thread_id = ThreadId::from_string(&binding.thread_id) + .with_context(|| format!("invalid clawbot thread id `{}`", binding.thread_id))?; + if self.active_turn_id_for_thread(thread_id).await.is_some() { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "thread_busy", + "session_id": session.session_id, + "thread_id": thread_id, + }), + ); + return Ok(()); + } + if self + .clawbot_pending_turns + .get(&thread_id) + .is_some_and(|queue| queue.iter().any(|pending| pending.session == *session)) + { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "pending_turn", + "session_id": session.session_id, + "thread_id": thread_id, + }), + ); + return Ok(()); + } + + let Some(message) = next_unread_message_for_session(&runtime, session) else { + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.dispatch_skipped", + serde_json::json!({ + "reason": "no_unread_message", + "session_id": session.session_id, + "thread_id": thread_id, + }), + ); + return Ok(()); + }; + let turn_mode = runtime.snapshot().config.turn_mode; + self.attach_clawbot_bound_thread_if_needed(app_server, thread_id) + .await?; + if let Err(err) = self + .send_clawbot_outbound_reaction(ProviderOutboundReaction { + target: ProviderMessageRef::new( + message.provider, + message.session_id.clone(), + message.message_id.clone(), + ), + emoji_type: FEISHU_AUTO_ACK_EMOJI_TYPE.to_string(), + }) + .await + { + tracing::warn!( + thread_id = %thread_id, + session = session.session_id, + error = %err, + "failed to auto-ack clawbot inbound message" + ); + } + let turn_id = self + .submit_clawbot_message_to_thread( + app_server, + thread_id, + message.text.clone(), + turn_mode, + ) + .await?; + let _ = append_diagnostic_event( + workspace_root.as_path(), + "bridge.thread_turn_started", + serde_json::json!({ + "session_id": session.session_id, + "thread_id": thread_id, + "turn_id": turn_id, + "message_id": message.message_id.clone(), + "text": message.text.clone(), + }), + ); + self.register_pending_clawbot_turn( + thread_id, + session.clone(), + turn_id, + message.message_id.clone(), + turn_mode, + ); + Ok(()) + } + + async fn attach_clawbot_bound_thread_if_needed( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> Result<()> { + if self.thread_event_channels.contains_key(&thread_id) { + return Ok(()); + } + if let Err(attach_err) = self + .attach_live_thread_for_selection(app_server, thread_id) + .await + { + tracing::warn!( + thread_id = %thread_id, + error = %attach_err, + "falling back to thread/read for clawbot thread attachment" + ); + let thread = app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + .map_err(|err| { + anyhow::anyhow!("failed to read clawbot thread {thread_id}: {err}") + })?; + let session = self.session_state_for_thread_read(thread_id, &thread).await; + let channel = self.ensure_thread_channel(thread_id); + let mut store = channel.store.lock().await; + store.set_session(session, Vec::new()); + } + Ok(()) + } + + async fn submit_clawbot_message_to_thread( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + message: String, + turn_mode: ClawbotTurnMode, + ) -> Result { + let trimmed = message.trim().to_string(); + if trimmed.is_empty() { + return Err(anyhow::anyhow!("cannot submit empty clawbot message")); + } + + let session = self + .clawbot_thread_session(thread_id) + .await? + .with_context(|| { + format!("missing live thread session for clawbot thread {thread_id}") + })?; + let op: AppCommand = Op::UserTurn { + items: vec![UserInput::Text { + text: trimmed.clone(), + text_elements: Vec::new(), + }], + cwd: session.cwd.clone(), + approval_policy: clawbot_approval_policy(session.approval_policy, turn_mode), + approvals_reviewer: Some(session.approvals_reviewer), + sandbox_policy: session.sandbox_policy.clone(), + model: session.model.clone(), + effort: session.reasoning_effort, + summary: None, + service_tier: Some(session.service_tier), + final_output_json_schema: None, + collaboration_mode: None, + personality: self.config.personality, + } + .into(); + crate::session_log::log_outbound_op(&op); + let response = app_server + .turn_start( + thread_id, + vec![UserInput::Text { + text: trimmed, + text_elements: Vec::new(), + }], + session.cwd, + clawbot_approval_policy(session.approval_policy, turn_mode), + session.approvals_reviewer, + session.sandbox_policy, + session.model, + session.reasoning_effort, + /*summary*/ None, + Some(session.service_tier), + /*collaboration_mode*/ None, + self.config.personality, + /*output_schema*/ None, + ) + .await + .map_err(|err| { + anyhow::anyhow!("failed to start clawbot turn for thread {thread_id}: {err}") + })?; + self.note_thread_outbound_op(thread_id, &op).await; + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.active_turn_id = Some(response.turn.id.clone()); + } + Ok(response.turn.id) + } + + async fn clawbot_thread_session( + &self, + thread_id: ThreadId, + ) -> Result> { + let Some(channel) = self.thread_event_channels.get(&thread_id) else { + return Ok(None); + }; + let store = channel.store.lock().await; + Ok(store.session.clone()) + } + + async fn send_clawbot_thread_reply( + &mut self, + workspace_root: &Path, + session: &ProviderSessionRef, + text: String, + ) -> Result<()> { + let runtime = ClawbotRuntime::load(workspace_root.to_path_buf())?; + let Some(binding) = runtime.load_binding_for_session(session)? else { + let _ = append_diagnostic_event( + workspace_root, + "bridge.reply_skipped", + serde_json::json!({ + "reason": "missing_binding", + "session_id": session.session_id, + "text": text, + }), + ); + return Ok(()); + }; + if !binding.outbound_forwarding_enabled { + let _ = append_diagnostic_event( + workspace_root, + "bridge.reply_skipped", + serde_json::json!({ + "reason": "outbound_forwarding_disabled", + "session_id": session.session_id, + "thread_id": binding.thread_id, + "text": text, + }), + ); + return Ok(()); + } + self.send_clawbot_outbound_text( + workspace_root, + ProviderOutboundTextMessage { + session: session.clone(), + text, + }, + ) + .await + } + + async fn send_clawbot_outbound_text( + &mut self, + workspace_root: &Path, + message: ProviderOutboundTextMessage, + ) -> Result<()> { + #[cfg(test)] + { + let _ = workspace_root; + self.clawbot_outbound_messages.push(message); + Ok(()) + } + + #[cfg(not(test))] + { + let runtime = ClawbotRuntime::load(workspace_root.to_path_buf())?; + let provider = runtime + .feishu_provider() + .context("missing Feishu config for clawbot outbound bridge")?; + let session_id = message.session.session_id.clone(); + let text = message.text.clone(); + let send_result = provider.send_text(message).await; + let kind = if send_result.is_ok() { + "bridge.reply_forwarded" + } else { + "bridge.reply_forward_failed" + }; + let _ = append_diagnostic_event( + workspace_root, + kind, + serde_json::json!({ + "session_id": session_id, + "text": text, + "error": send_result.as_ref().err().map(ToString::to_string), + }), + ); + send_result + } + } + + async fn send_clawbot_outbound_reaction( + &mut self, + reaction: ProviderOutboundReaction, + ) -> Result<()> { + #[cfg(test)] + { + self.clawbot_outbound_reactions.push(reaction); + Ok(()) + } + + #[cfg(not(test))] + { + let workspace_root = self + .clawbot_workspace_root + .clone() + .context("missing clawbot workspace root for outbound reaction")?; + let runtime = ClawbotRuntime::load(workspace_root)?; + let provider = runtime + .feishu_provider() + .context("missing Feishu config for clawbot outbound bridge")?; + provider.add_reaction(reaction).await + } + } + + fn register_pending_clawbot_turn( + &mut self, + thread_id: ThreadId, + session: ProviderSessionRef, + turn_id: String, + message_id: String, + turn_mode: ClawbotTurnMode, + ) { + let pending_turn = PendingClawbotTurn { + thread_id: thread_id.to_string(), + turn_id, + session, + message_id, + turn_mode, + }; + self.clawbot_pending_turns + .entry(thread_id) + .or_default() + .push_back(pending_turn.clone()); + if let Err(err) = self + .clawbot_store() + .and_then(|store| store.upsert_pending_turn(pending_turn)) + { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to persist clawbot pending turn" + ); + } + } + + fn take_pending_clawbot_turn( + &mut self, + thread_id: ThreadId, + turn_id: &str, + ) -> Result> { + let Some(queue) = self.clawbot_pending_turns.get_mut(&thread_id) else { + return Ok(None); + }; + let pending = queue + .iter() + .position(|pending| pending.turn_id == turn_id) + .and_then(|index| queue.remove(index)); + if queue.is_empty() { + self.clawbot_pending_turns.remove(&thread_id); + } + if pending.is_some() { + let _ = self.remove_pending_clawbot_turn(thread_id, turn_id)?; + } + Ok(pending) + } + + fn remove_pending_clawbot_turn( + &mut self, + thread_id: ThreadId, + turn_id: &str, + ) -> Result> { + self.clawbot_store()? + .remove_pending_turn(&thread_id.to_string(), turn_id) + } + + fn clawbot_turn_mode_for_turn( + &self, + thread_id: ThreadId, + turn_id: &str, + ) -> Option { + self.clawbot_pending_turns + .get(&thread_id)? + .iter() + .find(|pending| pending.turn_id == turn_id) + .map(|pending| pending.turn_mode) + } + + pub(super) fn clawbot_auto_response_op_for_server_request( + &self, + thread_id: ThreadId, + request: &ServerRequest, + ) -> Option { + match request { + ServerRequest::ToolRequestUserInput { params, .. } => { + let turn_mode = self.clawbot_turn_mode_for_turn(thread_id, ¶ms.turn_id)?; + if !turn_mode.uses_noninteractive_prompt_handling() { + return None; + } + Some(AppCommand::user_input_answer( + params.turn_id.clone(), + RequestUserInputResponse { + answers: HashMap::new(), + }, + )) + } + ServerRequest::PermissionsRequestApproval { params, .. } => { + let turn_mode = self.clawbot_turn_mode_for_turn(thread_id, ¶ms.turn_id)?; + if !turn_mode.uses_noninteractive_prompt_handling() { + return None; + } + Some(AppCommand::request_permissions_response( + params.item_id.clone(), + RequestPermissionsResponse { + permissions: Default::default(), + scope: PermissionGrantScope::Turn, + }, + )) + } + _ => None, + } + } + + pub(super) async fn maybe_auto_resolve_clawbot_server_request( + &mut self, + app_server: &AppServerSession, + thread_id: ThreadId, + request: &ServerRequest, + ) -> bool { + let Some(op) = self.clawbot_auto_response_op_for_server_request(thread_id, request) else { + return false; + }; + + match self + .try_resolve_app_server_request(app_server, thread_id, &op) + .await + { + Ok(true) => true, + Ok(false) => { + self.pending_app_server_requests + .note_server_request(request); + false + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to auto-resolve clawbot server request" + ); + self.pending_app_server_requests + .note_server_request(request); + false + } + } + } +} + +fn clawbot_approval_policy( + existing_policy: AskForApproval, + turn_mode: ClawbotTurnMode, +) -> AskForApproval { + if !turn_mode.uses_noninteractive_prompt_handling() { + return existing_policy; + } + + AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }) +} + +fn clawbot_outbound_text_for_turn(turn: &codex_app_server_protocol::Turn) -> Option { + match turn.status { + codex_app_server_protocol::TurnStatus::Completed => turn.items.iter().find_map(|item| { + let codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } = item else { + return None; + }; + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }), + codex_app_server_protocol::TurnStatus::Failed => turn + .error + .as_ref() + .map(|error| feishu_failure_reply_text(&error.message)), + codex_app_server_protocol::TurnStatus::Interrupted => { + last_agent_message_for_turn(turn).or_else(|| Some("Request interrupted.".to_string())) + } + codex_app_server_protocol::TurnStatus::InProgress => None, + } +} + +fn last_agent_message_for_turn(turn: &codex_app_server_protocol::Turn) -> Option { + turn.items.iter().rev().find_map(|item| { + let codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } = item else { + return None; + }; + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +fn next_unread_message_for_session( + runtime: &ClawbotRuntime, + session: &ProviderSessionRef, +) -> Option { + runtime + .store() + .load_unread_messages() + .ok()? + .into_iter() + .filter(|message| message.session_ref() == *session) + .min_by(|left, right| { + left.received_at + .cmp(&right.received_at) + .then(left.message_id.cmp(&right.message_id)) + }) +} + +#[cfg(test)] +mod tests { + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnStatus; + use pretty_assertions::assert_eq; + + use super::clawbot_outbound_text_for_turn; + + #[test] + fn interrupted_turn_uses_last_agent_message_for_reply() { + let turn = Turn { + id: "turn-1".to_string(), + status: TurnStatus::Interrupted, + items: vec![ + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "first reply".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::AgentMessage { + id: "agent-2".to_string(), + text: " ".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::AgentMessage { + id: "agent-3".to_string(), + text: "last reply".to_string(), + phase: None, + memory_citation: None, + }, + ], + error: None, + }; + + assert_eq!( + clawbot_outbound_text_for_turn(&turn), + Some("last reply".to_string()) + ); + } +} diff --git a/codex-rs/tui/src/app/clawbot_controls.rs b/codex-rs/tui/src/app/clawbot_controls.rs new file mode 100644 index 000000000..eacbf2e19 --- /dev/null +++ b/codex-rs/tui/src/app/clawbot_controls.rs @@ -0,0 +1,919 @@ +use anyhow::Context; +use anyhow::Result; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotTurnMode; +use codex_clawbot::ConnectionStatus; +use codex_clawbot::FeishuConfig; +use codex_clawbot::ForwardingDirection; +use codex_clawbot::ForwardingState; +use codex_clawbot::ProviderKind; +use codex_clawbot::ProviderRuntimeState; +use codex_clawbot::ProviderSession; +use codex_clawbot::ProviderSessionRef; +use codex_protocol::ThreadId; + +use super::App; +use crate::app_event::AppEvent; +use crate::app_event::ClawbotFeishuConfigField; +use crate::app_event::ClawbotForwardingChannel; +use crate::app_event_sender::AppEventSender; +use crate::app_server_session::AppServerSession; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +const CLAWBOT_MANAGEMENT_VIEW_ID: &str = "clawbot-management"; + +impl ClawbotFeishuConfigField { + fn title(self) -> &'static str { + match self { + Self::AppId => "Feishu App ID", + Self::AppSecret => "Feishu App Secret", + Self::VerificationToken => "Feishu Verification Token", + Self::EncryptKey => "Feishu Encrypt Key", + Self::BotOpenId => "Feishu Bot Open ID", + Self::BotUserId => "Feishu Bot User ID", + } + } + + fn current_value(self, config: Option<&FeishuConfig>) -> Option { + let value = match self { + Self::AppId => config.map(|config| config.app_id.clone()), + Self::AppSecret => config.map(|config| config.app_secret.clone()), + Self::VerificationToken => config.and_then(|config| config.verification_token.clone()), + Self::EncryptKey => config.and_then(|config| config.encrypt_key.clone()), + Self::BotOpenId => config.and_then(|config| config.bot_open_id.clone()), + Self::BotUserId => config.and_then(|config| config.bot_user_id.clone()), + }?; + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + } + + fn description(self, config: Option<&FeishuConfig>) -> String { + let Some(value) = self.current_value(config) else { + return "Not set".to_string(); + }; + if self.is_secret() { + format!("Configured: {}", mask_secret(&value)) + } else { + format!("Configured: {}", truncate_value(&value, /*max_chars*/ 28)) + } + } + + fn prompt_placeholder(self) -> &'static str { + match self { + Self::AppId => "Paste the Feishu app_id and press Enter", + Self::AppSecret => "Paste the Feishu app_secret and press Enter", + Self::VerificationToken => { + "Paste the verification token, or submit an empty value to clear it" + } + Self::EncryptKey => "Paste the encrypt key, or submit an empty value to clear it", + Self::BotOpenId => "Paste the bot open_id, or submit an empty value to clear it", + Self::BotUserId => "Paste the bot user_id, or submit an empty value to clear it", + } + } + + fn prompt_context_label(self, config: Option<&FeishuConfig>) -> String { + match self.current_value(config) { + Some(value) if self.is_secret() => { + format!("Current: {}", mask_secret(&value)) + } + Some(value) => { + format!("Current: {}", truncate_value(&value, /*max_chars*/ 40)) + } + None => "Current: not set".to_string(), + } + } + + fn is_secret(self) -> bool { + matches!( + self, + Self::AppSecret | Self::VerificationToken | Self::EncryptKey + ) + } +} + +impl ClawbotForwardingChannel { + fn title(self) -> &'static str { + match self { + Self::Inbound => "Inbound Forwarding", + Self::Outbound => "Outbound Forwarding", + } + } + + fn description(self, enabled: bool) -> String { + let direction = match self { + Self::Inbound => "Feishu -> Codex", + Self::Outbound => "Codex -> Feishu", + }; + let state = if enabled { "Enabled" } else { "Disabled" }; + format!("{state}: {direction}") + } + + fn selected_description(self, enabled: bool) -> String { + match (self, enabled) { + (Self::Inbound, true) => { + "Disable automatic delivery of unread Feishu messages into the bound thread." + .to_string() + } + (Self::Inbound, false) => { + "Re-enable automatic delivery of unread Feishu messages into the bound thread." + .to_string() + } + (Self::Outbound, true) => { + "Disable reply forwarding from Codex back to the bound Feishu session.".to_string() + } + (Self::Outbound, false) => { + "Re-enable reply forwarding from Codex back to the bound Feishu session." + .to_string() + } + } + } +} + +impl App { + pub(crate) fn open_clawbot_management_popup(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(CLAWBOT_MANAGEMENT_VIEW_ID); + let params = self.clawbot_management_popup_params(initial_selected_idx); + if !self + .chat_widget + .replace_selection_view_if_active(CLAWBOT_MANAGEMENT_VIEW_ID, params) + { + self.chat_widget + .show_selection_view(self.clawbot_management_popup_params(initial_selected_idx)); + } + } + + pub(crate) fn open_clawbot_feishu_config_prompt(&mut self, field: ClawbotFeishuConfigField) { + let config = ClawbotRuntime::load(self.config.cwd.to_path_buf()) + .ok() + .and_then(|runtime| runtime.snapshot().config.feishu.clone()); + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + field.title().to_string(), + field.prompt_placeholder().to_string(), + Some(field.prompt_context_label(config.as_ref())), + Box::new(move |value| { + tx.send(AppEvent::SaveClawbotFeishuConfigValue { field, value }); + }), + ); + self.chat_widget.show_view(Box::new(view)); + } + + pub(crate) fn open_clawbot_manual_bind_prompt(&mut self) { + let current_thread = self + .active_thread_id + .map(|thread_id| thread_id.to_string()) + .unwrap_or_else(|| "No active thread".to_string()); + let current_binding = self + .active_thread_id + .and_then(|thread_id| { + ClawbotRuntime::load(self.config.cwd.to_path_buf()) + .ok() + .and_then(|runtime| { + runtime + .bound_session_for_thread(&thread_id.to_string()) + .ok() + }) + .flatten() + .map(|session| session.session_id) + }) + .unwrap_or_else(|| "not bound".to_string()); + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "Bind Feishu session to current thread".to_string(), + "Paste a Feishu session id and press Enter".to_string(), + Some(format!( + "Thread: {current_thread} · Current binding: {current_binding}" + )), + Box::new(move |session_id| { + tx.send(AppEvent::SaveClawbotManualBindSessionId { session_id }); + }), + ); + self.chat_widget.show_view(Box::new(view)); + } + + pub(crate) fn save_clawbot_feishu_config_value( + &mut self, + field: ClawbotFeishuConfigField, + value: String, + ) -> Result<()> { + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + let mut config = runtime.snapshot().config.feishu.clone().unwrap_or_default(); + let trimmed = value.trim().to_string(); + match field { + ClawbotFeishuConfigField::AppId => { + config.app_id = trimmed; + } + ClawbotFeishuConfigField::AppSecret => { + config.app_secret = trimmed; + } + ClawbotFeishuConfigField::VerificationToken => { + config.verification_token = (!trimmed.is_empty()).then_some(trimmed); + } + ClawbotFeishuConfigField::EncryptKey => { + config.encrypt_key = (!trimmed.is_empty()).then_some(trimmed); + } + ClawbotFeishuConfigField::BotOpenId => { + config.bot_open_id = (!trimmed.is_empty()).then_some(trimmed); + } + ClawbotFeishuConfigField::BotUserId => { + config.bot_user_id = (!trimmed.is_empty()).then_some(trimmed); + } + } + runtime.update_feishu_config(Some(config))?; + self.refresh_clawbot_provider_runtime()?; + self.open_clawbot_management_popup(); + self.chat_widget + .add_info_message(format!("Updated {}.", field.title()), /*hint*/ None); + Ok(()) + } + + pub(crate) fn save_clawbot_turn_mode(&mut self, mode: ClawbotTurnMode) -> Result<()> { + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + runtime.update_turn_mode(mode)?; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!( + "Clawbot turn mode set to {}.", + clawbot_turn_mode_label(mode) + ), + /*hint*/ None, + ); + Ok(()) + } + + pub(crate) async fn bind_clawbot_session_to_current_thread( + &mut self, + app_server: &mut AppServerSession, + session_id: String, + ) -> Result<()> { + let thread_id = self + .active_thread_id + .context("no active thread available for Clawbot binding")?; + let trimmed = session_id.trim().to_string(); + if trimmed.is_empty() { + return Err(anyhow::anyhow!("session id cannot be empty")); + } + let session = ProviderSessionRef::new(ProviderKind::Feishu, trimmed.clone()); + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + if runtime.snapshot().config.feishu.is_some() { + runtime.scan_feishu_sessions().await?; + if !runtime + .snapshot() + .sessions + .iter() + .any(|existing| existing.session_ref() == session) + { + return Err(anyhow::anyhow!( + "Feishu session {trimmed} is not visible to the current bot" + )); + } + } + runtime.connect_session_to_thread(&session, thread_id.to_string())?; + self.refresh_clawbot_provider_runtime()?; + self.dispatch_next_clawbot_message(app_server, &session) + .await?; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!("Bound thread {thread_id} to Feishu session {trimmed}."), + /*hint*/ None, + ); + Ok(()) + } + + pub(crate) fn clawbot_disconnect_current_thread(&mut self) -> Result<()> { + let thread_id = self + .active_thread_id + .context("no active thread available for Clawbot disconnect")?; + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + let Some(session) = runtime.disconnect_thread(&thread_id.to_string())? else { + return Err(anyhow::anyhow!( + "current thread is not bound to a Clawbot session" + )); + }; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!( + "Disconnected Feishu session {} from thread {thread_id}.", + session.session_id + ), + /*hint*/ None, + ); + Ok(()) + } + + pub(crate) fn clawbot_set_current_thread_forwarding( + &mut self, + channel: ClawbotForwardingChannel, + enabled: bool, + ) -> Result<()> { + let thread_id = self + .active_thread_id + .context("no active thread available for Clawbot forwarding")?; + let direction = match channel { + ClawbotForwardingChannel::Inbound => ForwardingDirection::Inbound, + ClawbotForwardingChannel::Outbound => ForwardingDirection::Outbound, + }; + let state = if enabled { + ForwardingState::Enabled + } else { + ForwardingState::Disabled + }; + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + runtime + .set_forwarding_state_for_thread(&thread_id.to_string(), direction, state)? + .context("current thread is not bound to a Clawbot session")?; + self.open_clawbot_management_popup(); + self.chat_widget + .add_info_message(channel.description(enabled), /*hint*/ None); + Ok(()) + } + + pub(crate) fn retry_clawbot_feishu_connection(&mut self) -> Result<()> { + let runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + let Some(config) = runtime.snapshot().config.feishu.as_ref() else { + return Err(anyhow::anyhow!("Feishu credentials are not configured")); + }; + if !config.has_api_credentials() { + return Err(anyhow::anyhow!("Feishu app_id and app_secret are required")); + } + self.refresh_clawbot_provider_runtime()?; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + "Restarted Feishu runtime bridge.".to_string(), + /*hint*/ None, + ); + Ok(()) + } + + pub(crate) async fn scan_clawbot_feishu_sessions(&mut self) -> Result<()> { + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + runtime.scan_feishu_sessions().await?; + let discovered = runtime + .snapshot() + .sessions + .iter() + .filter(|session| session.provider == ProviderKind::Feishu) + .count(); + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!("Scanned Feishu sessions. {discovered} discovered."), + /*hint*/ None, + ); + Ok(()) + } + + pub(crate) fn clear_clawbot_feishu_sessions(&mut self) -> Result<()> { + let mut runtime = ClawbotRuntime::load(self.config.cwd.to_path_buf())?; + let sessions_before = runtime + .snapshot() + .sessions + .iter() + .filter(|session| { + session.provider == ProviderKind::Feishu && session.bound_thread_id.is_none() + }) + .count(); + let unread_before = runtime + .store() + .load_unread_messages()? + .into_iter() + .filter(|message| message.provider == ProviderKind::Feishu) + .filter(|message| { + !runtime + .snapshot() + .bindings + .iter() + .any(|binding| binding.session_ref() == message.session_ref()) + }) + .count(); + runtime.clear_unbound_feishu_sessions()?; + self.open_clawbot_management_popup(); + self.chat_widget.add_info_message( + format!( + "Cleared {sessions_before} unbound Feishu sessions and {unread_before} cached unread messages." + ), + /*hint*/ None, + ); + Ok(()) + } + + fn clawbot_management_popup_params( + &self, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let (snapshot, clearable_unread_count) = + ClawbotRuntime::load(self.config.cwd.to_path_buf()) + .map(|runtime| { + let snapshot = runtime.snapshot().clone(); + let clearable_unread_count = runtime + .store() + .load_unread_messages() + .unwrap_or_default() + .into_iter() + .filter(|message| message.provider == ProviderKind::Feishu) + .filter(|message| { + !snapshot + .bindings + .iter() + .any(|binding| binding.session_ref() == message.session_ref()) + }) + .count(); + (snapshot, clearable_unread_count) + }) + .unwrap_or_default(); + let feishu_config = snapshot.config.feishu.as_ref(); + let provider_state = snapshot + .runtime + .iter() + .find(|state| state.provider == ProviderKind::Feishu) + .cloned() + .unwrap_or(ProviderRuntimeState::unconfigured(ProviderKind::Feishu)); + let active_thread_id = self.active_thread_id.map(|thread_id| thread_id.to_string()); + let current_binding = active_thread_id.as_deref().and_then(|thread_id| { + snapshot + .bindings + .iter() + .find(|binding| binding.thread_id == thread_id) + }); + let turn_mode = snapshot.config.turn_mode; + let next_turn_mode = match turn_mode { + ClawbotTurnMode::Interactive => ClawbotTurnMode::NonInteractive, + ClawbotTurnMode::NonInteractive => ClawbotTurnMode::Interactive, + }; + let mut feishu_sessions = snapshot + .sessions + .iter() + .filter(|session| session.provider == ProviderKind::Feishu) + .cloned() + .collect::>(); + feishu_sessions.sort_by(|left, right| { + right + .bound_thread_id + .is_some() + .cmp(&left.bound_thread_id.is_some()) + .then(session_title(left).cmp(&session_title(right))) + .then(left.session_id.cmp(&right.session_id)) + }); + let bound_session_count = feishu_sessions + .iter() + .filter(|session| session.bound_thread_id.is_some()) + .count(); + let unbound_session_count = feishu_sessions.len().saturating_sub(bound_session_count); + let mut items = vec![ + SelectionItem { + name: "Turn Mode".to_string(), + description: Some(clawbot_turn_mode_summary(turn_mode)), + selected_description: Some(match turn_mode { + ClawbotTurnMode::Interactive => { + "Switch clawbot-originated turns into non-interactive mode so remote sessions do not block on prompts.".to_string() + } + ClawbotTurnMode::NonInteractive => { + "Restore normal interactive prompt handling for clawbot-originated turns.".to_string() + } + }), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotSetTurnMode { + mode: next_turn_mode, + }); + })], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Feishu Sessions".to_string(), + description: Some(format!( + "{} total · {} bound · {} unbound", + feishu_sessions.len(), + bound_session_count, + unbound_session_count + )), + selected_description: Some( + "Scan discovered Feishu chats, inspect bound/unbound state, and clear stale unbound cache." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }, + SelectionItem { + name: "Scan Feishu Sessions".to_string(), + description: Some( + "Discover Feishu chats and bot groups using the persisted workspace credentials." + .to_string(), + ), + selected_description: Some( + "Refresh the discovered session list before binding or cleanup." + .to_string(), + ), + is_disabled: !feishu_config.is_some_and(FeishuConfig::has_api_credentials), + actions: vec![Box::new(|tx| tx.send(AppEvent::ScanClawbotFeishuSessions))], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Clear Unbound Sessions".to_string(), + description: Some(format!( + "Remove {unbound_session_count} unbound sessions and {clearable_unread_count} cached unread messages." + )), + selected_description: Some( + "Keep active bindings intact while dropping stale discovered-session cache." + .to_string(), + ), + is_disabled: unbound_session_count == 0 && clearable_unread_count == 0, + actions: vec![Box::new(|tx| tx.send(AppEvent::ClearClawbotFeishuSessions))], + dismiss_on_select: false, + ..Default::default() + }, + ]; + + if feishu_sessions.is_empty() { + items.push(SelectionItem { + name: "No Feishu sessions discovered".to_string(), + description: Some( + "Run Scan Feishu Sessions to refresh the workspace-local discovered session list." + .to_string(), + ), + selected_description: Some( + "Discovered sessions appear here with their current bound or unbound state." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + items.extend(feishu_sessions.iter().map(|session| { + clawbot_session_item( + session, + active_thread_id.as_deref(), + current_binding.map(|binding| binding.session_id.as_str()), + ) + })); + } + + items.extend([ + clawbot_config_item(ClawbotFeishuConfigField::AppId, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::AppSecret, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::VerificationToken, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::EncryptKey, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::BotOpenId, feishu_config), + clawbot_config_item(ClawbotFeishuConfigField::BotUserId, feishu_config), + SelectionItem { + name: "Retry Feishu Connection".to_string(), + description: Some(connection_description(&provider_state)), + selected_description: Some( + "Restart the workspace-local Feishu websocket/runtime bridge.".to_string(), + ), + is_disabled: !feishu_config.is_some_and(FeishuConfig::has_api_credentials), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::RetryClawbotFeishuConnection) + })], + dismiss_on_select: false, + ..Default::default() + }, + ]); + + let bind_description = match (&active_thread_id, current_binding) { + (Some(thread_id), Some(binding)) => { + format!("Thread {thread_id} -> {}", binding.session_id) + } + (Some(thread_id), None) => format!("Thread {thread_id} is not bound"), + (None, _) => "No active thread".to_string(), + }; + items.push(SelectionItem { + name: "Bind Current Thread".to_string(), + description: Some(bind_description), + selected_description: Some( + "Manually bind the current Codex thread to a Feishu session id.".to_string(), + ), + is_disabled: active_thread_id.is_none(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenClawbotManualBindPrompt) + })], + dismiss_on_select: false, + ..Default::default() + }); + + for channel in [ + ClawbotForwardingChannel::Inbound, + ClawbotForwardingChannel::Outbound, + ] { + let enabled = current_binding.is_some_and(|binding| match channel { + ClawbotForwardingChannel::Inbound => binding.inbound_forwarding_enabled, + ClawbotForwardingChannel::Outbound => binding.outbound_forwarding_enabled, + }); + items.push(SelectionItem { + name: channel.title().to_string(), + description: Some(channel.description(enabled)), + selected_description: Some(channel.selected_description(enabled)), + is_disabled: current_binding.is_none(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ClawbotSetCurrentThreadForwarding { + channel, + enabled: !enabled, + }); + })], + dismiss_on_select: false, + ..Default::default() + }); + } + + items.push(SelectionItem { + name: "Disconnect Current Thread".to_string(), + description: Some( + current_binding + .map(|binding| format!("Unbind {}", binding.session_id)) + .unwrap_or_else(|| "No binding for current thread".to_string()), + ), + selected_description: Some( + "Remove the current thread's Feishu binding without deleting cached session state." + .to_string(), + ), + is_disabled: current_binding.is_none(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::ClawbotDisconnectCurrentThread) + })], + dismiss_on_select: false, + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(CLAWBOT_MANAGEMENT_VIEW_ID), + title: Some("Clawbot".to_string()), + subtitle: Some( + "Manage workspace-local Feishu credentials, sessions, and the current thread binding." + .to_string(), + ), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + } + } +} + +fn clawbot_config_item( + field: ClawbotFeishuConfigField, + config: Option<&FeishuConfig>, +) -> SelectionItem { + SelectionItem { + name: field.title().to_string(), + description: Some(field.description(config)), + selected_description: Some( + "Persist this workspace-local Feishu setting under .codex/clawbot/config.toml." + .to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenClawbotFeishuConfigPrompt { field }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn connection_description(state: &ProviderRuntimeState) -> String { + let status = match state.connection { + ConnectionStatus::Unconfigured => "Unconfigured", + ConnectionStatus::Disconnected => "Disconnected", + ConnectionStatus::Connecting => "Connecting", + ConnectionStatus::Connected => "Connected", + ConnectionStatus::Error => "Error", + }; + match state.last_error.as_deref() { + Some(error) if !error.trim().is_empty() => { + format!("{status}: {}", truncate_value(error, /*max_chars*/ 48)) + } + _ => status.to_string(), + } +} + +fn clawbot_session_item( + session: &ProviderSession, + active_thread_id: Option<&str>, + current_binding_session_id: Option<&str>, +) -> SelectionItem { + let is_current = current_binding_session_id.is_some_and(|session_id| { + session_id == session.session_id && session.provider == ProviderKind::Feishu + }); + let description = if session.bound_thread_id.is_some() { + format!("bound · {} unread", session.unread_count) + } else { + format!("unbound · {} unread", session.unread_count) + }; + let jump_target = session + .bound_thread_id + .as_deref() + .and_then(|thread_id| ThreadId::from_string(thread_id).ok()); + let selected_description = match (active_thread_id, session.bound_thread_id.as_deref()) { + (Some(thread_id), Some(bound_thread_id)) if thread_id == bound_thread_id => { + format!( + "Current thread {thread_id} is already bound to Feishu session {}.", + session.session_id + ) + } + (_, Some(bound_thread_id)) if jump_target.is_some() => { + format!( + "Jump to bound thread {bound_thread_id} to continue or manage Feishu session {}.", + session.session_id + ) + } + (_, Some(bound_thread_id)) => format!( + "Feishu session {} is bound to invalid thread id {bound_thread_id}.", + session.session_id + ), + (Some(thread_id), None) if is_current => { + format!( + "Thread {thread_id} is already bound to Feishu session {}.", + session.session_id + ) + } + (Some(thread_id), None) => format!( + "Bind current thread {thread_id} directly to Feishu session {}.", + session.session_id + ), + (None, None) => format!( + "Open a Codex thread before binding Feishu session {}.", + session.session_id + ), + }; + let session_id = session.session_id.clone(); + let actions: Vec = if let Some(thread_id) = jump_target { + vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::SelectAgentThread(thread_id)); + })] + } else { + vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::SaveClawbotManualBindSessionId { + session_id: session_id.clone(), + }); + })] + }; + let is_disabled = match session.bound_thread_id.as_deref() { + Some(bound_thread_id) => active_thread_id == Some(bound_thread_id) || jump_target.is_none(), + None => active_thread_id.is_none() || is_current, + }; + SelectionItem { + name: session_title(session), + description: Some(description), + selected_description: Some(selected_description), + is_disabled, + actions, + dismiss_on_select: false, + ..Default::default() + } +} + +fn session_title(session: &ProviderSession) -> String { + session + .display_name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| session.session_id.clone()) +} + +fn clawbot_turn_mode_label(mode: ClawbotTurnMode) -> &'static str { + match mode { + ClawbotTurnMode::Interactive => "interactive", + ClawbotTurnMode::NonInteractive => "non-interactive", + } +} + +fn clawbot_turn_mode_summary(mode: ClawbotTurnMode) -> String { + match mode { + ClawbotTurnMode::Interactive => { + "interactive: clawbot turns may surface question and approval prompts.".to_string() + } + ClawbotTurnMode::NonInteractive => { + "non-interactive: clawbot turns auto-dismiss question and permission prompts." + .to_string() + } + } +} + +fn mask_secret(value: &str) -> String { + let chars = value.chars().count(); + if chars <= 4 { + return "*".repeat(chars.max(1)); + } + let suffix: String = value + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{}{}", "*".repeat(chars.saturating_sub(4)), suffix) +} + +fn truncate_value(value: &str, max_chars: usize) -> String { + let chars = value.chars().collect::>(); + if chars.len() <= max_chars { + return value.to_string(); + } + let prefix = chars + .into_iter() + .take(max_chars.saturating_sub(1)) + .collect::(); + format!("{prefix}…") +} + +#[cfg(test)] +mod tests { + use codex_clawbot::ProviderKind; + use codex_clawbot::ProviderSession; + use codex_protocol::ThreadId; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + use super::clawbot_session_item; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::SelectionViewParams; + use crate::bottom_pane::list_selection_view::ListSelectionView; + use crate::render::renderable::Renderable; + + fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, width, height); + view.render(area, frame.buffer_mut()); + }) + .expect("draw popup"); + format!("{:?}", terminal.backend()) + } + + #[test] + fn bound_session_item_jumps_to_bound_thread() { + let item = clawbot_session_item( + &ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_bound".to_string(), + display_name: Some("tracker".to_string()), + unread_count: 2, + last_message_at: None, + status: codex_clawbot::SessionStatus::Bound, + bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()), + }, + Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"), + /*current_binding_session_id*/ None, + ); + + assert!(!item.is_disabled); + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + (item.actions[0])(&tx); + + assert!( + matches!( + rx.try_recv().expect("event"), + AppEvent::SelectAgentThread(thread_id) + if thread_id + == ThreadId::from_string("019d607a-cf72-72e1-a5b7-0dc17ad019ad") + .expect("thread id") + ), + "expected bound session item to jump to the bound thread" + ); + } + + #[test] + fn bound_session_jump_item_snapshot() { + let item = clawbot_session_item( + &ProviderSession { + provider: ProviderKind::Feishu, + session_id: "chat_bound".to_string(), + display_name: Some("tracker".to_string()), + unread_count: 2, + last_message_at: None, + status: codex_clawbot::SessionStatus::Bound, + bound_thread_id: Some("019d607a-cf72-72e1-a5b7-0dc17ad019ad".to_string()), + }, + Some("019d607a-cf72-72e1-a5b7-0dc17ad019ae"), + /*current_binding_session_id*/ None, + ); + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Clawbot".to_string()), + subtitle: Some("Session Jump".to_string()), + items: vec![item], + initial_selected_idx: Some(0), + ..Default::default() + }, + tx, + ); + + assert_snapshot!( + "bound_session_jump_item", + render_selection_popup(&view, /*width*/ 92, /*height*/ 14) + ); + } +} diff --git a/codex-rs/tui/src/app/jump_navigation.rs b/codex-rs/tui/src/app/jump_navigation.rs new file mode 100644 index 000000000..17e77d39e --- /dev/null +++ b/codex-rs/tui/src/app/jump_navigation.rs @@ -0,0 +1,154 @@ +use std::sync::Arc; + +use ratatui::text::Line; + +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::McpToolCallCell; +use crate::history_cell::ReasoningSummaryCell; +use crate::history_cell::UserHistoryCell; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum JumpTargetKind { + UserMessage, + AgentMessage, + Reasoning, + ToolCall, + Event, +} + +impl JumpTargetKind { + fn title(self) -> &'static str { + match self { + Self::UserMessage => "User Message", + Self::AgentMessage => "Assistant Message", + Self::Reasoning => "Reasoning", + Self::ToolCall => "Tool Call", + Self::Event => "Event", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct JumpTarget { + pub(crate) cell_index: usize, + pub(crate) ordinal: usize, + pub(crate) kind: JumpTargetKind, + pub(crate) title: String, + pub(crate) preview: String, +} + +impl JumpTarget { + fn new(cell_index: usize, ordinal: usize, kind: JumpTargetKind, preview: String) -> Self { + Self { + cell_index, + ordinal, + kind, + title: format!("{} {ordinal}", kind.title()), + preview, + } + } + + pub(crate) fn search_value(&self) -> String { + format!("{} {}", self.title, self.preview) + } +} + +pub(crate) fn build_jump_targets(cells: &[Arc]) -> Vec { + let mut targets = Vec::new(); + let mut ordinal = 1usize; + + for (cell_index, cell) in cells.iter().enumerate() { + let preview = preview_from_lines(cell.transcript_lines(u16::MAX)); + if preview.is_empty() { + continue; + } + + targets.push(JumpTarget::new( + cell_index, + ordinal, + classify_history_cell(cell.as_ref()), + preview, + )); + ordinal += 1; + } + + targets +} + +fn classify_history_cell(cell: &dyn HistoryCell) -> JumpTargetKind { + if cell.as_any().is::() { + JumpTargetKind::UserMessage + } else if cell.as_any().is::() { + JumpTargetKind::AgentMessage + } else if cell.as_any().is::() { + JumpTargetKind::Reasoning + } else if cell.as_any().is::() { + JumpTargetKind::ToolCall + } else { + JumpTargetKind::Event + } +} + +fn preview_from_lines(lines: Vec>) -> String { + lines + .into_iter() + .map(line_to_plain_text) + .map(|line| { + line.trim() + .trim_start_matches(['•', '-', '>', '›']) + .trim() + .to_string() + }) + .filter(|line| !line.is_empty()) + .take(2) + .collect::>() + .join(" ") +} + +fn line_to_plain_text(line: Line<'static>) -> String { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use ratatui::text::Line; + use std::sync::Arc; + + use super::JumpTargetKind; + use super::build_jump_targets; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::PlainHistoryCell; + use crate::history_cell::UserHistoryCell; + + #[test] + fn build_jump_targets_classifies_cells_and_skips_empty_entries() { + let cells = vec![ + Arc::new(UserHistoryCell { + message: "first question".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(PlainHistoryCell::new(vec![Line::from(" ")])), + Arc::new(AgentMessageCell::new( + vec![Line::from("first answer")], + /*is_first_line*/ true, + )), + ]; + + let targets = build_jump_targets(&cells); + + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].kind, JumpTargetKind::UserMessage); + assert_eq!(targets[0].title, "User Message 1"); + assert_eq!(targets[0].preview, "first question"); + assert_eq!(targets[1].kind, JumpTargetKind::AgentMessage); + assert_eq!(targets[1].title, "Assistant Message 2"); + assert_eq!(targets[1].preview, "first answer"); + } +} diff --git a/codex-rs/tui/src/app/key_chord.rs b/codex-rs/tui/src/app/key_chord.rs new file mode 100644 index 000000000..9704121df --- /dev/null +++ b/codex-rs/tui/src/app/key_chord.rs @@ -0,0 +1,165 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum KeyChordState { + #[default] + Idle, + AwaitingCtrlXSecondKey, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeyChordAction { + UndoLastUserMessage, + CopyLatestOutput, + RespawnCurrentSession, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum KeyChordResolution { + NoMatch, + AwaitingSecondKey, + Matched(KeyChordAction), + Cancelled, + Forward(KeyEvent), +} + +impl KeyChordState { + pub(crate) fn clear(&mut self) { + *self = Self::Idle; + } + + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyChordResolution { + match self { + Self::Idle => handle_idle_key_event(self, key_event), + Self::AwaitingCtrlXSecondKey => handle_ctrl_x_second_key(self, key_event), + } + } +} + +fn handle_idle_key_event(state: &mut KeyChordState, key_event: KeyEvent) -> KeyChordResolution { + if key_event.kind != KeyEventKind::Press { + return KeyChordResolution::NoMatch; + } + + if key_event.code == KeyCode::Char('x') && key_event.modifiers == KeyModifiers::CONTROL { + *state = KeyChordState::AwaitingCtrlXSecondKey; + KeyChordResolution::AwaitingSecondKey + } else { + KeyChordResolution::NoMatch + } +} + +fn handle_ctrl_x_second_key(state: &mut KeyChordState, key_event: KeyEvent) -> KeyChordResolution { + if key_event.kind != KeyEventKind::Press { + return KeyChordResolution::AwaitingSecondKey; + } + + let resolution = match (key_event.code, key_event.modifiers) { + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage) + } + (KeyCode::Char('y'), KeyModifiers::CONTROL) => { + KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput) + } + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + KeyChordResolution::Matched(KeyChordAction::RespawnCurrentSession) + } + (KeyCode::Char('x'), KeyModifiers::CONTROL) => KeyChordResolution::AwaitingSecondKey, + (KeyCode::Esc, _) => KeyChordResolution::Cancelled, + _ => KeyChordResolution::Forward(key_event), + }; + + if !matches!(resolution, KeyChordResolution::AwaitingSecondKey) { + *state = KeyChordState::Idle; + } + + resolution +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn ctrl_x_ctrl_u_matches_undo_last_user_message() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)), + KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_ctrl_y_matches_copy_latest_output() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)), + KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_ctrl_r_matches_respawn_current_session() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)), + KeyChordResolution::Matched(KeyChordAction::RespawnCurrentSession) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_unknown_second_key_is_forwarded_and_clears_state() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)), + KeyChordResolution::Forward(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_release_keeps_waiting_for_second_key() { + let mut state = KeyChordState::default(); + + assert_eq!( + state.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!( + state.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Char('x'), + KeyModifiers::CONTROL, + KeyEventKind::Release, + )), + KeyChordResolution::AwaitingSecondKey + ); + assert_eq!(state, KeyChordState::AwaitingCtrlXSecondKey); + } +} diff --git a/codex-rs/tui/src/app/profile_management.rs b/codex-rs/tui/src/app/profile_management.rs new file mode 100644 index 000000000..0fe63d813 --- /dev/null +++ b/codex-rs/tui/src/app/profile_management.rs @@ -0,0 +1,1058 @@ +use std::collections::HashSet; + +use toml::Value as TomlValue; + +use super::App; +use super::PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT; +use crate::app_event::AppEvent; +use crate::app_event::RuntimeProfileTarget; +use crate::app_server_session::AppServerSession; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::external_editor; +use crate::history_cell; +use crate::profile_router::DefaultProfileRouter; +use crate::profile_router::PROFILE_ROUTER_STATE_RELATIVE_PATH; +use crate::profile_router::ProfileFallbackAction; +use crate::profile_router::ProfileRouteEntry; +use crate::profile_router::ProfileRouterState; +use crate::profile_router::ProfileRouterStore; +use crate::tui; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::ServerNotification; +use codex_core::config::Config; +use codex_protocol::ThreadId; + +const PROFILE_MANAGEMENT_VIEW_ID: &str = "profile-management"; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct RoutedProfileSummary { + id: String, + provider_label: String, + model: Option, + base_url: Option, + route_position: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DefaultProfileSummary { + provider_label: String, + model: Option, + base_url: Option, +} + +impl App { + pub(super) fn profile_router_store(&self) -> ProfileRouterStore { + ProfileRouterStore::new(self.config.codex_home.clone()) + } + + pub(super) fn routed_profile_runtime_changed( + current_config: &Config, + next_config: &Config, + ) -> bool { + current_config.active_profile != next_config.active_profile + || current_config.model_provider_id != next_config.model_provider_id + || current_config.model_provider != next_config.model_provider + || current_config.chatgpt_base_url != next_config.chatgpt_base_url + } + + pub(crate) fn open_profile_management_panel(&mut self) { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let active_selected_idx = self + .chat_widget + .selected_index_for_active_view(PROFILE_MANAGEMENT_VIEW_ID); + let profiles = self.routed_profile_summaries(&router_state); + let params = profile_management_root_params( + self.active_profile.as_deref(), + &self.default_profile_summary(), + &profiles, + &router_state, + self.chat_widget.is_task_running(), + active_selected_idx, + ); + if active_selected_idx.is_some() { + let _ = self + .chat_widget + .replace_selection_view_if_active(PROFILE_MANAGEMENT_VIEW_ID, params); + } else { + self.chat_widget.show_selection_view(params); + } + } + + pub(crate) async fn edit_profile_fallback_config_from_ui(&mut self, tui: &mut tui::Tui) { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let profiles = self.routed_profile_summaries(&router_state); + if profiles.is_empty() { + match self + .profile_router_store() + .update(|state| *state = ProfileRouterState::default()) + { + Ok(_) => { + self.chat_widget.add_info_message( + "Cleared the fallback route because no named profiles are defined." + .to_string(), + /*hint*/ None, + ); + self.open_profile_management_panel(); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to update {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + } + } + return; + } + + let seed = fallback_route_editor_seed(&profiles, &router_state); + let Ok(editor_cmd) = self.resolve_editor_command_for_profiles() else { + return; + }; + let edited = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::run_editor_with_suffix(&seed, &editor_cmd, ".txt").await + }) + .await; + + match edited { + Ok(contents) => { + let current_profile_ids = profiles + .iter() + .map(|profile| profile.id.clone()) + .collect::>(); + match parse_fallback_route_editor_contents(&contents, ¤t_profile_ids) { + Ok(ordered_profile_ids) => { + let next_state = rewritten_router_state(&router_state, ordered_profile_ids); + match self + .profile_router_store() + .update(|state| *state = next_state.clone()) + { + Ok(_) => { + self.chat_widget.add_info_message( + format!( + "Updated fallback route in {PROFILE_ROUTER_STATE_RELATIVE_PATH}." + ), + /*hint*/ None, + ); + self.open_profile_management_panel(); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to update {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + } + } + } + Err(err) => { + self.chat_widget.add_error_message(err); + } + } + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } + } + tui.frame_requester().schedule_frame(); + } + + fn default_profile_summary(&self) -> DefaultProfileSummary { + let effective_config = self.config.config_layer_stack.effective_config(); + let table = effective_config.as_table(); + let root_provider_id = table + .and_then(|table| table.get("model_provider")) + .and_then(TomlValue::as_str) + .unwrap_or(self.config.model_provider_id.as_str()); + let root_model = table + .and_then(|table| table.get("model")) + .and_then(TomlValue::as_str) + .map(ToOwned::to_owned); + let (provider_label, base_url) = self.provider_label_and_base_url(root_provider_id); + + DefaultProfileSummary { + provider_label, + model: root_model, + base_url, + } + } + + fn routed_profile_summaries( + &self, + router_state: &ProfileRouterState, + ) -> Vec { + let effective_config = self.config.config_layer_stack.effective_config(); + let Some(table) = effective_config.as_table() else { + return Vec::new(); + }; + let root_model = table + .get("model") + .and_then(TomlValue::as_str) + .map(ToOwned::to_owned); + let root_provider_id = table + .get("model_provider") + .and_then(TomlValue::as_str) + .unwrap_or(self.config.model_provider_id.as_str()); + let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else { + return Vec::new(); + }; + + let mut profile_ids = profiles.keys().cloned().collect::>(); + profile_ids.sort(); + + profile_ids + .into_iter() + .map(|id| { + let profile = profiles.get(&id).and_then(TomlValue::as_table); + let provider_id = profile + .and_then(|profile| profile.get("model_provider")) + .and_then(TomlValue::as_str) + .unwrap_or(root_provider_id); + let (provider_label, base_url) = self.provider_label_and_base_url(provider_id); + RoutedProfileSummary { + route_position: router_state + .routes + .iter() + .position(|route| route.profile_id == id) + .map(|index| index + 1), + model: profile + .and_then(|profile| profile.get("model")) + .and_then(TomlValue::as_str) + .map(ToOwned::to_owned) + .or_else(|| root_model.clone()), + id, + provider_label, + base_url, + } + }) + .collect() + } + + fn resolve_editor_command_for_profiles(&mut self) -> Result>, ()> { + match external_editor::resolve_editor_commands() { + Ok(cmds) => Ok(cmds), + Err(external_editor::EditorError::MissingEditor) => { + self.chat_widget + .add_to_history(history_cell::new_error_event( + "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`." + .to_string(), + )); + Err(()) + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + Err(()) + } + } + } + + fn provider_label_and_base_url(&self, provider_id: &str) -> (String, Option) { + if provider_id == self.config.model_provider_id { + ( + self.config.model_provider.name.clone(), + self.config.model_provider.base_url.clone(), + ) + } else if let Some(provider) = self.config.model_providers.get(provider_id) { + (provider.name.clone(), provider.base_url.clone()) + } else { + (provider_id.to_string(), None) + } + } + + pub(super) async fn close_active_thread_for_profile_reload( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + ) -> std::result::Result<(), String> { + self.backtrack.pending_rollback = None; + app_server + .thread_unsubscribe(thread_id) + .await + .map_err(|err| { + format!("Failed to unload current session before switching profiles: {err}") + })?; + + let close_wait = async { + loop { + match app_server.next_event().await { + Some(AppServerEvent::ServerNotification(ServerNotification::ThreadClosed( + notification, + ))) if notification.thread_id == thread_id.to_string() => { + break Ok(()); + } + Some(AppServerEvent::Disconnected { message }) => { + self.chat_widget.add_error_message(message.clone()); + self.app_event_tx + .send(AppEvent::FatalExitRequest(message.clone())); + break Err(format!( + "App-server disconnected while closing current session: {message}" + )); + } + Some(event) => { + self.handle_app_server_event(app_server, event).await; + } + None => { + break Err( + "App-server event stream closed while closing current session." + .to_string(), + ); + } + } + } + }; + + tokio::time::timeout(PROFILE_SWITCH_THREAD_CLOSE_TIMEOUT, close_wait) + .await + .map_err(|_| { + format!( + "Timed out waiting for current session `{thread_id}` to close before switching profiles." + ) + })??; + + self.clear_active_thread().await; + self.abort_thread_event_listener(thread_id); + Ok(()) + } + + pub(super) async fn switch_runtime_profile( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + profile_id: Option<&str>, + ) -> std::result::Result<(), String> { + if self.active_profile.as_deref() == profile_id { + return Ok(()); + } + + let previous_override = self.harness_overrides.config_profile.clone(); + let previous_active_profile = self.active_profile.clone(); + self.harness_overrides.config_profile = profile_id.map(ToOwned::to_owned); + + let current_cwd = self.chat_widget.config_ref().cwd.to_path_buf(); + let mut next_config = match self.rebuild_config_for_cwd(current_cwd).await { + Ok(config) => config, + Err(err) => { + self.harness_overrides.config_profile = previous_override; + self.active_profile = previous_active_profile; + return Err(err.to_string()); + } + }; + self.apply_runtime_policy_overrides(&mut next_config); + + if let Err(err) = self + .apply_runtime_config_change( + tui, + app_server, + next_config, + /*reload_live_thread*/ true, + ) + .await + { + self.harness_overrides.config_profile = previous_override; + self.active_profile = previous_active_profile; + return Err(err); + } + Ok(()) + } + + pub(super) async fn retry_last_user_turn_with_profile_fallback( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + action: ProfileFallbackAction, + error_message: String, + ) { + if !self.chat_widget.has_retryable_user_turn() { + self.chat_widget.add_error_message(error_message); + return; + } + + match action { + ProfileFallbackAction::RetrySameProfileFirst => { + let profile_label = self + .chat_widget + .active_profile_label() + .unwrap_or_else(|| "current".to_string()); + if self.chat_widget.profile_retry_attempted() + || !self + .chat_widget + .retry_last_user_turn_for_profile_fallback(format!( + "Retrying the last turn on profile `{profile_label}`." + )) + { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let Some(next_profile_id) = DefaultProfileRouter + .fallback_profile(&router_state, self.active_profile.as_deref()) + else { + self.chat_widget.add_error_message(error_message); + return; + }; + self.chat_widget.finish_failed_turn_for_profile_fallback(); + if let Err(err) = self + .switch_runtime_profile(tui, app_server, Some(&next_profile_id)) + .await + { + self.chat_widget.add_error_message(format!( + "Failed to switch to fallback profile `{next_profile_id}`: {err}" + )); + return; + } + if let Err(err) = self.profile_router_store().update(|state| { + state.set_active_profile(&next_profile_id); + }) { + self.chat_widget.add_error_message(format!( + "Failed to persist fallback profile `{next_profile_id}` in {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + return; + } + self.chat_widget.submit_profile_fallback_retry(format!( + "Retrying the last turn with profile `{next_profile_id}`." + )); + } + } + ProfileFallbackAction::SwitchProfileImmediately => { + let router_state = self.profile_router_store().load().unwrap_or_default(); + let Some(next_profile_id) = DefaultProfileRouter + .fallback_profile(&router_state, self.active_profile.as_deref()) + else { + self.chat_widget.add_error_message(error_message); + return; + }; + self.chat_widget.finish_failed_turn_for_profile_fallback(); + if let Err(err) = self + .switch_runtime_profile(tui, app_server, Some(&next_profile_id)) + .await + { + self.chat_widget.add_error_message(format!( + "Failed to switch to fallback profile `{next_profile_id}`: {err}" + )); + return; + } + if let Err(err) = self.profile_router_store().update(|state| { + state.set_active_profile(&next_profile_id); + }) { + self.chat_widget.add_error_message(format!( + "Failed to persist fallback profile `{next_profile_id}` in {PROFILE_ROUTER_STATE_RELATIVE_PATH}: {err}" + )); + return; + } + self.chat_widget.submit_profile_fallback_retry(format!( + "Retrying the last turn with profile `{next_profile_id}`." + )); + } + } + } +} + +fn profile_management_root_params( + active_profile: Option<&str>, + default_profile: &DefaultProfileSummary, + profiles: &[RoutedProfileSummary], + router_state: &ProfileRouterState, + task_running: bool, + initial_selected_idx: Option, +) -> SelectionViewParams { + let mut items = vec![ + profile_selection_item( + "Default Config".to_string(), + default_profile_description(default_profile), + active_profile.is_none(), + task_running, + RuntimeProfileTarget::Default, + ), + SelectionItem { + name: "Fallback Config".to_string(), + description: Some(root_fallback_summary(router_state)), + selected_description: Some( + "Open your external editor and reorder all named profiles. Saving rewrites the fallback route file from scratch." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::EditProfileFallbackConfig); + })], + dismiss_on_select: false, + search_value: Some("fallback config route reorder edit".to_string()), + ..Default::default() + }, + ]; + + if profiles.is_empty() { + items.push(SelectionItem { + name: "No named profiles".to_string(), + description: Some( + "Add `[profiles.]` entries in config.toml to route API traffic through alternate endpoints." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + items.extend(profiles.iter().cloned().map(|profile| { + profile_selection_item( + profile.id.clone(), + routed_profile_description(&profile), + active_profile == Some(profile.id.as_str()), + task_running, + RuntimeProfileTarget::Named(profile.id), + ) + })); + } + + SelectionViewParams { + view_id: Some(PROFILE_MANAGEMENT_VIEW_ID), + title: Some("Profiles".to_string()), + subtitle: Some(format!( + "Current runtime: {} · {} named profile(s).", + active_profile.unwrap_or("default"), + profiles.len(), + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search profiles".to_string()), + initial_selected_idx, + ..Default::default() + } +} + +fn profile_selection_item( + name: String, + description: String, + is_current: bool, + task_running: bool, + target: RuntimeProfileTarget, +) -> SelectionItem { + let (is_disabled, disabled_reason) = if is_current { + (true, Some("Already active.".to_string())) + } else if task_running { + ( + true, + Some("Wait for the current task to finish before switching profiles.".to_string()), + ) + } else { + (false, None) + }; + + SelectionItem { + name: name.clone(), + description: Some(description.clone()), + selected_description: Some( + "Reload the current session with this profile while preserving input continuity." + .to_string(), + ), + is_current, + is_disabled, + disabled_reason, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SwitchRuntimeProfile { + target: target.clone(), + }); + })], + dismiss_on_select: true, + search_value: Some(format!("{name} {description}")), + ..Default::default() + } +} + +fn default_profile_description(profile: &DefaultProfileSummary) -> String { + let mut parts = vec![format!("provider: {}", profile.provider_label)]; + if let Some(base_url) = &profile.base_url { + parts.push(base_url.clone()); + } + if let Some(model) = &profile.model { + parts.push(format!("model: {model}")); + } + parts.push("root config".to_string()); + parts.join(" · ") +} + +fn routed_profile_description(profile: &RoutedProfileSummary) -> String { + let mut parts = vec![profile_endpoint_description(profile)]; + parts.push( + profile + .route_position + .map(|position| format!("fallback #{position}")) + .unwrap_or_else(|| "not in fallback route".to_string()), + ); + parts.join(" · ") +} + +fn profile_endpoint_description(profile: &RoutedProfileSummary) -> String { + let mut parts = vec![format!("provider: {}", profile.provider_label)]; + if let Some(base_url) = &profile.base_url { + parts.push(base_url.clone()); + } + if let Some(model) = &profile.model { + parts.push(format!("model: {model}")); + } + parts.join(" · ") +} + +fn root_fallback_summary(router_state: &ProfileRouterState) -> String { + if router_state.routes.is_empty() { + "No profiles in the fallback route.".to_string() + } else { + format!( + "{} profile(s) in route · active fallback: {}", + router_state.routes.len(), + router_state.active_profile_id.as_deref().unwrap_or("none") + ) + } +} + +fn fallback_route_editor_seed( + profiles: &[RoutedProfileSummary], + router_state: &ProfileRouterState, +) -> String { + let mut ordered_ids = Vec::with_capacity(profiles.len()); + let current_profile_ids = profiles + .iter() + .map(|profile| profile.id.as_str()) + .collect::>(); + + for route in &router_state.routes { + let profile_id = route.profile_id.as_str(); + if current_profile_ids.contains(profile_id) + && !ordered_ids.iter().any(|id| id == profile_id) + { + ordered_ids.push(profile_id.to_string()); + } + } + for profile in profiles { + if !ordered_ids.iter().any(|id| id == &profile.id) { + ordered_ids.push(profile.id.clone()); + } + } + + let mut seed = [ + "# Reorder fallback profiles, one id per line.", + "# Omitted profiles are allowed.", + "# Keep only profiles currently defined in config.toml, at most once each.", + "# Blank lines and lines starting with # are ignored.", + ] + .join("\n"); + seed.push_str("\n\n"); + seed.push_str(&ordered_ids.join("\n")); + seed.push('\n'); + seed +} + +fn parse_fallback_route_editor_contents( + contents: &str, + current_profile_ids: &[String], +) -> Result, String> { + let expected_ids = current_profile_ids + .iter() + .map(std::string::String::as_str) + .collect::>(); + let mut seen_ids = HashSet::with_capacity(current_profile_ids.len()); + let mut ordered_ids = Vec::with_capacity(current_profile_ids.len()); + + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if !expected_ids.contains(line) { + return Err(format!( + "Unknown profile `{line}` in fallback config. Keep only profiles currently defined in config.toml." + )); + } + if !seen_ids.insert(line.to_string()) { + return Err(format!( + "Duplicate profile `{line}` in fallback config. Each profile must appear exactly once." + )); + } + ordered_ids.push(line.to_string()); + } + + Ok(ordered_ids) +} + +fn rewritten_router_state( + previous_state: &ProfileRouterState, + ordered_profile_ids: Vec, +) -> ProfileRouterState { + let active_profile_id = previous_state + .active_profile_id + .as_ref() + .filter(|profile_id| ordered_profile_ids.iter().any(|id| id == *profile_id)) + .cloned(); + + ProfileRouterState { + active_profile_id, + routes: ordered_profile_ids + .into_iter() + .map(|profile_id| ProfileRouteEntry { profile_id }) + .collect(), + ..ProfileRouterState::default() + } +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + use super::DefaultProfileSummary; + use super::RoutedProfileSummary; + use super::fallback_route_editor_seed; + use super::parse_fallback_route_editor_contents; + use super::profile_management_root_params; + use super::rewritten_router_state; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::profile_router::ProfileRouteEntry; + use crate::profile_router::ProfileRouterState; + use crate::render::renderable::Renderable; + + fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, width, height); + view.render(area, frame.buffer_mut()); + }) + .expect("draw popup"); + format!("{:?}", terminal.backend()) + } + + fn test_profiles() -> Vec { + vec![ + RoutedProfileSummary { + id: "primary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.primary.example/v1".to_string()), + route_position: Some(1), + }, + RoutedProfileSummary { + id: "secondary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.secondary.example/v1".to_string()), + route_position: Some(2), + }, + RoutedProfileSummary { + id: "tertiary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.tertiary.example/v1".to_string()), + route_position: None, + }, + ] + } + + fn test_router_state() -> ProfileRouterState { + ProfileRouterState { + version: 1, + active_profile_id: Some("primary".to_string()), + routes: vec![ + ProfileRouteEntry { + profile_id: "primary".to_string(), + }, + ProfileRouteEntry { + profile_id: "secondary".to_string(), + }, + ], + } + } + + #[test] + fn profile_management_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + profile_management_root_params( + Some("primary"), + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.openai.com/v1".to_string()), + }, + &test_profiles(), + &test_router_state(), + /*task_running*/ false, + /*initial_selected_idx*/ None, + ), + tx, + ); + + assert_snapshot!( + "profile_management_popup", + render_selection_popup(&view, /*width*/ 96, /*height*/ 22) + ); + } + + #[test] + fn fallback_route_editor_seed_uses_current_profiles_only() { + let seed = fallback_route_editor_seed(&test_profiles(), &test_router_state()); + + assert_eq!( + seed, + concat!( + "# Reorder fallback profiles, one id per line.\n", + "# Omitted profiles are allowed.\n", + "# Keep only profiles currently defined in config.toml, at most once each.\n", + "# Blank lines and lines starting with # are ignored.\n", + "\n", + "primary\n", + "secondary\n", + "tertiary\n" + ) + ); + } + + #[test] + fn fallback_route_parser_accepts_partial_unique_profile_list() { + let current_profile_ids = vec![ + "primary".to_string(), + "secondary".to_string(), + "tertiary".to_string(), + ]; + + let ordered = parse_fallback_route_editor_contents( + "# comment\nsecondary\nprimary\ntertiary\n", + ¤t_profile_ids, + ) + .expect("valid fallback route"); + assert_eq!( + ordered, + vec![ + "secondary".to_string(), + "primary".to_string(), + "tertiary".to_string() + ] + ); + + let partial = + parse_fallback_route_editor_contents("secondary\nprimary\n", ¤t_profile_ids) + .expect("omitted profiles should be allowed"); + assert_eq!( + partial, + vec!["secondary".to_string(), "primary".to_string()] + ); + + let duplicate = parse_fallback_route_editor_contents( + "secondary\nprimary\nprimary\ntertiary\n", + ¤t_profile_ids, + ) + .expect_err("duplicate profile should fail"); + assert_eq!( + duplicate, + "Duplicate profile `primary` in fallback config. Each profile must appear exactly once." + ); + + let unknown = parse_fallback_route_editor_contents( + "secondary\nunknown\nprimary\ntertiary\n", + ¤t_profile_ids, + ) + .expect_err("unknown profile should fail"); + assert_eq!( + unknown, + "Unknown profile `unknown` in fallback config. Keep only profiles currently defined in config.toml." + ); + } + + #[test] + fn rewritten_router_state_drops_stale_entries_and_preserves_active_profile() { + let state = rewritten_router_state( + &ProfileRouterState { + version: 1, + active_profile_id: Some("secondary".to_string()), + routes: vec![ + ProfileRouteEntry { + profile_id: "stale".to_string(), + }, + ProfileRouteEntry { + profile_id: "primary".to_string(), + }, + ], + }, + vec![ + "tertiary".to_string(), + "secondary".to_string(), + "primary".to_string(), + ], + ); + + assert_eq!( + state, + ProfileRouterState { + version: 1, + active_profile_id: Some("secondary".to_string()), + routes: vec![ + ProfileRouteEntry { + profile_id: "tertiary".to_string(), + }, + ProfileRouteEntry { + profile_id: "secondary".to_string(), + }, + ProfileRouteEntry { + profile_id: "primary".to_string(), + }, + ], + } + ); + } + + #[test] + fn rewritten_router_state_clears_missing_active_profile() { + let state = rewritten_router_state( + &ProfileRouterState { + version: 1, + active_profile_id: Some("stale".to_string()), + routes: Vec::new(), + }, + vec!["primary".to_string()], + ); + + assert_eq!( + state, + ProfileRouterState { + version: 1, + active_profile_id: None, + routes: vec![ProfileRouteEntry { + profile_id: "primary".to_string(), + }], + } + ); + } + + #[test] + fn profile_management_popup_fallback_item_opens_editor_flow() { + let params = profile_management_root_params( + Some("primary"), + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.openai.com/v1".to_string()), + }, + &test_profiles(), + &test_router_state(), + /*task_running*/ false, + /*initial_selected_idx*/ None, + ); + + assert_eq!(params.items[1].name, "Fallback Config"); + assert_eq!( + params.items[1].selected_description.as_deref(), + Some( + "Open your external editor and reorder all named profiles. Saving rewrites the fallback route file from scratch." + ) + ); + assert_eq!(params.items[1].dismiss_on_select, false); + let (tx, mut rx) = unbounded_channel::(); + (params.items[1].actions[0])(&AppEventSender::new(tx)); + assert!(matches!( + rx.try_recv().ok(), + Some(AppEvent::EditProfileFallbackConfig) + )); + } + + #[test] + fn profile_management_popup_shows_no_named_profiles() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + profile_management_root_params( + None, + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: Some("https://api.openai.com/v1".to_string()), + }, + &[], + &ProfileRouterState::default(), + /*task_running*/ false, + /*initial_selected_idx*/ None, + ), + tx, + ); + + assert_snapshot!( + "profile_management_popup_no_named_profiles", + render_selection_popup(&view, /*width*/ 96, /*height*/ 20) + ); + } + + #[test] + fn profile_management_popup_fallback_item_stays_enabled_while_task_running() { + let params = profile_management_root_params( + Some("primary"), + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: None, + }, + &[RoutedProfileSummary { + id: "primary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: None, + route_position: Some(1), + }], + &ProfileRouterState { + version: 1, + active_profile_id: Some("primary".to_string()), + routes: vec![ProfileRouteEntry { + profile_id: "primary".to_string(), + }], + }, + /*task_running*/ true, + /*initial_selected_idx*/ None, + ); + + assert_eq!(params.items[1].is_disabled, false); + } + + #[test] + fn profile_management_panel_disables_switches_while_task_running() { + let params = profile_management_root_params( + Some("primary"), + &DefaultProfileSummary { + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: None, + }, + &[RoutedProfileSummary { + id: "primary".to_string(), + provider_label: "OpenAI".to_string(), + model: Some("gpt-5".to_string()), + base_url: None, + route_position: Some(1), + }], + &ProfileRouterState { + version: 1, + active_profile_id: Some("primary".to_string()), + routes: vec![ProfileRouteEntry { + profile_id: "primary".to_string(), + }], + }, + /*task_running*/ true, + /*initial_selected_idx*/ None, + ); + + assert_eq!(params.items[0].is_disabled, true); + assert_eq!(params.items[1].is_disabled, false); + assert_eq!(params.items[2].is_disabled, true); + assert_eq!( + params.items[0].disabled_reason.as_deref(), + Some("Wait for the current task to finish before switching profiles.") + ); + assert_eq!( + params.items[2].disabled_reason.as_deref(), + Some("Already active.") + ); + } +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_failure_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_failure_popup.snap new file mode 100644 index 000000000..fad24b9dc --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_failure_popup.snap @@ -0,0 +1,43 @@ +--- +source: tui/src/app/btw.rs +expression: "render_selection_popup(&view, 92, 20)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 20 }, + content: [ + " ", + " Temporary BTW failed `/btw` failed: upstream unavailable ", + " The hidden temporary discussion did not com ", + " ", + "› 1. Close Dismiss this temporary ", + " discussion. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 34, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 23, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_loading_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_loading_popup.snap new file mode 100644 index 000000000..1ddee4b07 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_loading_popup.snap @@ -0,0 +1,43 @@ +--- +source: tui/src/app/btw.rs +expression: "render_selection_popup(&view, 92, 20)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 20 }, + content: [ + " ", + " Temporary BTW discussion Codex is answering in a hidden ephemeral ", + " Running a hidden temporary discussion threa thread. Nothing will be written back to the ", + " main composer unless you explicitly choose ", + "› 1. Discard Cancel and destroy the an insert action. ", + " temporary discussion. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 26, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 36, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 35, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_result_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_result_popup.snap new file mode 100644 index 000000000..6261de893 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_result_popup.snap @@ -0,0 +1,61 @@ +--- +source: tui/src/app/btw.rs +expression: "render_selection_popup(&view, 92, 28)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 28 }, + content: [ + " ", + " Temporary BTW answer Use a hidden thread to brainstorm ", + " Choose what to do with the temporary answer tradeoffs, then choose whether to insert ", + " the summary or the full answer back into ", + "› 1. Insert Summary Insert a short summary the main composer. ", + " into the main composer. ", + " 2. Insert Full Insert the full answer ", + " into the main composer. ", + " 3. Discard Destroy the temporary ", + " discussion and keep the ", + " main composer untouched. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 43, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 44, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 43, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 44, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 42, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 44, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 27, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__clawbot_controls__tests__bound_session_jump_item.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__clawbot_controls__tests__bound_session_jump_item.snap new file mode 100644 index 000000000..8d849783c --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__clawbot_controls__tests__bound_session_jump_item.snap @@ -0,0 +1,36 @@ +--- +source: tui/src/app/clawbot_controls.rs +expression: "render_selection_popup(&view, 92, 14)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 14 }, + content: [ + " ", + " Clawbot ", + " Session Jump ", + " ", + "› 1. tracker Jump to bound thread 019d607a-cf72-72e1-a5b7-0dc17ad019ad to continue or ", + " manage Feishu session chat_bound. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 9, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 86, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 47, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap new file mode 100644 index 000000000..f2268b215 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup.snap @@ -0,0 +1,61 @@ +--- +source: tui/src/app/profile_management.rs +expression: "render_selection_popup(&view, 96, 22)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 96, height: 22 }, + content: [ + " ", + " Profiles ", + " Current runtime: primary · 3 named profile(s). ", + " ", + " Type to search profiles ", + "› Default Config Reload the current session with this profile while preserving input ", + " continuity. ", + " Fallback Config 2 profile(s) in route · active fallback: primary ", + " primary (current) provider: OpenAI · https://api.primary.example/v1 · model: gpt-5 · ", + " fallback #1 ", + " secondary provider: OpenAI · https://api.secondary.example/v1 · model: gpt-5 · ", + " fallback #2 ", + " tertiary provider: OpenAI · https://api.tertiary.example/v1 · model: gpt-5 · not ", + " in fallback route ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 10, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 48, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 25, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 88, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 32, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 69, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 87, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 32, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 89, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 32, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 92, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 21, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 21, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 96, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup_no_named_profiles.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup_no_named_profiles.snap new file mode 100644 index 000000000..f74973aaa --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__profile_management__tests__profile_management_popup_no_named_profiles.snap @@ -0,0 +1,51 @@ +--- +source: tui/src/app/profile_management.rs +expression: "render_selection_popup(&view, 96, 20)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 96, height: 20 }, + content: [ + " ", + " Profiles ", + " Current runtime: default · 0 named profile(s). ", + " ", + " Type to search profiles ", + "› Default Config (current) Reload the current session with this profile while preserving ", + " input continuity. ", + " Fallback Config No profiles in the fallback route. ", + " No named profiles Add `[profiles.]` entries in config.toml to route API ", + " traffic through alternate endpoints. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 10, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 48, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 25, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 89, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 28, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 62, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 87, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 64, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 96, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_empty_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_empty_popup.snap new file mode 100644 index 000000000..a1eb1b655 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_empty_popup.snap @@ -0,0 +1,43 @@ +--- +source: tui/src/app/thread_menu.rs +expression: "render_selection_popup(&view, 92, 18)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 18 }, + content: [ + " ", + " Jump To Message ", + " No committed transcript entries are available yet. ", + " ", + " Type to search committed transcript ", + "› Nothing to jump to yet Send a message or wait for a response before using Jump To ", + " Message. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 52, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 37, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 84, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 17, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_popup.snap new file mode 100644 index 000000000..4837987a9 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__jump_to_message_popup.snap @@ -0,0 +1,47 @@ +--- +source: tui/src/app/thread_menu.rs +expression: "render_selection_popup(&view, 92, 22)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 22 }, + content: [ + " ", + " Jump To Message ", + " 2 committed transcript entries are available. ", + " ", + " Type to search committed transcript ", + "› User Message 1 How do I keep the retry chain intact? ", + " Assistant Message 2 Classify 503 as fallback-eligible before surfacing the final error. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 37, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 60, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 23, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 90, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 21, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__thread_panel_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__thread_panel_popup.snap new file mode 100644 index 000000000..b464ab7fc --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_menu__tests__thread_panel_popup.snap @@ -0,0 +1,49 @@ +--- +source: tui/src/app/thread_menu.rs +expression: "render_selection_popup(&view, 92, 20)" +--- +TestBackend { buffer: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 20 }, + content: [ + " ", + " Thread ", + " Fork, rewind, or jump within the current conversation. ", + " ", + "› 1. Fork Current Session Fork the current thread into a new session and continue ", + " there. ", + " 2. Jump To Message Search committed transcript entries and open the transcript ", + " overlay. ", + " 3. Undo Last User Message Restore the last sent input and roll back one turn. ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " Press enter to confirm or esc to go back ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 56, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 84, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 35, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 88, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 37, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 80, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 19, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +}, scrollback: Buffer { + area: Rect { x: 0, y: 0, width: 92, height: 0 } +}, cursor: false, pos: (0, 0) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap new file mode 100644 index 000000000..32c5de55c --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_file_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow + director · manual.yaml + + Type to search workflow actions +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + Jobs 2 jobs + Triggers 3 triggers + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap new file mode 100644 index 000000000..246786dfe --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_job_popup.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow Job + director · notify · manual.yaml + + Type to search job actions +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + Run Now Run this job immediately in a background workflow thread. + Disable Job Prevent triggers from including this job until it is enabled again. + Context: Ephemeral Toggle between embed and ephemeral execution context. + Response: User Toggle whether this job replies as assistant output or a user follow-up. + Edit Needs 1 dependency + Edit Steps 1 step + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_jobs_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_jobs_popup.snap new file mode 100644 index 000000000..d990b86b3 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_jobs_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow Jobs + director · manual.yaml + + Type to search jobs +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + summarize Enabled · Embed · Assistant · Ready + notify Enabled · Ephemeral · User · Ready + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap new file mode 100644 index 000000000..ada45a6fd --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_trigger_popup.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow Trigger + director · review_backlog · manual.yaml + + Type to search trigger actions +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + Run Now Run this trigger immediately in a background workflow thread. + Disable Trigger Prevent this trigger from starting until it is enabled again. + Type: Manual Choose which trigger type this workflow entry should use. + Edit Trigger ID Rename this trigger id. + Edit Target Jobs 1 job + No Trigger Parameter This trigger type does not require an extra schedule parameter. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap new file mode 100644 index 000000000..dedbc0372 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_manual_triggers_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow Triggers + director · manual.yaml + + Type to search triggers +› Back Return to the previous workflow menu. + Edit workflow.yaml Open manual.yaml in your editor. + review_backlog Manual · Enabled · Ready + triage Manual · Disabled · Ready + pulse Interval (30m) · Enabled · Ready + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup.snap new file mode 100644 index 000000000..9821e62e9 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow + Manage workflow files, jobs, and triggers directly. + + Type to search workflows +› Background Tasks Insert a background task snapshot into the transcript. /ps + shows the same live workflow state. + director - edit yaml Open manual.yaml in your editor. + director - job - summarize Workflow job + director - job - notify Workflow job + director - trigger - review_backlog Manual · Enabled + director - trigger - triage Manual · Disabled + director - trigger - pulse Interval (30m) · Enabled + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap new file mode 100644 index 000000000..7773603d2 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_root_popup_empty.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Workflow + Manage workflow files, jobs, and triggers directly. + + Type to search workflows +› Background Tasks Insert a background task snapshot into the transcript. /ps shows the same + live workflow state. + Create workflow.yaml Create a starter template under .codex/workflows and open it in your + editor. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap new file mode 100644 index 000000000..881066694 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__workflow_controls__tests__workflow_trigger_type_popup.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app/workflow_controls.rs +expression: popup +--- + Trigger Type + director · pulse · manual.yaml + + Type to search trigger types +› Back Return to the previous workflow menu. + Manual Run only when triggered from the workflow menu. + Before Turn Run automatically before the next user turn. + After Turn Run automatically after the current turn finishes. + File Watch Run automatically when workspace files change. Overlapping runs are skipped. + Idle Run after the workspace has been idle for a duration. + Interval Current type + Cron Run on a cron schedule. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/btw_tests.rs b/codex-rs/tui/src/app/tests/btw_tests.rs new file mode 100644 index 000000000..e7fa582ce --- /dev/null +++ b/codex-rs/tui/src/app/tests/btw_tests.rs @@ -0,0 +1,86 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn btw_completion_notification_emits_completion_event_and_is_swallowed() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + last_status: None, + }); + + let swallowed = app.handle_btw_notification( + thread_id, + &turn_completed_notification_with_agent_message( + thread_id, + "turn-btw", + TurnStatus::Completed, + "Temporary answer", + ), + ); + + assert!(swallowed); + match app_event_rx.try_recv() { + Ok(AppEvent::BtwCompleted { + thread_id: actual_thread_id, + result: Ok(message), + }) => { + assert_eq!(actual_thread_id, thread_id); + assert_eq!(message, "Temporary answer"); + } + other => panic!("expected BtwCompleted event, got {other:?}"), + } +} + +#[tokio::test] +async fn btw_loading_popup_surfaces_hidden_hook_status() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + last_status: None, + }); + + let swallowed = + app.handle_btw_notification(thread_id, &hook_started_notification(thread_id, "turn-btw")); + + assert!(swallowed); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + popup.contains("Current hidden status:") + && popup.contains("Running UserPromptSubmit hook: checking") + && popup.contains("go-workflow input policy"), + "expected hidden hook status in /btw popup: {popup}" + ); +} + +#[tokio::test] +async fn btw_request_user_input_opens_failure_popup_instead_of_hanging() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.btw_session = Some(BtwSessionState { + thread_id, + final_message: None, + last_status: None, + }); + + let reason = app.reject_btw_request( + thread_id, + &request_user_input_request(thread_id, "turn-btw", "call-1"), + ); + + assert_eq!( + reason, + Some( + "the hidden temporary discussion asked for interactive user input. Run the prompt in the main thread instead.".to_string() + ) + ); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + popup.contains("asked for interactive user input"), + "expected /btw failure popup for hidden request_user_input: {popup}" + ); +} diff --git a/codex-rs/tui/src/app/tests/clawbot_tests.rs b/codex-rs/tui/src/app/tests/clawbot_tests.rs new file mode 100644 index 000000000..9576bcf51 --- /dev/null +++ b/codex-rs/tui/src/app/tests/clawbot_tests.rs @@ -0,0 +1,613 @@ +use super::*; +use crate::app_event::ClawbotForwardingChannel; +use codex_clawbot::ClawbotRuntime; +use codex_clawbot::ClawbotStore; +use codex_clawbot::ClawbotTurnMode; +use codex_clawbot::ProviderEvent as ClawbotProviderEvent; +use codex_clawbot::ProviderKind as ClawbotProviderKind; +use codex_clawbot::ProviderMessageRef; +use codex_clawbot::ProviderOutboundReaction; +use codex_clawbot::ProviderOutboundTextMessage; +use codex_clawbot::ProviderSession; +use codex_clawbot::ProviderSessionRef; +use codex_clawbot::SessionStatus as ClawbotSessionStatus; +use pretty_assertions::assert_eq; + +async fn bind_test_clawbot_session( + app: &mut App, + app_server: &mut AppServerSession, + session_id: &str, +) -> Result<(ThreadId, ProviderSessionRef)> { + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + let session = ProviderSessionRef::new(ClawbotProviderKind::Feishu, session_id); + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .persist_session(ProviderSession { + provider: ClawbotProviderKind::Feishu, + session_id: session_id.to_string(), + display_name: Some("Alice".to_string()), + unread_count: 0, + last_message_at: None, + status: ClawbotSessionStatus::Discovered, + bound_thread_id: None, + }) + .expect("persist session"); + runtime + .connect_session_to_thread(&session, thread_id.to_string()) + .expect("connect session"); + app.sync_clawbot_workspace(app_server).await; + Ok((thread_id, session)) +} + +#[tokio::test] +async fn clawbot_inbound_message_resumes_bound_thread_and_starts_turn() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_resume").await?; + + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "hello from feishu".to_string(), + received_at: 1, + }), + ) + .await + .expect("handle clawbot inbound message"); + + assert!(app.thread_event_channels.contains_key(&thread_id)); + assert_eq!( + app.clawbot_outbound_reactions, + vec![ProviderOutboundReaction { + target: ProviderMessageRef::new(ClawbotProviderKind::Feishu, "chat_resume", "msg_1"), + emoji_type: "TONGUE".to_string(), + }] + ); + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + assert!(app.active_turn_id_for_thread(thread_id).await.is_some()); + + Ok(()) +} + +#[tokio::test] +async fn noninteractive_clawbot_request_user_input_builds_auto_response() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.clawbot_pending_turns.insert( + thread_id, + VecDeque::from([PendingClawbotTurn { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), + message_id: "msg-1".to_string(), + turn_mode: ClawbotTurnMode::NonInteractive, + }]), + ); + let request = ServerRequest::ToolRequestUserInput { + request_id: AppServerRequestId::Integer(1), + params: ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: Vec::new(), + }, + }; + + let op = app + .clawbot_auto_response_op_for_server_request(thread_id, &request) + .expect("auto response op"); + + match op.view() { + crate::app_command::AppCommandView::UserInputAnswer { id, response } => { + assert_eq!(id, "turn-1"); + assert_eq!( + response, + &codex_protocol::request_user_input::RequestUserInputResponse { + answers: HashMap::new(), + } + ); + } + _ => panic!("expected UserInputAnswer"), + } +} + +#[tokio::test] +async fn noninteractive_clawbot_permissions_request_builds_auto_response() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.clawbot_pending_turns.insert( + thread_id, + VecDeque::from([PendingClawbotTurn { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_auto"), + message_id: "msg-1".to_string(), + turn_mode: ClawbotTurnMode::NonInteractive, + }]), + ); + let request = ServerRequest::PermissionsRequestApproval { + request_id: AppServerRequestId::Integer(7), + params: PermissionsRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-approval".to_string(), + reason: Some("Need access".to_string()), + permissions: codex_app_server_protocol::RequestPermissionProfile { + network: None, + file_system: None, + }, + }, + }; + + let op = app + .clawbot_auto_response_op_for_server_request(thread_id, &request) + .expect("auto response op"); + + match op.view() { + crate::app_command::AppCommandView::RequestPermissionsResponse { id, response } => { + assert_eq!(id, "call-approval"); + assert_eq!( + response, + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: Default::default(), + scope: codex_protocol::request_permissions::PermissionGrantScope::Turn, + } + ); + } + _ => panic!("expected RequestPermissionsResponse"), + } +} + +#[tokio::test] +async fn clawbot_turn_completed_forwards_reply_and_drains_next_message() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_reply").await?; + + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "first".to_string(), + received_at: 1, + }), + ) + .await + .expect("handle first clawbot inbound"); + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_2".to_string(), + text: "second".to_string(), + received_at: 2, + }), + ) + .await + .expect("handle second clawbot inbound"); + + let first_turn_id = app + .clawbot_pending_turns + .get(&thread_id) + .and_then(|queue| queue.front()) + .map(|pending| pending.turn_id.clone()) + .expect("first pending turn"); + let queued_runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!(queued_runtime.snapshot().unread_message_count, 2); + + app.enqueue_thread_notification( + thread_id, + turn_completed_notification_with_agent_message( + thread_id, + &first_turn_id, + TurnStatus::Completed, + "forwarded reply", + ), + ) + .await?; + app.handle_clawbot_turn_completed( + &mut app_server, + thread_id, + test_turn( + &first_turn_id, + TurnStatus::Completed, + vec![ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "forwarded reply".to_string(), + phase: None, + memory_citation: None, + }], + ), + ) + .await + .expect("handle clawbot turn completion"); + + assert_eq!( + app.clawbot_outbound_messages, + vec![ProviderOutboundTextMessage { + session: session.clone(), + text: "forwarded reply".to_string(), + }] + ); + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + let drained_runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!(drained_runtime.snapshot().unread_message_count, 1); + + Ok(()) +} + +#[tokio::test] +async fn clawbot_restart_recovers_pending_turn_and_forwards_reply() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_restart").await?; + + app.handle_clawbot_provider_event( + &mut app_server, + ClawbotProviderEvent::InboundMessage(codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "hello after restart".to_string(), + received_at: 1, + }), + ) + .await + .expect("handle clawbot inbound"); + + let original_turn_id = app + .clawbot_pending_turns + .get(&thread_id) + .and_then(|queue| queue.front()) + .map(|pending| pending.turn_id.clone()) + .expect("pending turn"); + let store = ClawbotStore::new(app.config.cwd.to_path_buf()); + assert_eq!(store.load_pending_turns().expect("pending turns").len(), 1); + + let mut restarted_app = make_test_app().await; + restarted_app.config.cwd = tempdir.path().to_path_buf().abs(); + restarted_app.sync_clawbot_workspace(&mut app_server).await; + + assert_eq!( + restarted_app + .clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + + restarted_app + .handle_clawbot_turn_completed( + &mut app_server, + thread_id, + test_turn( + &original_turn_id, + TurnStatus::Completed, + vec![ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "restart reply".to_string(), + phase: None, + memory_citation: None, + }], + ), + ) + .await + .expect("complete restored clawbot turn"); + + assert_eq!( + restarted_app.clawbot_outbound_messages, + vec![ProviderOutboundTextMessage { + session, + text: "restart reply".to_string(), + }] + ); + + let runtime = ClawbotRuntime::load(restarted_app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!(runtime.snapshot().unread_message_count, 0); + assert_eq!( + store.load_pending_turns().expect("pending turns"), + Vec::new() + ); + + Ok(()) +} + +#[tokio::test] +async fn clawbot_sync_clears_stale_pending_turn_and_redelivers_unread() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_stale").await?; + + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .apply_provider_event(ClawbotProviderEvent::InboundMessage( + codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "deliver me".to_string(), + received_at: 1, + }, + )) + .expect("queue unread"); + + let store = ClawbotStore::new(app.config.cwd.to_path_buf()); + store + .upsert_pending_turn(PendingClawbotTurn { + thread_id: thread_id.to_string(), + turn_id: "stale-turn".to_string(), + session: session.clone(), + message_id: "msg_1".to_string(), + turn_mode: ClawbotTurnMode::NonInteractive, + }) + .expect("persist stale pending turn"); + app.clawbot_pending_turns.clear(); + + app.sync_clawbot_workspace(&mut app_server).await; + + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + assert_eq!(app.clawbot_outbound_reactions.len(), 1); + assert_eq!( + store + .load_pending_turns() + .expect("pending turns") + .into_iter() + .map(|pending| pending.turn_id) + .collect::>(), + app.clawbot_pending_turns + .get(&thread_id) + .expect("pending queue") + .iter() + .map(|pending| pending.turn_id.clone()) + .collect::>() + ); + assert_ne!( + app.clawbot_pending_turns + .get(&thread_id) + .and_then(|queue| queue.front()) + .map(|pending| pending.turn_id.as_str()), + Some("stale-turn") + ); + + Ok(()) +} + +#[tokio::test] +async fn clawbot_manual_bind_replays_cached_unread_messages() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let thread_id = started.session.thread_id; + app.active_thread_id = Some(thread_id); + + let session = ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_bind"); + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .apply_provider_event(ClawbotProviderEvent::InboundMessage( + codex_clawbot::ProviderInboundMessage { + session: session.clone(), + message_id: "msg_1".to_string(), + text: "queued before bind".to_string(), + received_at: 1, + }, + )) + .expect("queue unread"); + + app.bind_clawbot_session_to_current_thread(&mut app_server, "chat_bind".to_string()) + .await + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!( + runtime + .bound_session_for_thread(&thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + Some(session) + ); + assert_eq!( + app.clawbot_pending_turns + .get(&thread_id) + .map(std::collections::VecDeque::len), + Some(1) + ); + Ok(()) +} + +#[tokio::test] +async fn clawbot_current_thread_controls_update_binding_state() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, _session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_controls").await?; + app.active_thread_id = Some(thread_id); + + app.clawbot_set_current_thread_forwarding( + ClawbotForwardingChannel::Inbound, + /*enabled*/ false, + ) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + app.clawbot_set_current_thread_forwarding( + ClawbotForwardingChannel::Outbound, + /*enabled*/ false, + ) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let binding = runtime + .load_binding_for_thread(&thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))? + .expect("binding"); + assert!(!binding.inbound_forwarding_enabled); + assert!(!binding.outbound_forwarding_enabled); + + app.clawbot_disconnect_current_thread() + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!( + runtime + .load_binding_for_thread(&thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + None + ); + Ok(()) +} + +#[tokio::test] +async fn clawbot_management_popup_snapshot() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let (thread_id, _session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_snapshot").await?; + app.active_thread_id = Some(thread_id); + + let mut runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .update_feishu_config(Some(codex_clawbot::FeishuConfig { + app_id: "cli_app_123".to_string(), + app_secret: "secret_value_4567".to_string(), + verification_token: Some("verify_token".to_string()), + encrypt_key: None, + bot_open_id: Some("ou_bot_open_id".to_string()), + bot_user_id: None, + })) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .persist_session(ProviderSession { + provider: ClawbotProviderKind::Feishu, + session_id: "chat_discovered".to_string(), + display_name: Some("Bob".to_string()), + unread_count: 0, + last_message_at: None, + status: ClawbotSessionStatus::Discovered, + bound_thread_id: None, + }) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + runtime + .apply_provider_event(ClawbotProviderEvent::InboundMessage( + codex_clawbot::ProviderInboundMessage { + session: ProviderSessionRef::new(ClawbotProviderKind::Feishu, "chat_discovered"), + message_id: "msg_discovered".to_string(), + text: "hello".to_string(), + received_at: 10, + }, + )) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + app.open_clawbot_management_popup(); + + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert_snapshot!("clawbot_management_popup", popup); + Ok(()) +} + +#[tokio::test] +async fn clawbot_rebinds_discovered_session_from_management_actions() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let tempdir = tempdir()?; + app.config.cwd = tempdir.path().to_path_buf().abs(); + + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("start thread"); + let target_thread_id = started.session.thread_id; + app.active_thread_id = Some(target_thread_id); + + let (source_thread_id, session) = + bind_test_clawbot_session(&mut app, &mut app_server, "chat_rebind").await?; + + app.bind_clawbot_session_to_current_thread(&mut app_server, "chat_rebind".to_string()) + .await + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + + let runtime = ClawbotRuntime::load(app.config.cwd.to_path_buf()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?; + assert_eq!( + runtime + .bound_session_for_thread(&target_thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + Some(session.clone()) + ); + assert_eq!( + runtime + .bound_session_for_thread(&source_thread_id.to_string()) + .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?, + None + ); + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap new file mode 100644 index 000000000..5109eb0bf --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__clawbot_tests__clawbot_management_popup.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/app/tests/clawbot_tests.rs +assertion_line: 413 +expression: popup +--- + Clawbot + Manage workspace-local Feishu credentials, sessions, and the current thread binding. + +› 1. Turn Mode Switch clawbot-originated turns into non-interactive mode so + remote sessions do not block on prompts. + Feishu Sessions 2 total · 1 bound · 1 unbound + 3. Scan Feishu Sessions Discover Feishu chats and bot groups using the persisted workspace + credentials. + 4. Clear Unbound Sessions Remove 1 unbound sessions and 1 cached unread messages. + Alice bound · 0 unread + 6. Bob unbound · 1 unread + 7. Feishu App ID Configured: cli_app_123 + 8. Feishu App Secret Configured: *************4567 + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/thread_menu.rs b/codex-rs/tui/src/app/thread_menu.rs new file mode 100644 index 000000000..8231f45e4 --- /dev/null +++ b/codex-rs/tui/src/app/thread_menu.rs @@ -0,0 +1,269 @@ +use std::sync::Arc; + +use super::App; +use super::jump_navigation::build_jump_targets; +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::history_cell::HistoryCell; + +const THREAD_PANEL_VIEW_ID: &str = "thread-actions"; +const JUMP_TO_MESSAGE_VIEW_ID: &str = "jump-to-message"; + +impl App { + pub(crate) fn open_thread_panel(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(THREAD_PANEL_VIEW_ID); + let params = thread_panel_params(self.chat_widget.is_task_running(), initial_selected_idx); + if !self + .chat_widget + .replace_selection_view_if_active(THREAD_PANEL_VIEW_ID, params) + { + self.chat_widget.show_selection_view(thread_panel_params( + self.chat_widget.is_task_running(), + initial_selected_idx, + )); + } + } + + pub(crate) fn open_jump_to_message_panel(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(JUMP_TO_MESSAGE_VIEW_ID); + let params = jump_to_message_panel_params(&self.transcript_cells, initial_selected_idx); + if !self + .chat_widget + .replace_selection_view_if_active(JUMP_TO_MESSAGE_VIEW_ID, params) + { + self.chat_widget + .show_selection_view(jump_to_message_panel_params( + &self.transcript_cells, + initial_selected_idx, + )); + } + } +} + +fn thread_panel_params( + task_running: bool, + initial_selected_idx: Option, +) -> SelectionViewParams { + let mut items = vec![SelectionItem { + name: "Fork Current Session".to_string(), + description: Some("Create a new thread from the current session state.".to_string()), + selected_description: Some( + "Fork the current thread into a new session and continue there.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::ForkCurrentSession))], + dismiss_on_select: true, + is_disabled: task_running, + disabled_reason: task_running + .then_some("Wait for the current task to finish before forking.".to_string()), + ..Default::default() + }]; + + items.push(SelectionItem { + name: "Jump To Message".to_string(), + description: Some( + "Search committed transcript entries and open the transcript overlay.".to_string(), + ), + selected_description: Some( + "Search the current transcript and jump directly to a committed entry.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenJumpToMessagePanel))], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Undo Last User Message".to_string(), + description: Some("Restore the last sent input and roll back one turn.".to_string()), + selected_description: Some( + "Restore the latest user input to the composer and rewind one committed turn." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::UndoLastUserMessage))], + dismiss_on_select: true, + is_disabled: task_running, + disabled_reason: task_running + .then_some("Wait for the current task to finish before undoing a turn.".to_string()), + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(THREAD_PANEL_VIEW_ID), + title: Some("Thread".to_string()), + subtitle: Some("Fork, rewind, or jump within the current conversation.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + } +} + +fn jump_to_message_panel_params( + transcript_cells: &[Arc], + initial_selected_idx: Option, +) -> SelectionViewParams { + let targets = build_jump_targets(transcript_cells); + let subtitle = if targets.is_empty() { + Some("No committed transcript entries are available yet.".to_string()) + } else { + Some(format!( + "{} committed transcript entr{} available.", + targets.len(), + if targets.len() == 1 { + "y is" + } else { + "ies are" + }, + )) + }; + let items = if targets.is_empty() { + vec![SelectionItem { + name: "Nothing to jump to yet".to_string(), + description: Some( + "Send a message or wait for a response before using Jump To Message.".to_string(), + ), + is_disabled: true, + ..Default::default() + }] + } else { + targets + .into_iter() + .map(|target| { + let cell_index = target.cell_index; + let search_value = target.search_value(); + let name = target.title; + let description = target.preview; + SelectionItem { + name, + description: Some(description), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::JumpToTranscriptCell { cell_index }); + })], + dismiss_on_select: true, + search_value: Some(search_value), + ..Default::default() + } + }) + .collect() + }; + + SelectionViewParams { + view_id: Some(JUMP_TO_MESSAGE_VIEW_ID), + title: Some("Jump To Message".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search committed transcript".to_string()), + initial_selected_idx, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use std::sync::Arc; + use tokio::sync::mpsc::unbounded_channel; + + use super::jump_to_message_panel_params; + use super::thread_panel_params; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::HistoryCell; + use crate::history_cell::UserHistoryCell; + use crate::render::renderable::Renderable; + + fn render_selection_popup(view: &ListSelectionView, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|frame| { + let area = Rect::new(0, 0, width, height); + view.render(area, frame.buffer_mut()); + }) + .expect("draw popup"); + format!("{:?}", terminal.backend()) + } + + #[test] + fn thread_panel_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + thread_panel_params( + /*task_running*/ false, /*initial_selected_idx*/ None, + ), + tx, + ); + + assert_snapshot!( + "thread_panel_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 20) + ); + } + + #[test] + fn thread_panel_disables_mutations_while_task_running() { + let params = thread_panel_params( + /*task_running*/ true, /*initial_selected_idx*/ None, + ); + + assert_eq!(params.items[0].is_disabled, true); + assert_eq!(params.items[1].is_disabled, false); + assert_eq!(params.items[2].is_disabled, true); + } + + #[test] + fn jump_to_message_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let cells = vec![ + Arc::new(UserHistoryCell { + message: "How do I keep the retry chain intact?".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(AgentMessageCell::new( + vec!["Classify 503 as fallback-eligible before surfacing the final error.".into()], + /*is_first_line*/ true, + )) as Arc, + ]; + let view = ListSelectionView::new( + jump_to_message_panel_params(&cells, /*initial_selected_idx*/ None), + tx, + ); + + assert_snapshot!( + "jump_to_message_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 22) + ); + } + + #[test] + fn jump_to_message_empty_popup_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let cells: Vec> = Vec::new(); + let view = ListSelectionView::new( + jump_to_message_panel_params(&cells, /*initial_selected_idx*/ None), + tx, + ); + + assert_snapshot!( + "jump_to_message_empty_popup", + render_selection_popup(&view, /*width*/ 92, /*height*/ 18) + ); + } +} diff --git a/codex-rs/tui/src/app/workflow_controls.rs b/codex-rs/tui/src/app/workflow_controls.rs new file mode 100644 index 000000000..df71300f5 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_controls.rs @@ -0,0 +1,2068 @@ +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::app_event::AppEvent; +use crate::app_event::WorkflowControlsDestination; +use crate::app_event::WorkflowJobEditableField; +use crate::app_event::WorkflowTriggerEditableField; +use crate::app_event::WorkflowTriggerType; +use crate::app_server_session::AppServerSession; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::external_editor; +use crate::history_cell; +use crate::history_cell::HistoryCell; +use crate::tui; + +use super::App; +use super::workflow_definition::LoadedWorkflowFile; +use super::workflow_definition::LoadedWorkflowJob; +use super::workflow_definition::LoadedWorkflowRegistry; +use super::workflow_definition::WorkflowContextMode; +use super::workflow_definition::WorkflowResponseMode; +use super::workflow_definition::WorkflowTriggerKind; +use super::workflow_definition::load_workflow_registry; +use super::workflow_editor; + +const WORKFLOW_CONTROLS_VIEW_ID: &str = "workflow-controls"; + +#[derive(Clone)] +struct WorkflowMenuState { + files: Vec, + registry_error: Option, +} + +#[derive(Clone)] +struct WorkflowFileSummary { + workflow_path: PathBuf, + workflow_name: Option, + display_name: String, + filename: String, + jobs: Vec, + triggers: Vec, + trigger_count: usize, + job_count: usize, +} + +#[derive(Clone)] +struct WorkflowTriggerSummary { + id: String, + enabled: bool, + kind: WorkflowTriggerKind, +} + +impl App { + pub(crate) fn open_workflow_controls_popup(&mut self) { + self.open_workflow_control_view(WorkflowControlsDestination::Root); + } + + pub(crate) fn open_workflow_control_view(&mut self, destination: WorkflowControlsDestination) { + let active_selected_idx = self + .chat_widget + .selected_index_for_active_view(WORKFLOW_CONTROLS_VIEW_ID); + let params = match destination { + WorkflowControlsDestination::Root => { + self.workflow_root_popup_params(active_selected_idx) + } + WorkflowControlsDestination::File { workflow_path } => { + self.workflow_file_popup_params(workflow_path.as_path(), Some(0)) + } + WorkflowControlsDestination::Jobs { workflow_path } => { + self.workflow_jobs_popup_params(workflow_path.as_path(), Some(0)) + } + WorkflowControlsDestination::Job { + workflow_path, + job_name, + } => self.workflow_job_popup_params(workflow_path.as_path(), &job_name, Some(0)), + WorkflowControlsDestination::ManualTriggers { workflow_path } => { + self.workflow_manual_triggers_popup_params(workflow_path.as_path(), Some(0)) + } + WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id, + } => self.workflow_manual_trigger_popup_params( + workflow_path.as_path(), + &trigger_id, + Some(0), + ), + WorkflowControlsDestination::TriggerType { + workflow_path, + trigger_id, + } => self.workflow_trigger_type_popup_params( + workflow_path.as_path(), + &trigger_id, + Some(0), + ), + }; + self.show_workflow_popup(params, active_selected_idx.is_some()); + } + + pub(crate) fn refresh_workflow_controls_if_active(&mut self) { + let Some(initial_selected_idx) = self + .chat_widget + .selected_index_for_active_view(WORKFLOW_CONTROLS_VIEW_ID) + else { + return; + }; + self.show_workflow_popup( + self.workflow_root_popup_params(Some(initial_selected_idx)), + /*replace_if_active*/ true, + ); + } + + pub(crate) fn start_manual_workflow_trigger_from_ui( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + ) -> Arc { + let cell = self.start_manual_workflow_trigger_run(app_server, workflow_name, trigger_id); + self.refresh_workflow_controls_if_active(); + cell + } + + pub(crate) fn start_manual_workflow_job_from_ui( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + job_name: String, + ) -> Arc { + let cell = self.start_manual_workflow_job_run(app_server, workflow_name, job_name); + self.refresh_workflow_controls_if_active(); + cell + } + + pub(crate) async fn create_default_workflow_template_from_ui(&mut self, tui: &mut tui::Tui) { + match workflow_editor::create_default_workflow_template(self.config.cwd.as_path()) { + Ok(workflow_path) => { + self.chat_widget.add_info_message( + format!("Created workflow template at {}.", workflow_path.display()), + /*hint*/ None, + ); + self.edit_workflow_file_from_ui( + tui, + workflow_path.clone(), + WorkflowControlsDestination::File { workflow_path }, + ) + .await; + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + pub(crate) async fn edit_workflow_file_from_ui( + &mut self, + tui: &mut tui::Tui, + workflow_path: PathBuf, + reopen: WorkflowControlsDestination, + ) { + let Ok(editor_cmd) = self.resolve_editor_command_for_workflows() else { + return; + }; + + let edit_result = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::edit_file(workflow_path.as_path(), &editor_cmd).await + }) + .await; + match edit_result { + Ok(()) => { + self.open_workflow_control_view(reopen); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } + } + tui.frame_requester().schedule_frame(); + } + + pub(crate) async fn edit_workflow_job_field_from_ui( + &mut self, + tui: &mut tui::Tui, + workflow_path: PathBuf, + job_name: String, + field: WorkflowJobEditableField, + ) { + let seed = match workflow_editor::job_field_seed(workflow_path.as_path(), &job_name, field) + { + Ok(seed) => seed, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + return; + } + }; + let Ok(editor_cmd) = self.resolve_editor_command_for_workflows() else { + return; + }; + let suffix = match field { + WorkflowJobEditableField::Needs => ".yaml", + WorkflowJobEditableField::Steps => ".yaml", + }; + let edited = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::run_editor_with_suffix(&seed, &editor_cmd, suffix).await + }) + .await; + match edited { + Ok(updated) => match workflow_editor::write_job_field( + workflow_path.as_path(), + &job_name, + field, + &updated, + ) { + Ok(()) => { + self.chat_widget.add_info_message( + format!( + "Updated `{}` for workflow job `{job_name}`.", + workflow_job_field_label(field) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + }, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } + } + tui.frame_requester().schedule_frame(); + } + + pub(crate) async fn edit_workflow_trigger_field_from_ui( + &mut self, + tui: &mut tui::Tui, + workflow_path: PathBuf, + trigger_id: String, + field: WorkflowTriggerEditableField, + ) { + let seed = match workflow_editor::trigger_field_seed( + workflow_path.as_path(), + &trigger_id, + field, + ) { + Ok(seed) => seed, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + return; + } + }; + let Ok(editor_cmd) = self.resolve_editor_command_for_workflows() else { + return; + }; + let suffix = if matches!(field, WorkflowTriggerEditableField::Jobs) { + ".yaml" + } else { + ".txt" + }; + let edited = tui + .with_restored(tui::RestoreMode::KeepRaw, || async { + external_editor::run_editor_with_suffix(&seed, &editor_cmd, suffix).await + }) + .await; + match edited { + Ok(updated) => match workflow_editor::write_trigger_field( + workflow_path.as_path(), + &trigger_id, + field, + &updated, + ) { + Ok(next_trigger_id) => { + self.chat_widget.add_info_message( + format!( + "Updated `{}` for workflow trigger `{next_trigger_id}`.", + workflow_trigger_field_label(field) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id: next_trigger_id, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + }, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } + } + tui.frame_requester().schedule_frame(); + } + + pub(crate) fn toggle_workflow_job_enabled_from_ui( + &mut self, + workflow_path: PathBuf, + job_name: String, + ) { + match workflow_editor::toggle_job_enabled(workflow_path.as_path(), &job_name) { + Ok(enabled) => { + self.chat_widget.add_info_message( + format!( + "{} workflow job `{job_name}`.", + if enabled { "Enabled" } else { "Disabled" } + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + pub(crate) fn toggle_workflow_trigger_enabled_from_ui( + &mut self, + workflow_path: PathBuf, + trigger_id: String, + ) { + match workflow_editor::toggle_trigger_enabled(workflow_path.as_path(), &trigger_id) { + Ok(enabled) => { + self.chat_widget.add_info_message( + format!( + "{} workflow trigger `{trigger_id}`.", + if enabled { "Enabled" } else { "Disabled" } + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + pub(crate) fn set_workflow_trigger_type_from_ui( + &mut self, + workflow_path: PathBuf, + trigger_id: String, + trigger_type: WorkflowTriggerType, + ) { + match workflow_editor::set_trigger_type(workflow_path.as_path(), &trigger_id, trigger_type) + { + Ok(next_trigger_id) => { + self.chat_widget.add_info_message( + format!( + "Workflow trigger `{next_trigger_id}` now uses {}.", + workflow_trigger_type_label(trigger_type) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id: next_trigger_id, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + pub(crate) fn cycle_workflow_job_context_from_ui( + &mut self, + workflow_path: PathBuf, + job_name: String, + ) { + match workflow_editor::cycle_job_context(workflow_path.as_path(), &job_name) { + Ok(context) => { + self.chat_widget.add_info_message( + format!( + "Workflow job `{job_name}` now uses {} context.", + workflow_context_label(context) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + pub(crate) fn cycle_workflow_job_response_from_ui( + &mut self, + workflow_path: PathBuf, + job_name: String, + ) { + match workflow_editor::cycle_job_response(workflow_path.as_path(), &job_name) { + Ok(response) => { + self.chat_widget.add_info_message( + format!( + "Workflow job `{job_name}` now delivers a {} response.", + workflow_response_label(response) + ), + /*hint*/ None, + ); + self.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name, + }); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(err)); + } + } + } + + fn resolve_editor_command_for_workflows(&mut self) -> Result>, ()> { + match external_editor::resolve_editor_commands() { + Ok(cmds) => Ok(cmds), + Err(external_editor::EditorError::MissingEditor) => { + self.chat_widget + .add_to_history(history_cell::new_error_event( + "Cannot open external editor: no usable editor found in $VISUAL, $EDITOR, or `vim`." + .to_string(), + )); + Err(()) + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + Err(()) + } + } + } + + fn show_workflow_popup(&mut self, params: SelectionViewParams, replace_if_active: bool) { + if replace_if_active { + let _ = self + .chat_widget + .replace_selection_view_if_active(WORKFLOW_CONTROLS_VIEW_ID, params); + return; + } + self.chat_widget.show_selection_view(params); + } + + fn workflow_root_popup_params( + &self, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let running_labels = self.background_workflow_labels(); + let queued_labels = self.queued_trigger_labels(); + let state = workflow_menu_state(self.config.cwd.as_path()); + + let mut items = vec![SelectionItem { + name: "Background Tasks".to_string(), + description: Some(workflow_status_summary(&running_labels, &queued_labels)), + selected_description: Some( + "Insert a background task snapshot into the transcript. /ps shows the same live workflow state." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::ShowWorkflowBackgroundTasks))], + dismiss_on_select: false, + ..Default::default() + }]; + + match state { + Ok(state) => { + if let Some(err) = state.registry_error { + items.push(SelectionItem { + name: "Workflow Registry Error".to_string(), + description: Some(err), + selected_description: Some( + "Structured workflow actions are unavailable until the YAML parses again, but you can still open files below." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + + if state.files.is_empty() { + items.push(SelectionItem { + name: "Create workflow.yaml".to_string(), + description: Some( + "Create a starter template under .codex/workflows and open it in your editor." + .to_string(), + ), + selected_description: Some( + "Create a default workflow template, then open it in your configured editor." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::CreateDefaultWorkflowTemplate))], + dismiss_on_select: false, + ..Default::default() + }); + } else { + for file in state.files { + let workflow_prefix = file.display_name.clone(); + let workflow_filename = file.filename.clone(); + + items.push(SelectionItem { + name: format!("{workflow_prefix} - edit yaml"), + description: Some(format!("Open {workflow_filename} in your editor.")), + selected_description: Some(match file.workflow_name { + Some(_) => { + "Open the real workflow YAML file in your external editor." + .to_string() + } + None => { + "Open this workflow file in your editor so you can fix the YAML." + .to_string() + } + }), + search_value: Some(format!( + "{} {} edit workflow yaml", + workflow_prefix.to_ascii_lowercase(), + workflow_filename.to_ascii_lowercase() + )), + actions: vec![Box::new({ + let workflow_path = file.workflow_path.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowFile { + workflow_path: workflow_path.clone(), + reopen: WorkflowControlsDestination::Root, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + if file.workflow_name.is_some() { + for job_name in &file.jobs { + items.push(SelectionItem { + name: format!("{workflow_prefix} - job - {job_name}"), + description: Some("Workflow job".to_string()), + selected_description: Some( + "Open this job directly. From there you can run it, toggle it, and edit its fields." + .to_string(), + ), + search_value: Some(format!( + "{} {} {} job", + workflow_prefix.to_ascii_lowercase(), + workflow_filename.to_ascii_lowercase(), + job_name.to_ascii_lowercase() + )), + actions: vec![Box::new({ + let workflow_path = file.workflow_path.clone(); + let job_name = job_name.clone(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::Job { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + }, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + } + + for trigger in &file.triggers { + items.push(SelectionItem { + name: format!("{workflow_prefix} - trigger - {}", trigger.id), + description: Some(format!( + "{} · {}", + workflow_trigger_kind_display(&trigger.kind), + if trigger.enabled { "Enabled" } else { "Disabled" } + )), + selected_description: Some( + "Open this trigger directly. From there you can run it, toggle it, change its type, and edit its parameters." + .to_string(), + ), + search_value: Some(format!( + "{} {} {} trigger {}", + workflow_prefix.to_ascii_lowercase(), + workflow_filename.to_ascii_lowercase(), + trigger.id.to_ascii_lowercase(), + workflow_trigger_kind_display(&trigger.kind).to_ascii_lowercase() + )), + actions: vec![Box::new({ + let workflow_path = file.workflow_path.clone(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: + WorkflowControlsDestination::ManualTrigger { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + }, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + } + } + } + } + } + Err(err) => { + items.push(SelectionItem { + name: "Workflow Registry Error".to_string(), + description: Some(err), + selected_description: Some( + "Fix the workflow files under .codex/workflows, then reopen /workflow." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow".to_string()), + subtitle: Some("Manage workflow files, jobs, and triggers directly.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search workflows".to_string()), + initial_selected_idx, + ..Default::default() + } + } + + fn workflow_file_popup_params( + &self, + workflow_path: &Path, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_file_view_state(self.config.cwd.as_path(), workflow_path) { + Ok(state) => { + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::Root), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::File { + workflow_path: workflow_path.to_path_buf(), + }, + ), + ]; + + if let Some(err) = &state.registry_error { + items.push(SelectionItem { + name: "Registry Error".to_string(), + description: Some(err.clone()), + selected_description: Some( + "Fix the YAML in your editor to restore jobs and trigger actions." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Jobs".to_string(), + description: Some(count_label(state.summary.job_count, "job")), + selected_description: Some( + "Open the jobs menu for this workflow. From there you can drill into a job, run it, toggle it, and edit fields." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::Jobs { + workflow_path: workflow_path.clone(), + }, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + items.push(SelectionItem { + name: "Triggers".to_string(), + description: Some(count_label( + state.summary.trigger_count, + "trigger", + )), + selected_description: Some( + "Open this workflow's triggers. Trigger runs stay visible in the footer and /ps." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::ManualTriggers { + workflow_path: workflow_path.clone(), + }, + }); + } + })], + dismiss_on_select: false, + is_disabled: state.summary.trigger_count == 0, + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow".to_string()), + subtitle: Some(format!( + "{} · {}", + state.summary.display_name, state.summary.filename + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search workflow actions".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Workflow", + "Failed to open workflow file.", + err, + workflow_back_item(WorkflowControlsDestination::Root), + initial_selected_idx, + ), + } + } + + fn workflow_jobs_popup_params( + &self, + workflow_path: &Path, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_file_state(self.config.cwd.as_path(), workflow_path) { + Ok((summary, registry, workflow)) => { + let running_labels = self.background_workflow_labels(); + let queued_labels = self.queued_trigger_labels(); + let running_set = running_labels.iter().cloned().collect::>(); + let queued_set = queued_labels.iter().cloned().collect::>(); + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::Root), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::Jobs { + workflow_path: workflow_path.to_path_buf(), + }, + ), + ]; + + let mut jobs = registry + .jobs + .values() + .filter(|job| job.workflow_path == workflow.source_path) + .cloned() + .collect::>(); + jobs.sort_by_key(|job| job.definition_index); + + if jobs.is_empty() { + items.push(SelectionItem { + name: "No jobs defined".to_string(), + description: Some( + "Edit workflow.yaml to add jobs to this workflow.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } else { + for job in jobs { + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + let status = workflow_target_status( + &format!("{} · {}", workflow.name, job.name), + &running_set, + &queued_set, + ); + items.push(SelectionItem { + name: job.name.clone(), + description: Some(format!( + "{} · {} · {} · {status}", + if job.config.enabled { "Enabled" } else { "Disabled" }, + workflow_context_label(job.config.context), + workflow_response_label(job.config.response) + )), + selected_description: Some( + "Open this job. You can run it now, toggle enabled state, and edit its fields." + .to_string(), + ), + search_value: Some(format!( + "{} {} {} {}", + summary.display_name, + summary.filename, + job.name, + status + )), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::Job { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + }, + }); + })], + dismiss_on_select: false, + ..Default::default() + }); + } + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow Jobs".to_string()), + subtitle: Some(format!("{} · {}", workflow.name, summary.filename)), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search jobs".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Workflow Jobs", + "Failed to load workflow jobs.", + err, + workflow_back_item(WorkflowControlsDestination::Root), + initial_selected_idx, + ), + } + } + + fn workflow_manual_triggers_popup_params( + &self, + workflow_path: &Path, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_file_state(self.config.cwd.as_path(), workflow_path) { + Ok((summary, _registry, workflow)) => { + let running_labels = self.background_workflow_labels(); + let queued_labels = self.queued_trigger_labels(); + let running_set = running_labels.iter().cloned().collect::>(); + let queued_set = queued_labels.iter().cloned().collect::>(); + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::Root), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::ManualTriggers { + workflow_path: workflow_path.to_path_buf(), + }, + ), + ]; + + let triggers = workflow.triggers.iter().collect::>(); + if triggers.is_empty() { + items.push(SelectionItem { + name: "No triggers defined".to_string(), + description: Some("Edit workflow.yaml to add triggers.".to_string()), + is_disabled: true, + ..Default::default() + }); + } else { + for trigger in triggers { + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + let label = format!("{} · {}", workflow.name, trigger.id); + let status = workflow_target_status(&label, &running_set, &queued_set); + items.push(SelectionItem { + name: trigger.id.clone(), + description: Some(format!( + "{} · {} · {status}", + workflow_trigger_kind_display(&trigger.kind), + if trigger.enabled { "Enabled" } else { "Disabled" } + )), + selected_description: Some( + "Open this trigger. From there you can run it, toggle it, change its type, and edit its parameters." + .to_string(), + ), + search_value: Some(format!( + "{} {} {} {}", + summary.display_name, + summary.filename, + trigger.id, + status + )), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::ManualTrigger { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + }, + }); + })], + dismiss_on_select: false, + ..Default::default() + }); + } + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow Triggers".to_string()), + subtitle: Some(format!("{} · {}", workflow.name, summary.filename)), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search triggers".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Workflow Triggers", + "Failed to load workflow triggers.", + err, + workflow_back_item(WorkflowControlsDestination::Root), + initial_selected_idx, + ), + } + } + + fn workflow_manual_trigger_popup_params( + &self, + workflow_path: &Path, + trigger_id: &str, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_trigger_state(self.config.cwd.as_path(), workflow_path, trigger_id) { + Ok((summary, workflow, trigger)) => { + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::Root), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::ManualTrigger { + workflow_path: workflow_path.to_path_buf(), + trigger_id: trigger.id.clone(), + }, + ), + ]; + + if trigger.enabled { + items.push(SelectionItem { + name: "Run Now".to_string(), + description: Some( + "Run this trigger immediately in a background workflow thread." + .to_string(), + ), + selected_description: Some( + "Start this trigger now. Running state stays visible in the footer and /ps." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_name = workflow.name.clone(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::StartManualWorkflowTrigger { + workflow_name: workflow_name.clone(), + trigger_id: trigger_id.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + } else { + items.push(SelectionItem { + name: "Run Now".to_string(), + description: Some("This trigger is disabled.".to_string()), + selected_description: Some( + "Enable this trigger first, then run it from here.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + + items.push(SelectionItem { + name: if trigger.enabled { + "Disable Trigger".to_string() + } else { + "Enable Trigger".to_string() + }, + description: Some(if trigger.enabled { + "Prevent this trigger from starting until it is enabled again.".to_string() + } else { + "Allow this trigger to run again.".to_string() + }), + selected_description: Some( + "Toggle this trigger's enabled state in workflow.yaml.".to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::ToggleWorkflowTriggerEnabled { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: format!("Type: {}", workflow_trigger_kind_display(&trigger.kind)), + description: Some( + "Choose which trigger type this workflow entry should use.".to_string(), + ), + selected_description: Some( + "Open the trigger type picker, then choose the new trigger type." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: WorkflowControlsDestination::TriggerType { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + }, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Edit Trigger ID".to_string(), + description: Some("Rename this trigger id.".to_string()), + selected_description: Some( + "Open the trigger id in your external editor and save the updated value back into workflow.yaml." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowTriggerField { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + field: WorkflowTriggerEditableField::Id, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Edit Target Jobs".to_string(), + description: Some(count_label( + workflow + .triggers + .iter() + .find(|candidate| candidate.id == trigger.id) + .map_or(0, |candidate| candidate.jobs.len()), + "job", + )), + selected_description: Some( + "Open this trigger's `jobs` field in your external editor and save the YAML list back into the workflow file." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowTriggerField { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + field: WorkflowTriggerEditableField::Jobs, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + let parameter_item = workflow_trigger_parameter_item(workflow_path, &trigger); + items.push(parameter_item); + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow Trigger".to_string()), + subtitle: Some(format!( + "{} · {} · {}", + workflow.name, trigger.id, summary.filename + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search trigger actions".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Workflow Trigger", + "Failed to load workflow trigger.", + err, + workflow_back_item(WorkflowControlsDestination::Root), + initial_selected_idx, + ), + } + } + + fn workflow_trigger_type_popup_params( + &self, + workflow_path: &Path, + trigger_id: &str, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_trigger_state(self.config.cwd.as_path(), workflow_path, trigger_id) { + Ok((summary, workflow, trigger)) => { + let mut items = vec![workflow_back_item( + WorkflowControlsDestination::ManualTrigger { + workflow_path: workflow_path.to_path_buf(), + trigger_id: trigger.id.clone(), + }, + )]; + + for trigger_type in [ + WorkflowTriggerType::Manual, + WorkflowTriggerType::BeforeTurn, + WorkflowTriggerType::AfterTurn, + WorkflowTriggerType::FileWatch, + WorkflowTriggerType::Idle, + WorkflowTriggerType::Interval, + WorkflowTriggerType::Cron, + ] { + let is_active = workflow_trigger_matches_type(&trigger.kind, trigger_type); + items.push(SelectionItem { + name: workflow_trigger_type_label(trigger_type).to_string(), + description: Some(if is_active { + "Current type".to_string() + } else { + workflow_trigger_type_description(trigger_type).to_string() + }), + selected_description: Some( + "Write the selected trigger type back into workflow.yaml.".to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::SetWorkflowTriggerType { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + trigger_type, + }); + } + })], + dismiss_on_select: false, + is_disabled: is_active, + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Trigger Type".to_string()), + subtitle: Some(format!( + "{} · {} · {}", + workflow.name, trigger.id, summary.filename + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search trigger types".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Trigger Type", + "Failed to load workflow trigger type picker.", + err, + workflow_back_item(WorkflowControlsDestination::Root), + initial_selected_idx, + ), + } + } + + fn workflow_job_popup_params( + &self, + workflow_path: &Path, + job_name: &str, + initial_selected_idx: Option, + ) -> SelectionViewParams { + match workflow_loaded_job_state(self.config.cwd.as_path(), workflow_path, job_name) { + Ok((summary, workflow, job)) => { + let mut items = vec![ + workflow_back_item(WorkflowControlsDestination::Root), + workflow_edit_file_item( + workflow_path.to_path_buf(), + WorkflowControlsDestination::Job { + workflow_path: workflow_path.to_path_buf(), + job_name: job_name.to_string(), + }, + ), + ]; + + items.push(SelectionItem { + name: "Run Now".to_string(), + description: Some( + "Run this job immediately in a background workflow thread.".to_string(), + ), + selected_description: Some( + "Start this job now. Job `enabled` only affects trigger-driven runs; manual runs are always allowed." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_name = workflow.name.clone(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::StartManualWorkflowJob { + workflow_name: workflow_name.clone(), + job_name: job_name.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: if job.config.enabled { + "Disable Job".to_string() + } else { + "Enable Job".to_string() + }, + description: Some(if job.config.enabled { + "Prevent triggers from including this job until it is enabled again." + .to_string() + } else { + "Allow triggers to include this job again.".to_string() + }), + selected_description: Some( + "Toggle whether trigger-driven workflow runs may include this job." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::ToggleWorkflowJobEnabled { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: format!("Context: {}", workflow_context_label(job.config.context)), + description: Some( + "Toggle between embed and ephemeral execution context.".to_string(), + ), + selected_description: Some( + "Toggle this job's `context` field in workflow.yaml.".to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::CycleWorkflowJobContext { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: format!("Response: {}", workflow_response_label(job.config.response)), + description: Some( + "Toggle whether this job replies as assistant output or a user follow-up." + .to_string(), + ), + selected_description: Some( + "Toggle this job's `response` field in workflow.yaml.".to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::CycleWorkflowJobResponse { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Edit Needs".to_string(), + description: Some(count_label(job.config.needs.len(), "dependency")), + selected_description: Some( + "Open the `needs` field in your external editor and save the YAML list back into the workflow file." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowJobField { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + field: WorkflowJobEditableField::Needs, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + items.push(SelectionItem { + name: "Edit Steps".to_string(), + description: Some(count_label(job.config.steps.len(), "step")), + selected_description: Some( + "Open this job's `steps` field in your external editor and save the YAML back into the workflow file." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let job_name = job.name.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowJobField { + workflow_path: workflow_path.clone(), + job_name: job_name.clone(), + field: WorkflowJobEditableField::Steps, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + }); + + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some("Workflow Job".to_string()), + subtitle: Some(format!( + "{} · {} · {}", + workflow.name, job.name, summary.filename + )), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search job actions".to_string()), + initial_selected_idx, + ..Default::default() + } + } + Err(err) => workflow_error_popup_params( + "Workflow Job", + "Failed to load workflow job.", + err, + workflow_back_item(WorkflowControlsDestination::Root), + initial_selected_idx, + ), + } + } +} + +fn workflow_menu_state(cwd: &Path) -> Result { + let workflow_paths = workflow_editor::workflow_file_paths(cwd)?; + let registry = load_workflow_registry(cwd); + let registry_error = registry.as_ref().err().map(ToString::to_string); + let files = workflow_paths + .into_iter() + .map(|workflow_path| match registry.as_ref() { + Ok(registry) => { + if let Some(workflow) = registry + .files + .iter() + .find(|workflow| workflow.source_path == workflow_path) + { + let mut jobs = registry + .jobs + .values() + .filter(|job| job.workflow_path == workflow.source_path) + .cloned() + .collect::>(); + jobs.sort_by_key(|job| job.definition_index); + let triggers = workflow + .triggers + .iter() + .map(|trigger| WorkflowTriggerSummary { + id: trigger.id.clone(), + enabled: trigger.enabled, + kind: trigger.kind.clone(), + }) + .collect::>(); + WorkflowFileSummary { + workflow_path: workflow_path.clone(), + workflow_name: Some(workflow.name.clone()), + display_name: workflow.name.clone(), + filename: filename_label(&workflow_path), + job_count: jobs.len(), + jobs: jobs.into_iter().map(|job| job.name).collect(), + trigger_count: triggers.len(), + triggers, + } + } else { + fallback_workflow_summary(workflow_path) + } + } + Err(_) => fallback_workflow_summary(workflow_path), + }) + .collect::>(); + + Ok(WorkflowMenuState { + files, + registry_error, + }) +} + +fn workflow_file_view_state( + cwd: &Path, + workflow_path: &Path, +) -> Result { + let state = workflow_menu_state(cwd)?; + let summary = state + .files + .iter() + .find(|file| file.workflow_path == workflow_path) + .cloned() + .ok_or_else(|| format!("workflow file `{}` does not exist", workflow_path.display()))?; + Ok(WorkflowFileViewState { + summary, + registry_error: state.registry_error, + }) +} + +fn workflow_loaded_file_state( + cwd: &Path, + workflow_path: &Path, +) -> Result< + ( + WorkflowFileSummary, + LoadedWorkflowRegistry, + LoadedWorkflowFile, + ), + String, +> { + let summary = workflow_menu_state(cwd)? + .files + .into_iter() + .find(|file| file.workflow_path == workflow_path) + .ok_or_else(|| format!("workflow file `{}` does not exist", workflow_path.display()))?; + let registry = load_workflow_registry(cwd).map_err(|err| err.to_string())?; + let workflow = registry + .files + .iter() + .find(|workflow| workflow.source_path == workflow_path) + .cloned() + .ok_or_else(|| format!("workflow `{}` is not available", workflow_path.display()))?; + Ok((summary, registry, workflow)) +} + +fn workflow_loaded_job_state( + cwd: &Path, + workflow_path: &Path, + job_name: &str, +) -> Result<(WorkflowFileSummary, LoadedWorkflowFile, LoadedWorkflowJob), String> { + let (summary, registry, workflow) = workflow_loaded_file_state(cwd, workflow_path)?; + let job = registry + .jobs + .get(job_name) + .filter(|job| job.workflow_path == workflow.source_path) + .cloned() + .ok_or_else(|| format!("workflow job `{job_name}` does not exist"))?; + Ok((summary, workflow, job)) +} + +fn workflow_loaded_trigger_state( + cwd: &Path, + workflow_path: &Path, + trigger_id: &str, +) -> Result< + ( + WorkflowFileSummary, + LoadedWorkflowFile, + WorkflowTriggerSummary, + ), + String, +> { + let (summary, _registry, workflow) = workflow_loaded_file_state(cwd, workflow_path)?; + let trigger = workflow + .triggers + .iter() + .find(|trigger| trigger.id == trigger_id) + .map(|trigger| WorkflowTriggerSummary { + id: trigger.id.clone(), + enabled: trigger.enabled, + kind: trigger.kind.clone(), + }) + .ok_or_else(|| format!("workflow trigger `{trigger_id}` does not exist"))?; + Ok((summary, workflow, trigger)) +} + +fn workflow_trigger_parameter_item( + workflow_path: &Path, + trigger: &WorkflowTriggerSummary, +) -> SelectionItem { + let Some((label, description)) = workflow_trigger_parameter_metadata(&trigger.kind) else { + return SelectionItem { + name: "No Trigger Parameter".to_string(), + description: Some( + "This trigger type does not require an extra schedule parameter.".to_string(), + ), + selected_description: Some( + "Change the trigger type if you need a schedule parameter such as `after`, `every`, or `cron`." + .to_string(), + ), + is_disabled: true, + ..Default::default() + }; + }; + + SelectionItem { + name: format!("Edit {label}"), + description: Some(description), + selected_description: Some( + "Open this trigger parameter in your external editor and save it back into workflow.yaml." + .to_string(), + ), + actions: vec![Box::new({ + let workflow_path = workflow_path.to_path_buf(); + let trigger_id = trigger.id.clone(); + move |tx| { + tx.send(AppEvent::EditWorkflowTriggerField { + workflow_path: workflow_path.clone(), + trigger_id: trigger_id.clone(), + field: WorkflowTriggerEditableField::Parameter, + }); + } + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn fallback_workflow_summary(workflow_path: PathBuf) -> WorkflowFileSummary { + WorkflowFileSummary { + display_name: workflow_path + .file_stem() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| filename_label(&workflow_path)), + filename: filename_label(&workflow_path), + workflow_path, + workflow_name: None, + jobs: Vec::new(), + triggers: Vec::new(), + trigger_count: 0, + job_count: 0, + } +} + +fn workflow_back_item(destination: WorkflowControlsDestination) -> SelectionItem { + SelectionItem { + name: "Back".to_string(), + description: Some("Return to the previous workflow menu.".to_string()), + selected_description: Some("Return to the previous workflow menu.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorkflowControlView { + destination: destination.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn workflow_edit_file_item( + workflow_path: PathBuf, + reopen: WorkflowControlsDestination, +) -> SelectionItem { + SelectionItem { + name: "Edit workflow.yaml".to_string(), + description: Some(format!( + "Open {} in your editor.", + filename_label(&workflow_path) + )), + selected_description: Some( + "Open the real workflow YAML file in your external editor.".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::EditWorkflowFile { + workflow_path: workflow_path.clone(), + reopen: reopen.clone(), + }); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +fn workflow_error_popup_params( + title: &str, + subtitle: &str, + err: String, + back_item: SelectionItem, + initial_selected_idx: Option, +) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(WORKFLOW_CONTROLS_VIEW_ID), + title: Some(title.to_string()), + subtitle: Some(subtitle.to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + back_item, + SelectionItem { + name: "Workflow Error".to_string(), + description: Some(err), + selected_description: Some( + "Fix the workflow YAML, then reopen this menu.".to_string(), + ), + is_disabled: true, + ..Default::default() + }, + ], + is_searchable: true, + search_placeholder: Some("Type to search workflow actions".to_string()), + initial_selected_idx, + ..Default::default() + } +} + +fn workflow_status_summary(running_labels: &[String], queued_labels: &[String]) -> String { + format!( + "Running: {} · Queued: {}", + running_labels.len(), + queued_labels.len() + ) +} + +fn workflow_target_status( + label: &str, + running_set: &HashSet, + queued_set: &HashSet, +) -> &'static str { + if running_set.contains(label) { + "Running" + } else if queued_set.contains(label) { + "Queued" + } else { + "Ready" + } +} + +fn workflow_context_label(context: WorkflowContextMode) -> &'static str { + match context { + WorkflowContextMode::Embed => "Embed", + WorkflowContextMode::Ephemeral => "Ephemeral", + } +} + +fn workflow_trigger_type_label(trigger_type: WorkflowTriggerType) -> &'static str { + match trigger_type { + WorkflowTriggerType::Manual => "Manual", + WorkflowTriggerType::BeforeTurn => "Before Turn", + WorkflowTriggerType::AfterTurn => "After Turn", + WorkflowTriggerType::FileWatch => "File Watch", + WorkflowTriggerType::Idle => "Idle", + WorkflowTriggerType::Interval => "Interval", + WorkflowTriggerType::Cron => "Cron", + } +} + +fn workflow_trigger_kind_display(kind: &WorkflowTriggerKind) -> String { + match kind { + WorkflowTriggerKind::Manual => "Manual".to_string(), + WorkflowTriggerKind::BeforeTurn => "Before Turn".to_string(), + WorkflowTriggerKind::AfterTurn => "After Turn".to_string(), + WorkflowTriggerKind::FileWatch => "File Watch".to_string(), + WorkflowTriggerKind::Idle { after } => format!("Idle ({after})"), + WorkflowTriggerKind::Interval { every } => format!("Interval ({every})"), + WorkflowTriggerKind::Cron { cron } => format!("Cron ({cron})"), + } +} + +fn workflow_trigger_type_description(trigger_type: WorkflowTriggerType) -> &'static str { + match trigger_type { + WorkflowTriggerType::Manual => "Run only when triggered from the workflow menu.", + WorkflowTriggerType::BeforeTurn => "Run automatically before the next user turn.", + WorkflowTriggerType::AfterTurn => "Run automatically after the current turn finishes.", + WorkflowTriggerType::FileWatch => { + "Run automatically when workspace files change. Overlapping runs are skipped." + } + WorkflowTriggerType::Idle => "Run after the workspace has been idle for a duration.", + WorkflowTriggerType::Interval => "Run on a fixed repeating interval.", + WorkflowTriggerType::Cron => "Run on a cron schedule.", + } +} + +fn workflow_trigger_matches_type( + kind: &WorkflowTriggerKind, + trigger_type: WorkflowTriggerType, +) -> bool { + matches!( + (kind, trigger_type), + (&WorkflowTriggerKind::Manual, WorkflowTriggerType::Manual) + | ( + &WorkflowTriggerKind::BeforeTurn, + WorkflowTriggerType::BeforeTurn + ) + | ( + &WorkflowTriggerKind::AfterTurn, + WorkflowTriggerType::AfterTurn + ) + | ( + &WorkflowTriggerKind::FileWatch, + WorkflowTriggerType::FileWatch + ) + | (&WorkflowTriggerKind::Idle { .. }, WorkflowTriggerType::Idle) + | ( + &WorkflowTriggerKind::Interval { .. }, + WorkflowTriggerType::Interval + ) + | (&WorkflowTriggerKind::Cron { .. }, WorkflowTriggerType::Cron) + ) +} + +fn workflow_trigger_parameter_metadata( + kind: &WorkflowTriggerKind, +) -> Option<(&'static str, String)> { + match kind { + WorkflowTriggerKind::Idle { after } => { + Some(("Idle Delay", format!("Current `after`: `{after}`."))) + } + WorkflowTriggerKind::Interval { every } => { + Some(("Interval", format!("Current `every`: `{every}`."))) + } + WorkflowTriggerKind::Cron { cron } => { + Some(("Cron Schedule", format!("Current `cron`: `{cron}`."))) + } + WorkflowTriggerKind::Manual + | WorkflowTriggerKind::BeforeTurn + | WorkflowTriggerKind::AfterTurn + | WorkflowTriggerKind::FileWatch => None, + } +} + +fn workflow_response_label(response: WorkflowResponseMode) -> &'static str { + match response { + WorkflowResponseMode::Assistant => "Assistant", + WorkflowResponseMode::User => "User", + } +} + +fn workflow_job_field_label(field: WorkflowJobEditableField) -> &'static str { + match field { + WorkflowJobEditableField::Needs => "needs", + WorkflowJobEditableField::Steps => "steps", + } +} + +fn workflow_trigger_field_label(field: WorkflowTriggerEditableField) -> &'static str { + match field { + WorkflowTriggerEditableField::Id => "id", + WorkflowTriggerEditableField::Jobs => "jobs", + WorkflowTriggerEditableField::Parameter => "parameter", + } +} + +fn filename_label(path: &Path) -> String { + path.file_name() + .map(|filename| filename.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()) +} + +fn count_label(count: usize, noun: &str) -> String { + let suffix = if count == 1 { "" } else { "s" }; + format!("{count} {noun}{suffix}") +} + +struct WorkflowFileViewState { + summary: WorkflowFileSummary, + registry_error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chatwidget::tests::render_bottom_popup; + use crate::test_support::PathBufExt; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + fn write_test_manual_workflow(workspace_cwd: &Path) { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).unwrap(); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: manual + id: review_backlog + jobs: [summarize] + - type: manual + id: triage + enabled: false + jobs: [notify] + - type: interval + id: pulse + every: 30m + jobs: [summarize] + +jobs: + summarize: + context: embed + steps: + - prompt: | + summarize the backlog + notify: + context: ephemeral + response: user + needs: [summarize] + steps: + - prompt: | + send workflow update +"#, + ) + .unwrap(); + } + + fn write_test_disabled_job_workflow(workspace_cwd: &Path) { + let workflows_dir = workspace_cwd.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).unwrap(); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: manual + id: review_backlog + jobs: [notify] + +jobs: + notify: + enabled: false + context: ephemeral + response: user + steps: + - prompt: | + send workflow update +"#, + ) + .unwrap(); + } + + #[tokio::test] + async fn workflow_root_popup_shows_create_template_when_empty() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + + app.open_workflow_control_view(WorkflowControlsDestination::Root); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_root_popup_empty", popup); + } + + #[tokio::test] + async fn workflow_file_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::File { workflow_path }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_file_popup", popup); + } + + #[tokio::test] + async fn workflow_root_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + + app.open_workflow_control_view(WorkflowControlsDestination::Root); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_root_popup", popup); + } + + #[tokio::test] + async fn workflow_jobs_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::Jobs { workflow_path }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_jobs_popup", popup); + } + + #[tokio::test] + async fn workflow_job_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name: "notify".to_string(), + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_job_popup", popup); + } + + #[tokio::test] + async fn disabled_job_popup_still_offers_run_now() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_disabled_job_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::Job { + workflow_path, + job_name: "notify".to_string(), + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!(popup.contains("Run Now Run this job immediately")); + assert!(!popup.contains("This job is disabled.")); + } + + #[tokio::test] + async fn workflow_manual_triggers_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::ManualTriggers { + workflow_path, + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_manual_triggers_popup", popup); + } + + #[tokio::test] + async fn workflow_manual_trigger_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::ManualTrigger { + workflow_path, + trigger_id: "review_backlog".to_string(), + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_manual_trigger_popup", popup); + } + + #[tokio::test] + async fn workflow_trigger_type_popup_snapshot() { + let mut app = super::super::tests::make_test_app().await; + let dir = tempdir().unwrap(); + app.config.cwd = dir.path().to_path_buf().abs(); + write_test_manual_workflow(app.config.cwd.as_path()); + let workflow_path = app + .config + .cwd + .as_path() + .join(".codex/workflows/manual.yaml"); + + app.open_workflow_control_view(WorkflowControlsDestination::TriggerType { + workflow_path, + trigger_id: "pulse".to_string(), + }); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!("workflow_trigger_type_popup", popup); + } + + #[test] + fn workflow_menu_state_lists_files_even_when_registry_is_invalid() { + let dir = tempdir().unwrap(); + let workspace = dir.path().join("workspace"); + let workflows_dir = workspace.join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).unwrap(); + let path = workflows_dir.join("broken.yaml"); + std::fs::write(&path, "name: [").unwrap(); + + let state = workflow_menu_state(workspace.as_path()).unwrap(); + assert_eq!(state.files.len(), 1); + assert_eq!(state.files[0].filename, "broken.yaml"); + assert!(state.registry_error.is_some()); + } +} diff --git a/codex-rs/tui/src/app/workflow_definition.rs b/codex-rs/tui/src/app/workflow_definition.rs new file mode 100644 index 000000000..8485c8517 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_definition.rs @@ -0,0 +1,472 @@ +use indexmap::IndexMap; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::VecDeque; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +const WORKFLOW_DIR_NAME: &str = "workflows"; + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorkflowDefinitionError { + Io(String), + Invalid(String), +} + +impl std::fmt::Display for WorkflowDefinitionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(message) | Self::Invalid(message) => f.write_str(message), + } + } +} + +impl std::error::Error for WorkflowDefinitionError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WorkflowContextMode { + Embed, + #[default] + Ephemeral, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WorkflowResponseMode { + #[default] + Assistant, + User, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub(crate) enum WorkflowStep { + Run { + run: String, + retry: Option, + timeout: Option, + }, + Prompt { + prompt: String, + retry: Option, + timeout: Option, + }, +} + +impl WorkflowStep { + pub(crate) fn retry_attempts(&self) -> u32 { + match self { + Self::Run { retry, .. } | Self::Prompt { retry, .. } => retry.unwrap_or(1), + } + } + + pub(crate) fn timeout(&self, default_timeout: Duration) -> Result { + let timeout = match self { + Self::Run { timeout, .. } | Self::Prompt { timeout, .. } => timeout, + }; + timeout.as_ref().map_or(Ok(default_timeout), |timeout| { + humantime::parse_duration(timeout) + .map_err(|err| format!("invalid step timeout `{timeout}`: {err}")) + }) + } + + pub(crate) fn kind(&self) -> &'static str { + match self { + Self::Run { .. } => "run", + Self::Prompt { .. } => "prompt", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct WorkflowJobConfig { + #[serde(default = "default_true")] + pub(crate) enabled: bool, + #[serde(default)] + pub(crate) needs: Vec, + #[serde(default)] + pub(crate) context: WorkflowContextMode, + #[serde(default)] + pub(crate) response: WorkflowResponseMode, + #[serde(default)] + pub(crate) steps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct WorkflowFile { + name: String, + #[serde(default)] + triggers: Vec, + #[serde(default)] + jobs: IndexMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct WorkflowTriggerConfig { + #[serde(default)] + id: Option, + #[serde(default = "default_true")] + enabled: bool, + jobs: Vec, + #[serde(flatten)] + kind: WorkflowTriggerKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum WorkflowTriggerKind { + Manual, + BeforeTurn, + AfterTurn, + FileWatch, + Idle { after: String }, + Interval { every: String }, + Cron { cron: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedWorkflowRegistry { + pub(crate) files: Vec, + pub(crate) jobs: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedWorkflowFile { + pub(crate) name: String, + pub(crate) source_path: PathBuf, + pub(crate) triggers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedWorkflowTrigger { + pub(crate) id: String, + pub(crate) enabled: bool, + pub(crate) jobs: Vec, + pub(crate) kind: WorkflowTriggerKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedWorkflowJob { + pub(crate) name: String, + pub(crate) workflow_name: String, + pub(crate) workflow_path: PathBuf, + pub(crate) definition_index: usize, + pub(crate) config: WorkflowJobConfig, +} + +pub(crate) fn load_workflow_registry( + cwd: &Path, +) -> Result { + let workflow_dir = cwd.join(".codex").join(WORKFLOW_DIR_NAME); + if !workflow_dir.exists() { + return Ok(LoadedWorkflowRegistry { + files: Vec::new(), + jobs: BTreeMap::new(), + }); + } + + let mut files = fs::read_dir(&workflow_dir) + .map_err(|err| { + WorkflowDefinitionError::Io(format!( + "failed to read workflow directory `{}`: {err}", + workflow_dir.display() + )) + })? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "yaml") + }) + .collect::>(); + files.sort(); + + let mut workflow_names = BTreeSet::new(); + let mut loaded_files = Vec::new(); + let mut jobs = BTreeMap::new(); + let mut next_job_index = 0usize; + + for path in files { + let contents = fs::read_to_string(&path).map_err(|err| { + WorkflowDefinitionError::Io(format!( + "failed to read workflow file `{}`: {err}", + path.display() + )) + })?; + let file: WorkflowFile = serde_yaml::from_str(&contents).map_err(|err| { + WorkflowDefinitionError::Invalid(format!( + "failed to parse workflow file `{}`: {err}", + path.display() + )) + })?; + + let workflow_name = file.name.trim(); + if workflow_name.is_empty() { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow file `{}` must define a non-empty `name`", + path.display() + ))); + } + if !workflow_names.insert(workflow_name.to_string()) { + return Err(WorkflowDefinitionError::Invalid(format!( + "duplicate workflow name `{workflow_name}` detected in `{}`", + path.display() + ))); + } + + for (job_name, job) in &file.jobs { + let job_name = job_name.trim(); + if job_name.is_empty() { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` in `{}` contains an empty job name", + path.display() + ))); + } + if jobs.contains_key(job_name) { + return Err(WorkflowDefinitionError::Invalid(format!( + "duplicate job name `{job_name}` detected while loading `{}`", + path.display() + ))); + } + if job.steps.is_empty() { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` job `{job_name}` in `{}` must define at least one step", + path.display() + ))); + } + for step in &job.steps { + if let Err(err) = step.timeout(Duration::from_secs(30)) { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` job `{job_name}` in `{}` has invalid {} step timeout: {err}", + path.display(), + step.kind() + ))); + } + } + if matches!(job.context, WorkflowContextMode::Embed) + && job + .steps + .iter() + .any(|step| matches!(step, WorkflowStep::Run { .. })) + { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` job `{job_name}` in `{}` cannot use `run` steps when `context` is `embed`", + path.display() + ))); + } + jobs.insert( + job_name.to_string(), + LoadedWorkflowJob { + name: job_name.to_string(), + workflow_name: workflow_name.to_string(), + workflow_path: path.clone(), + definition_index: next_job_index, + config: job.clone(), + }, + ); + next_job_index = next_job_index.saturating_add(1); + } + + let mut trigger_ids = BTreeSet::new(); + let mut triggers = Vec::new(); + for (index, trigger) in file.triggers.iter().enumerate() { + if trigger.jobs.is_empty() { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` trigger #{index} in `{}` must reference at least one job", + path.display() + ))); + } + for job_name in &trigger.jobs { + if !file.jobs.contains_key(job_name) { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` trigger `{}` in `{}` references missing job `{job_name}`", + trigger.id.as_deref().unwrap_or(""), + path.display() + ))); + } + } + let trigger_id = trigger + .id + .clone() + .unwrap_or_else(|| format!("trigger-{}", index + 1)); + if !trigger_ids.insert(trigger_id.clone()) { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{workflow_name}` in `{}` contains duplicate trigger id `{trigger_id}`", + path.display() + ))); + } + triggers.push(LoadedWorkflowTrigger { + id: trigger_id, + enabled: trigger.enabled, + jobs: trigger.jobs.clone(), + kind: trigger.kind.clone(), + }); + } + + loaded_files.push(LoadedWorkflowFile { + name: workflow_name.to_string(), + source_path: path, + triggers, + }); + } + + for job in jobs.values() { + for dependency in &job.config.needs { + if !jobs.contains_key(dependency) { + return Err(WorkflowDefinitionError::Invalid(format!( + "workflow `{}` job `{}` references missing dependency `{dependency}`", + job.workflow_name, job.name + ))); + } + } + } + + Ok(LoadedWorkflowRegistry { + files: loaded_files, + jobs, + }) +} + +pub(crate) fn ordered_jobs_for_roots( + registry: &LoadedWorkflowRegistry, + root_jobs: &[String], +) -> Result, WorkflowDefinitionError> { + let mut reachable = BTreeSet::new(); + let mut stack = root_jobs.to_vec(); + while let Some(job_name) = stack.pop() { + let job = registry.jobs.get(&job_name).ok_or_else(|| { + WorkflowDefinitionError::Invalid(format!( + "workflow execution root references missing job `{job_name}`" + )) + })?; + if reachable.insert(job_name.clone()) { + stack.extend(job.config.needs.iter().cloned()); + } + } + + let mut indegree = reachable + .iter() + .map(|job_name| (job_name.clone(), 0usize)) + .collect::>(); + let mut dependents = BTreeMap::>::new(); + for job_name in &reachable { + let Some(job) = registry.jobs.get(job_name) else { + return Err(WorkflowDefinitionError::Invalid(format!( + "reachable workflow job `{job_name}` is missing from registry" + ))); + }; + for dependency in &job.config.needs { + if !reachable.contains(dependency) { + continue; + } + *indegree.entry(job_name.clone()).or_default() += 1; + dependents + .entry(dependency.clone()) + .or_default() + .push(job_name.clone()); + } + } + + let mut ready = reachable + .iter() + .filter(|job_name| indegree.get(*job_name) == Some(&0)) + .cloned() + .collect::>(); + let mut ordered = Vec::new(); + while let Some(job_name) = pop_next_job(&mut ready, registry) { + ordered.push(job_name.clone()); + for dependent in dependents.get(&job_name).into_iter().flatten() { + if let Some(entry) = indegree.get_mut(dependent) { + *entry = entry.saturating_sub(1); + if *entry == 0 { + ready.push_back(dependent.clone()); + } + } + } + } + + if ordered.len() != reachable.len() { + return Err(WorkflowDefinitionError::Invalid( + "workflow job selection contains a cyclic dependency graph".to_string(), + )); + } + + Ok(ordered) +} + +fn pop_next_job(ready: &mut VecDeque, registry: &LoadedWorkflowRegistry) -> Option { + let best_index = ready + .iter() + .enumerate() + .filter_map(|(index, job_name)| { + registry + .jobs + .get(job_name) + .map(|job| (index, job.definition_index)) + }) + .min_by_key(|(_, definition_index)| *definition_index) + .map(|(index, _)| index)?; + ready.remove(best_index) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn workflow_step_timeout_uses_default_when_unset() { + let step = WorkflowStep::Prompt { + prompt: "summarize".to_string(), + retry: None, + timeout: None, + }; + + assert_eq!( + step.timeout(Duration::from_secs(30)).unwrap(), + Duration::from_secs(30) + ); + } + + #[test] + fn load_workflow_registry_rejects_invalid_step_timeout() { + let dir = tempdir().unwrap(); + let workflows_dir = dir.path().join(".codex/workflows"); + fs::create_dir_all(&workflows_dir).unwrap(); + let workflow_path = workflows_dir.join("workflow.yaml"); + fs::write( + &workflow_path, + r#"name: director + +jobs: + notify: + steps: + - prompt: summarize the changes + timeout: not-a-duration +"#, + ) + .unwrap(); + + let error = load_workflow_registry(dir.path()).unwrap_err(); + let message = error.to_string(); + assert!( + message.contains(&format!( + "workflow `director` job `notify` in `{}` has invalid prompt step timeout", + workflow_path.display() + )), + "unexpected error message: {message}" + ); + assert!( + message.contains("invalid step timeout `not-a-duration`"), + "unexpected error message: {message}" + ); + } +} diff --git a/codex-rs/tui/src/app/workflow_editor.rs b/codex-rs/tui/src/app/workflow_editor.rs new file mode 100644 index 000000000..c08216215 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_editor.rs @@ -0,0 +1,720 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use serde_yaml::Mapping; +use serde_yaml::Value as YamlValue; + +use super::workflow_definition::WorkflowContextMode; +use super::workflow_definition::WorkflowResponseMode; +use super::workflow_definition::WorkflowStep; +use super::workflow_yaml::serialize_yaml_value; +use crate::app_event::WorkflowJobEditableField; +use crate::app_event::WorkflowTriggerEditableField; +use crate::app_event::WorkflowTriggerType; + +pub(crate) const DEFAULT_WORKFLOW_TEMPLATE_FILENAME: &str = "workflow.yaml"; + +const WORKFLOW_DIR_NAME: &str = "workflows"; +const DEFAULT_WORKFLOW_TEMPLATE: &str = r#"name: sample-workflow + +triggers: + - type: manual + id: run_now + jobs: [main] + +jobs: + main: + context: ephemeral + response: assistant + steps: + - prompt: | + Describe the work this workflow should do. +"#; + +pub(crate) fn workflow_file_paths(cwd: &Path) -> Result, String> { + let workflow_dir = workflow_dir(cwd); + if !workflow_dir.exists() { + return Ok(Vec::new()); + } + + let mut files = fs::read_dir(&workflow_dir) + .map_err(|err| { + format!( + "failed to read workflow directory `{}`: {err}", + workflow_dir.display() + ) + })? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "yaml") + }) + .collect::>(); + files.sort(); + Ok(files) +} + +pub(crate) fn create_default_workflow_template(cwd: &Path) -> Result { + let workflow_dir = workflow_dir(cwd); + fs::create_dir_all(&workflow_dir).map_err(|err| { + format!( + "failed to create workflow directory `{}`: {err}", + workflow_dir.display() + ) + })?; + + let mut candidate = workflow_dir.join(DEFAULT_WORKFLOW_TEMPLATE_FILENAME); + if candidate.exists() { + let mut suffix = 2_u32; + loop { + let next = workflow_dir.join(format!("workflow-{suffix}.yaml")); + if !next.exists() { + candidate = next; + break; + } + suffix = suffix.saturating_add(1); + } + } + + fs::write(&candidate, DEFAULT_WORKFLOW_TEMPLATE).map_err(|err| { + format!( + "failed to write workflow template `{}`: {err}", + candidate.display() + ) + })?; + Ok(candidate) +} + +pub(crate) fn toggle_job_enabled(workflow_path: &Path, job_name: &str) -> Result { + mutate_job(workflow_path, job_name, |job| { + let enabled = job + .get(string_key("enabled")) + .and_then(|value| serde_yaml::from_value::(value.clone()).ok()) + .unwrap_or(true); + let next_enabled = !enabled; + job.insert(string_key("enabled"), YamlValue::Bool(next_enabled)); + Ok(next_enabled) + }) +} + +pub(crate) fn toggle_trigger_enabled( + workflow_path: &Path, + trigger_id: &str, +) -> Result { + mutate_trigger(workflow_path, trigger_id, |trigger| { + let enabled = trigger + .get(string_key("enabled")) + .and_then(|value| serde_yaml::from_value::(value.clone()).ok()) + .unwrap_or(true); + let next_enabled = !enabled; + trigger.insert(string_key("enabled"), YamlValue::Bool(next_enabled)); + Ok(next_enabled) + }) +} + +pub(crate) fn set_trigger_type( + workflow_path: &Path, + trigger_id: &str, + trigger_type: WorkflowTriggerType, +) -> Result { + mutate_trigger(workflow_path, trigger_id, |trigger| { + let current_parameter = trigger_parameter_seed_from_mapping(trigger); + clear_trigger_type_fields(trigger); + trigger.insert( + string_key("type"), + YamlValue::String(trigger_type_key(trigger_type).to_string()), + ); + if let Some((parameter_key, default_value)) = trigger_type_parameter_defaults(trigger_type) + { + trigger.insert( + string_key(parameter_key), + YamlValue::String(current_parameter.unwrap_or_else(|| default_value.to_string())), + ); + } + Ok(trigger_id.to_string()) + }) +} + +pub(crate) fn cycle_job_context( + workflow_path: &Path, + job_name: &str, +) -> Result { + mutate_job(workflow_path, job_name, |job| { + let current = job + .get(string_key("context")) + .and_then(|value| serde_yaml::from_value::(value.clone()).ok()) + .unwrap_or_default(); + let next = match current { + WorkflowContextMode::Embed => WorkflowContextMode::Ephemeral, + WorkflowContextMode::Ephemeral => WorkflowContextMode::Embed, + }; + job.insert( + string_key("context"), + serde_yaml::to_value(next).map_err(|err| err.to_string())?, + ); + Ok(next) + }) +} + +pub(crate) fn cycle_job_response( + workflow_path: &Path, + job_name: &str, +) -> Result { + mutate_job(workflow_path, job_name, |job| { + let current = job + .get(string_key("response")) + .and_then(|value| serde_yaml::from_value::(value.clone()).ok()) + .unwrap_or_default(); + let next = match current { + WorkflowResponseMode::Assistant => WorkflowResponseMode::User, + WorkflowResponseMode::User => WorkflowResponseMode::Assistant, + }; + job.insert( + string_key("response"), + serde_yaml::to_value(next).map_err(|err| err.to_string())?, + ); + Ok(next) + }) +} + +pub(crate) fn job_field_seed( + workflow_path: &Path, + job_name: &str, + field: WorkflowJobEditableField, +) -> Result { + let document = load_yaml_document(workflow_path)?; + let job = workflow_job_mapping(&document, job_name)?; + match field { + WorkflowJobEditableField::Needs => { + let needs = job + .get(string_key("needs")) + .map(|value| serde_yaml::from_value::>(value.clone())) + .transpose() + .map_err(|err| err.to_string())? + .unwrap_or_default(); + serialize_yaml_fragment(&needs) + } + WorkflowJobEditableField::Steps => { + let steps = job + .get(string_key("steps")) + .map(|value| serde_yaml::from_value::>(value.clone())) + .transpose() + .map_err(|err| err.to_string())? + .unwrap_or_default(); + serialize_yaml_fragment(&steps) + } + } +} + +pub(crate) fn write_job_field( + workflow_path: &Path, + job_name: &str, + field: WorkflowJobEditableField, + text: &str, +) -> Result<(), String> { + match field { + WorkflowJobEditableField::Needs => { + let needs = match text.trim() { + "" => Vec::new(), + _ => serde_yaml::from_str::>(text).map_err(|err| err.to_string())?, + }; + mutate_job(workflow_path, job_name, |job| { + job.insert( + string_key("needs"), + serde_yaml::to_value(needs).map_err(|err| err.to_string())?, + ); + Ok(()) + }) + } + WorkflowJobEditableField::Steps => { + let steps = match text.trim() { + "" => Vec::new(), + _ => serde_yaml::from_str::>(text) + .map_err(|err| err.to_string())?, + }; + mutate_job(workflow_path, job_name, |job| { + job.insert( + string_key("steps"), + serde_yaml::to_value(steps).map_err(|err| err.to_string())?, + ); + Ok(()) + }) + } + } +} + +pub(crate) fn trigger_field_seed( + workflow_path: &Path, + trigger_id: &str, + field: WorkflowTriggerEditableField, +) -> Result { + let document = load_yaml_document(workflow_path)?; + let trigger = workflow_trigger_mapping(&document, trigger_id)?; + match field { + WorkflowTriggerEditableField::Id => trigger + .get(string_key("id")) + .and_then(YamlValue::as_str) + .map(ToString::to_string) + .ok_or_else(|| format!("workflow trigger `{trigger_id}` does not define an `id`")), + WorkflowTriggerEditableField::Jobs => { + let jobs = trigger + .get(string_key("jobs")) + .map(|value| serde_yaml::from_value::>(value.clone())) + .transpose() + .map_err(|err| err.to_string())? + .unwrap_or_default(); + serialize_yaml_fragment(&jobs) + } + WorkflowTriggerEditableField::Parameter => trigger_parameter_seed_from_mapping(trigger) + .ok_or_else(|| format!("workflow trigger `{trigger_id}` has no editable parameter")), + } +} + +pub(crate) fn write_trigger_field( + workflow_path: &Path, + trigger_id: &str, + field: WorkflowTriggerEditableField, + text: &str, +) -> Result { + match field { + WorkflowTriggerEditableField::Id => { + let next_trigger_id = text.trim(); + if next_trigger_id.is_empty() { + return Err("workflow trigger id cannot be empty".to_string()); + } + mutate_trigger(workflow_path, trigger_id, |trigger| { + trigger.insert( + string_key("id"), + YamlValue::String(next_trigger_id.to_string()), + ); + Ok(next_trigger_id.to_string()) + }) + } + WorkflowTriggerEditableField::Jobs => { + let jobs = match text.trim() { + "" => Vec::new(), + _ => serde_yaml::from_str::>(text).map_err(|err| err.to_string())?, + }; + mutate_trigger(workflow_path, trigger_id, |trigger| { + trigger.insert( + string_key("jobs"), + serde_yaml::to_value(jobs).map_err(|err| err.to_string())?, + ); + Ok(trigger_id.to_string()) + }) + } + WorkflowTriggerEditableField::Parameter => { + let next_value = text.trim(); + if next_value.is_empty() { + return Err("workflow trigger parameter cannot be empty".to_string()); + } + mutate_trigger(workflow_path, trigger_id, |trigger| { + let Some(parameter_key) = trigger_parameter_key_from_mapping(trigger) else { + return Err(format!( + "workflow trigger `{trigger_id}` has no editable parameter" + )); + }; + trigger.insert( + string_key(parameter_key), + YamlValue::String(next_value.to_string()), + ); + Ok(trigger_id.to_string()) + }) + } + } +} + +fn workflow_dir(cwd: &Path) -> PathBuf { + cwd.join(".codex").join(WORKFLOW_DIR_NAME) +} + +fn load_yaml_document(workflow_path: &Path) -> Result { + let contents = fs::read_to_string(workflow_path).map_err(|err| { + format!( + "failed to read workflow file `{}`: {err}", + workflow_path.display() + ) + })?; + serde_yaml::from_str(&contents).map_err(|err| { + format!( + "failed to parse workflow file `{}`: {err}", + workflow_path.display() + ) + }) +} + +fn save_yaml_document(workflow_path: &Path, document: &YamlValue) -> Result<(), String> { + let contents = serialize_yaml_value(document).map_err(|err| { + format!( + "failed to serialize workflow file `{}`: {err}", + workflow_path.display() + ) + })?; + fs::write(workflow_path, contents).map_err(|err| { + format!( + "failed to write workflow file `{}`: {err}", + workflow_path.display() + ) + }) +} + +fn workflow_job_mapping<'a>( + document: &'a YamlValue, + job_name: &str, +) -> Result<&'a Mapping, String> { + let document = document + .as_mapping() + .ok_or_else(|| "workflow file root must be a YAML mapping".to_string())?; + let jobs = document + .get(string_key("jobs")) + .and_then(YamlValue::as_mapping) + .ok_or_else(|| "workflow file does not define a `jobs` mapping".to_string())?; + jobs.get(string_key(job_name)) + .and_then(YamlValue::as_mapping) + .ok_or_else(|| format!("workflow job `{job_name}` does not exist")) +} + +fn workflow_job_mapping_mut<'a>( + document: &'a mut YamlValue, + job_name: &str, +) -> Result<&'a mut Mapping, String> { + let document = document + .as_mapping_mut() + .ok_or_else(|| "workflow file root must be a YAML mapping".to_string())?; + let jobs = document + .get_mut(string_key("jobs")) + .and_then(YamlValue::as_mapping_mut) + .ok_or_else(|| "workflow file does not define a `jobs` mapping".to_string())?; + jobs.get_mut(string_key(job_name)) + .and_then(YamlValue::as_mapping_mut) + .ok_or_else(|| format!("workflow job `{job_name}` does not exist")) +} + +fn workflow_trigger_mapping<'a>( + document: &'a YamlValue, + trigger_id: &str, +) -> Result<&'a Mapping, String> { + let document = document + .as_mapping() + .ok_or_else(|| "workflow file root must be a YAML mapping".to_string())?; + let triggers = document + .get(string_key("triggers")) + .and_then(YamlValue::as_sequence) + .ok_or_else(|| "workflow file does not define a `triggers` sequence".to_string())?; + + for (index, trigger) in triggers.iter().enumerate() { + let Some(trigger_mapping) = trigger.as_mapping() else { + continue; + }; + let candidate_id = trigger_mapping + .get(string_key("id")) + .and_then(YamlValue::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| format!("trigger-{}", index + 1)); + if candidate_id == trigger_id { + return Ok(trigger_mapping); + } + } + + Err(format!("workflow trigger `{trigger_id}` does not exist")) +} + +fn workflow_trigger_mapping_mut<'a>( + document: &'a mut YamlValue, + trigger_id: &str, +) -> Result<&'a mut Mapping, String> { + let document = document + .as_mapping_mut() + .ok_or_else(|| "workflow file root must be a YAML mapping".to_string())?; + let triggers = document + .get_mut(string_key("triggers")) + .and_then(YamlValue::as_sequence_mut) + .ok_or_else(|| "workflow file does not define a `triggers` sequence".to_string())?; + + for (index, trigger) in triggers.iter_mut().enumerate() { + let Some(trigger_mapping) = trigger.as_mapping_mut() else { + continue; + }; + let candidate_id = trigger_mapping + .get(string_key("id")) + .and_then(YamlValue::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| format!("trigger-{}", index + 1)); + if candidate_id == trigger_id { + return Ok(trigger_mapping); + } + } + + Err(format!("workflow trigger `{trigger_id}` does not exist")) +} + +fn mutate_job( + workflow_path: &Path, + job_name: &str, + mutator: impl FnOnce(&mut Mapping) -> Result, +) -> Result { + let mut document = load_yaml_document(workflow_path)?; + let result = mutator(workflow_job_mapping_mut(&mut document, job_name)?)?; + save_yaml_document(workflow_path, &document)?; + Ok(result) +} + +fn mutate_trigger( + workflow_path: &Path, + trigger_id: &str, + mutator: impl FnOnce(&mut Mapping) -> Result, +) -> Result { + let mut document = load_yaml_document(workflow_path)?; + let result = mutator(workflow_trigger_mapping_mut(&mut document, trigger_id)?)?; + save_yaml_document(workflow_path, &document)?; + Ok(result) +} + +fn serialize_yaml_fragment(value: &impl serde::Serialize) -> Result { + let value = serde_yaml::to_value(value).map_err(|err| err.to_string())?; + let text = serialize_yaml_value(&value)?; + Ok(text.trim_end().to_string()) +} + +fn clear_trigger_type_fields(trigger: &mut Mapping) { + for key in ["type", "after", "every", "cron"] { + trigger.remove(string_key(key)); + } +} + +fn trigger_type_key(trigger_type: WorkflowTriggerType) -> &'static str { + match trigger_type { + WorkflowTriggerType::Manual => "manual", + WorkflowTriggerType::BeforeTurn => "before_turn", + WorkflowTriggerType::AfterTurn => "after_turn", + WorkflowTriggerType::FileWatch => "file_watch", + WorkflowTriggerType::Idle => "idle", + WorkflowTriggerType::Interval => "interval", + WorkflowTriggerType::Cron => "cron", + } +} + +fn trigger_type_parameter_defaults( + trigger_type: WorkflowTriggerType, +) -> Option<(&'static str, &'static str)> { + match trigger_type { + WorkflowTriggerType::Idle => Some(("after", "5m")), + WorkflowTriggerType::Interval => Some(("every", "5m")), + WorkflowTriggerType::Cron => Some(("cron", "0 * * * *")), + WorkflowTriggerType::Manual + | WorkflowTriggerType::BeforeTurn + | WorkflowTriggerType::AfterTurn + | WorkflowTriggerType::FileWatch => None, + } +} + +fn trigger_parameter_seed_from_mapping(trigger: &Mapping) -> Option { + let parameter_key = trigger_parameter_key_from_mapping(trigger)?; + trigger + .get(string_key(parameter_key)) + .and_then(YamlValue::as_str) + .map(ToString::to_string) +} + +fn trigger_parameter_key_from_mapping(trigger: &Mapping) -> Option<&'static str> { + match trigger.get(string_key("type")).and_then(YamlValue::as_str) { + Some("idle") => Some("after"), + Some("interval") => Some("every"), + Some("cron") => Some("cron"), + Some("manual" | "before_turn" | "after_turn" | "file_watch") | None => None, + Some(_) => None, + } +} + +fn string_key(value: &str) -> YamlValue { + YamlValue::String(value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + fn write_workflow(path: &Path) { + fs::create_dir_all(path.parent().expect("parent")).unwrap(); + fs::write( + path, + r#"name: director + +triggers: + - type: manual + id: review + jobs: [notify] + - type: interval + id: pulse + every: 30m + jobs: [notify] + +jobs: + notify: + response: assistant + steps: + - prompt: | + send an update +"#, + ) + .unwrap(); + } + + #[test] + fn create_default_workflow_template_writes_new_file() { + let dir = tempdir().unwrap(); + let created = create_default_workflow_template(dir.path()).unwrap(); + + assert_eq!( + created.file_name().unwrap().to_string_lossy(), + DEFAULT_WORKFLOW_TEMPLATE_FILENAME + ); + let text = fs::read_to_string(created).unwrap(); + assert!(text.contains("sample-workflow")); + assert!(text.contains("run_now")); + } + + #[test] + fn toggle_job_enabled_writes_explicit_bool() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + let enabled = toggle_job_enabled(&path, "notify").unwrap(); + assert!(!enabled); + let disabled = fs::read_to_string(&path).unwrap(); + assert!(disabled.contains("enabled: false")); + + let enabled_again = toggle_job_enabled(&path, "notify").unwrap(); + assert!(enabled_again); + let enabled_text = fs::read_to_string(&path).unwrap(); + assert!(enabled_text.contains("enabled: true")); + assert!(enabled_text.contains("prompt: |")); + assert!(enabled_text.contains("send an update")); + } + + #[test] + fn toggle_trigger_enabled_writes_explicit_bool() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + let enabled = toggle_trigger_enabled(&path, "review").unwrap(); + assert!(!enabled); + let disabled = fs::read_to_string(&path).unwrap(); + assert!(disabled.contains("enabled: false")); + + let enabled_again = toggle_trigger_enabled(&path, "review").unwrap(); + assert!(enabled_again); + let enabled_text = fs::read_to_string(&path).unwrap(); + assert!(enabled_text.contains("enabled: true")); + } + + #[test] + fn cycle_job_context_and_response_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + assert_eq!( + cycle_job_context(&path, "notify").unwrap(), + WorkflowContextMode::Embed + ); + assert_eq!( + cycle_job_response(&path, "notify").unwrap(), + WorkflowResponseMode::User + ); + } + + #[test] + fn edit_needs_and_steps_fields_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + write_job_field( + &path, + "notify", + WorkflowJobEditableField::Needs, + "- prepare\n- publish\n", + ) + .unwrap(); + write_job_field( + &path, + "notify", + WorkflowJobEditableField::Steps, + r#"- prompt: | + summarize the changes + timeout: 5m +- run: cargo test -p codex-tui + timeout: 2m +"#, + ) + .unwrap(); + + assert_eq!( + job_field_seed(&path, "notify", WorkflowJobEditableField::Needs).unwrap(), + "- prepare\n- publish" + ); + let steps = job_field_seed(&path, "notify", WorkflowJobEditableField::Steps).unwrap(); + assert!(steps.contains("prompt: |")); + assert!(steps.contains("summarize the changes")); + assert!(steps.contains("timeout: 5m")); + assert!(steps.contains("cargo test -p codex-tui")); + assert!(steps.contains("timeout: 2m")); + } + + #[test] + fn edit_trigger_fields_and_type_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".codex/workflows/workflow.yaml"); + write_workflow(&path); + + assert_eq!( + trigger_field_seed(&path, "review", WorkflowTriggerEditableField::Id).unwrap(), + "review" + ); + assert_eq!( + trigger_field_seed(&path, "pulse", WorkflowTriggerEditableField::Parameter).unwrap(), + "30m" + ); + + let next_trigger_id = write_trigger_field( + &path, + "review", + WorkflowTriggerEditableField::Id, + "review_now", + ) + .unwrap(); + assert_eq!(next_trigger_id, "review_now"); + write_trigger_field( + &path, + "review_now", + WorkflowTriggerEditableField::Jobs, + "- notify\n- review_now\n", + ) + .unwrap(); + set_trigger_type(&path, "review_now", WorkflowTriggerType::Cron).unwrap(); + write_trigger_field( + &path, + "review_now", + WorkflowTriggerEditableField::Parameter, + "*/15 * * * *", + ) + .unwrap(); + + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("id: review_now")); + assert!(text.contains("type: cron")); + assert!(text.contains("cron: '*/15 * * * *'") || text.contains("cron: \"*/15 * * * *\"")); + assert!(text.contains("- review_now")); + + set_trigger_type(&path, "review_now", WorkflowTriggerType::FileWatch).unwrap(); + let file_watch_text = fs::read_to_string(&path).unwrap(); + assert!(file_watch_text.contains("type: file_watch")); + assert!(!file_watch_text.contains("cron:")); + } +} diff --git a/codex-rs/tui/src/app/workflow_file_watch.rs b/codex-rs/tui/src/app/workflow_file_watch.rs new file mode 100644 index 000000000..2f5c33d0b --- /dev/null +++ b/codex-rs/tui/src/app/workflow_file_watch.rs @@ -0,0 +1,91 @@ +use std::path::Path; +use std::path::PathBuf; + +use notify::Event; +use notify::EventKind; +use notify::RecommendedWatcher; +use notify::RecursiveMode; +use notify::Watcher; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +pub(crate) struct WorkflowFileWatchState { + _watcher: RecommendedWatcher, +} + +impl WorkflowFileWatchState { + pub(crate) fn new(cwd: &Path, app_event_tx: AppEventSender) -> Result { + let mut watcher = + notify::recommended_watcher(move |result: notify::Result| match result { + Ok(event) => { + if !should_forward_event(&event) { + return; + } + let changed_paths = event + .paths + .into_iter() + .filter(|path| path.is_absolute()) + .collect::>(); + if changed_paths.is_empty() { + return; + } + app_event_tx.send(AppEvent::WorkflowWorkspaceFilesChanged { changed_paths }); + } + Err(err) => { + tracing::warn!("workflow file watcher event failed: {err}"); + } + }) + .map_err(|err| format!("failed to create watcher: {err}"))?; + watcher + .watch(cwd, RecursiveMode::Recursive) + .map_err(|err| format!("failed to watch `{}`: {err}", cwd.display()))?; + Ok(Self { _watcher: watcher }) + } +} + +fn should_forward_event(event: &Event) -> bool { + matches!( + event.kind, + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any + ) +} + +pub(crate) fn is_relevant_workspace_change(cwd: &Path, path: &Path) -> bool { + if !path.is_absolute() { + return false; + } + let Ok(relative_path) = path.strip_prefix(cwd) else { + return false; + }; + if relative_path.as_os_str().is_empty() { + return true; + } + !relative_path.starts_with(".git") && !relative_path.starts_with(".codex") +} + +#[cfg(test)] +mod tests { + use super::is_relevant_workspace_change; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn relevant_workspace_change_accepts_regular_file_and_directory_paths() { + let tempdir = tempdir().expect("tempdir"); + let cwd = tempdir.path(); + let file_path = cwd.join("src/main.rs"); + let directory_path = cwd.join("src"); + + assert_eq!(is_relevant_workspace_change(cwd, &file_path), true); + assert_eq!(is_relevant_workspace_change(cwd, &directory_path), true); + assert_eq!( + is_relevant_workspace_change(cwd, cwd.join(".git/index").as_path()), + false, + ); + assert_eq!( + is_relevant_workspace_change(cwd, cwd.join(".codex/workflows/test.yaml").as_path()), + false, + ); + } +} diff --git a/codex-rs/tui/src/app/workflow_history.rs b/codex-rs/tui/src/app/workflow_history.rs new file mode 100644 index 000000000..c89f825f6 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_history.rs @@ -0,0 +1,404 @@ +use super::App; +use super::workflow_runtime::WorkflowOutputDelivery; +use super::workflow_runtime::WorkflowPhaseContext; +use crate::app::workflow_definition::WorkflowTriggerKind; +use crate::app::workflow_definition::load_workflow_registry; +use crate::app_command::AppCommand; +use crate::app_command::AppCommandView; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::history_cell; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::markdown::append_markdown; +use codex_protocol::ThreadId; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use ratatui::text::Line; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +#[derive(Default)] +pub(crate) struct WorkflowHistoryState { + pub(super) thread_history_cells: HashMap>>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct WorkflowReplySource { + workflow_id: String, + action: Option, +} + +impl WorkflowReplySource { + pub(crate) fn new(workflow_id: String, action: Option) -> Self { + Self { + workflow_id, + action, + } + } + + pub(crate) fn hint(&self) -> String { + match self + .action + .as_deref() + .map(str::trim) + .filter(|action| !action.is_empty()) + { + Some(action) => format!("{} · {}", self.workflow_id, workflow_prompt_prefix(action)), + None => self.workflow_id.clone(), + } + } +} + +impl App { + pub(crate) async fn augment_primary_thread_op_with_before_turn_workflows( + &mut self, + app_server: &AppServerSession, + thread_id: ThreadId, + op: AppCommand, + ) -> (AppCommand, Vec>) { + if self.primary_thread_id != Some(thread_id) + || !matches!(op.view(), AppCommandView::UserTurn { .. }) + { + return (op, Vec::new()); + } + + let Op::UserTurn { + mut items, + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + model, + effort, + summary, + service_tier, + final_output_json_schema, + collaboration_mode, + personality, + } = op.into_core() + else { + unreachable!("UserTurn view should match UserTurn core op"); + }; + + let current_user_turn = user_text_from_inputs(&items); + if current_user_turn.trim().is_empty() { + return ( + AppCommand::from_core(Op::UserTurn { + items, + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + model, + effort, + summary, + service_tier, + final_output_json_schema, + collaboration_mode, + personality, + }), + Vec::new(), + ); + } + + let mut cells: Vec> = Vec::new(); + match self + .run_before_turn_workflows( + app_server, + WorkflowPhaseContext { + current_user_turn: Some(current_user_turn.as_str()), + last_assistant_message: None, + }, + ) + .await + { + Ok(results) => { + for result in results { + let source = WorkflowReplySource::new( + workflow_job_source_hint(&result), + /*action*/ None, + ); + cells.push(Arc::new(history_cell::new_info_event( + "Workflow job completed".to_string(), + Some(source.hint()), + ))); + let Some(message) = result.message.filter(|message| !message.trim().is_empty()) + else { + continue; + }; + let message = message.trim().to_string(); + match result.delivery { + WorkflowOutputDelivery::AssistantCell => { + cells.push(Arc::new(workflow_result_cell( + &message, + self.config.cwd.as_path(), + ))); + } + WorkflowOutputDelivery::MainThreadInput + | WorkflowOutputDelivery::UserFollowup => { + items.push(UserInput::Text { + text: message, + text_elements: Vec::new(), + }); + } + } + } + } + Err(error) => cells.push(Arc::new(history_cell::new_error_event(format!( + "Workflow before_turn failed: {error}" + )))), + } + + ( + AppCommand::from_core(Op::UserTurn { + items, + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + model, + effort, + summary, + service_tier, + final_output_json_schema, + collaboration_mode, + personality, + }), + cells, + ) + } + + pub(crate) fn queue_workflow_history_replay_for_thread(&self, thread_id: ThreadId) { + if self + .workflow_history + .thread_history_cells + .contains_key(&thread_id) + { + self.app_event_tx + .send(AppEvent::ReplayWorkflowHistory { thread_id }); + } + } + + pub(crate) fn replay_workflow_history_cells_for_thread( + &mut self, + thread_id: ThreadId, + width: u16, + ) -> Vec> { + let Some(cells) = self.workflow_history.thread_history_cells.get(&thread_id) else { + return Vec::new(); + }; + + let mut rendered = Vec::new(); + for cell in cells { + self.transcript_cells.push(cell.clone()); + let mut display = cell.display_lines(width); + if display.is_empty() { + continue; + } + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::default()); + } else { + self.has_emitted_history_lines = true; + } + } + rendered.extend(display); + } + rendered + } + + pub(crate) fn record_workflow_history_cell( + &mut self, + thread_id: ThreadId, + cell: Arc, + ) -> Option> { + self.workflow_history + .thread_history_cells + .entry(thread_id) + .or_default() + .push(cell.clone()); + (self.active_thread_id == Some(thread_id)).then_some(cell) + } + + pub(crate) fn queue_workflow_followup_to_primary( + &mut self, + text: String, + source: WorkflowReplySource, + ) -> Option> { + let Some(primary_thread_id) = self.primary_thread_id else { + self.chat_widget.add_error_message( + "Failed to find the main thread for background follow-up.".to_string(), + ); + return None; + }; + + let trimmed = text.trim().to_string(); + if trimmed.is_empty() { + return None; + } + + let origin_cell: Arc = Arc::new(workflow_info_cell(&source)); + let visible_cell = self.record_workflow_history_cell(primary_thread_id, origin_cell); + let Some(op) = self.workflow_followup_user_turn(trimmed) else { + self.chat_widget.add_error_message( + "Failed to build the main-thread follow-up for the workflow.".to_string(), + ); + return visible_cell; + }; + self.app_event_tx.send(AppEvent::SubmitWorkflowFollowup { + thread_id: primary_thread_id, + op, + }); + visible_cell + } + + fn workflow_followup_user_turn(&self, text: String) -> Option { + let text = text.trim().to_string(); + if text.is_empty() { + return None; + } + + let session = self.primary_session_configured.as_ref(); + let cwd = session + .map(|session| session.cwd.clone()) + .unwrap_or_else(|| self.config.cwd.to_path_buf()); + let approval_policy = session + .map(|session| session.approval_policy) + .unwrap_or_else(|| self.config.permissions.approval_policy.value()); + let approvals_reviewer = session.map(|session| session.approvals_reviewer); + let sandbox_policy = session + .map(|session| session.sandbox_policy.clone()) + .unwrap_or_else(|| self.config.permissions.sandbox_policy.get().clone()); + let model = session + .map(|session| session.model.clone()) + .filter(|model| !model.trim().is_empty()) + .or_else(|| self.config.model.clone()) + .unwrap_or_else(|| self.chat_widget.current_model().to_string()); + let effort = session.and_then(|session| session.reasoning_effort); + let service_tier = session.and_then(|session| session.service_tier.map(Some)); + + Some(Op::UserTurn { + items: vec![UserInput::Text { + text, + text_elements: Vec::new(), + }], + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + model, + effort, + summary: None, + service_tier, + final_output_json_schema: None, + collaboration_mode: None, + personality: self.config.personality, + }) + } + + pub(crate) async fn handle_primary_thread_turn_complete_for_workflows( + &mut self, + app_server: &AppServerSession, + last_agent_message: Option, + ) -> Vec> { + let Some(primary_thread_id) = self.primary_thread_id else { + return Vec::new(); + }; + + let registry = match load_workflow_registry(self.config.cwd.as_path()) { + Ok(registry) => registry, + Err(error) => { + self.chat_widget.add_error_message(format!( + "Workflow after_turn failed: failed to load workflows: {error}" + )); + return Vec::new(); + } + }; + + let mut visible_cells = Vec::new(); + let phase_context = WorkflowPhaseContext { + current_user_turn: None, + last_assistant_message: last_agent_message.as_deref(), + }; + for workflow in ®istry.files { + for trigger in &workflow.triggers { + if !trigger.enabled || !matches!(trigger.kind, WorkflowTriggerKind::AfterTurn) { + continue; + } + + let cell = self.start_scheduled_workflow_trigger_run( + app_server, + workflow.name.clone(), + trigger.id.clone(), + phase_context, + ); + if let Some(cell) = self.record_workflow_history_cell(primary_thread_id, cell) { + visible_cells.push(cell); + } + } + } + visible_cells + } +} + +pub(crate) fn workflow_result_cell(message: &str, cwd: &Path) -> AgentMessageCell { + let mut rendered = vec![Line::default()]; + append_markdown(message, /*width*/ None, Some(cwd), &mut rendered); + AgentMessageCell::new(rendered, /*is_first_line*/ false) +} + +fn workflow_info_cell(source: &WorkflowReplySource) -> PlainHistoryCell { + history_cell::new_info_event("Workflow reply".to_string(), Some(source.hint())) +} + +fn workflow_prompt_prefix(prompt: &str) -> String { + let prefix = prompt.chars().take(48).collect::(); + if prompt.chars().count() > 48 { + format!("{prefix}...") + } else { + prefix + } +} + +fn workflow_job_source_hint(result: &super::workflow_runtime::WorkflowJobRunResult) -> String { + format!( + "{}/{}:{}", + result.workflow_name, result.trigger_id, result.job_name + ) +} + +fn user_text_from_inputs(items: &[UserInput]) -> String { + items + .iter() + .filter_map(|item| match item { + UserInput::Text { text, .. } => Some(text.as_str()), + UserInput::Image { .. } + | UserInput::LocalImage { .. } + | UserInput::Skill { .. } + | UserInput::Mention { .. } + | _ => None, + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::WorkflowReplySource; + use pretty_assertions::assert_eq; + + #[test] + fn workflow_reply_source_hint_prefers_action_when_present() { + let source = WorkflowReplySource::new( + "director/review:summary".to_string(), + Some("summarize it".to_string()), + ); + assert_eq!( + source.hint(), + "director/review:summary · summarize it".to_string() + ); + } +} diff --git a/codex-rs/tui/src/app/workflow_runtime.rs b/codex-rs/tui/src/app/workflow_runtime.rs new file mode 100644 index 000000000..83eee95a1 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_runtime.rs @@ -0,0 +1,2290 @@ +use super::App; +use super::workflow_definition::LoadedWorkflowJob; +use super::workflow_definition::LoadedWorkflowRegistry; +use super::workflow_definition::WorkflowContextMode; +use super::workflow_definition::WorkflowResponseMode; +use super::workflow_definition::WorkflowStep; +use super::workflow_definition::WorkflowTriggerKind; +use super::workflow_definition::load_workflow_registry; +use super::workflow_definition::ordered_jobs_for_roots; +use super::workflow_history::WorkflowReplySource; +use super::workflow_history::workflow_result_cell; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::history_cell; +use crate::history_cell::HistoryCell; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tokio::process::Command; +use tokio::sync::mpsc; +use tokio::time::sleep; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const WORKFLOW_POLL_INTERVAL: Duration = Duration::from_millis(50); +const WORKFLOW_INTERRUPT_SETTLE_TIMEOUT: Duration = Duration::from_secs(1); +const WORKFLOW_STEP_TIMEOUT: Duration = Duration::from_secs(30); + +type BoxFuture<'a, T> = Pin + Send + 'a>>; +pub(crate) type WorkflowThreadNotificationChannels = + Arc>>>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowTriggerOverlapBehavior { + Queue, + Skip, +} + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BackgroundWorkflowRunTarget { + Trigger { + workflow_name: String, + trigger_id: String, + phase_context: OwnedWorkflowPhaseContext, + overlap_behavior: WorkflowTriggerOverlapBehavior, + }, + Job { + workflow_name: String, + job_name: String, + }, +} + +impl BackgroundWorkflowRunTarget { + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn workflow_name(&self) -> &str { + match self { + Self::Trigger { workflow_name, .. } | Self::Job { workflow_name, .. } => workflow_name, + } + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn slot_key(&self) -> &str { + match self { + Self::Trigger { trigger_id, .. } => trigger_id, + Self::Job { job_name, .. } => job_name, + } + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn label(&self) -> String { + format!("{} · {}", self.workflow_name(), self.slot_key()) + } + + #[cfg_attr(not(test), allow(dead_code))] + #[allow(dead_code)] + fn started_message(&self) -> &'static str { + match self { + Self::Trigger { .. } => "Workflow trigger started", + Self::Job { .. } => "Workflow job started", + } + } + + fn completed_message(&self) -> &'static str { + match self { + Self::Trigger { .. } => "Workflow trigger completed", + Self::Job { .. } => "Workflow job completed", + } + } + + fn stopped_message(&self) -> &'static str { + match self { + Self::Trigger { .. } => "Workflow trigger stopped", + Self::Job { .. } => "Workflow job stopped", + } + } + + fn failed_message(&self) -> &'static str { + match self { + Self::Trigger { .. } => "Workflow trigger failed", + Self::Job { .. } => "Workflow job failed", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowOutputDelivery { + MainThreadInput, + AssistantCell, + UserFollowup, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkflowDisabledJobBehavior { + Skip, + RunRootJobs, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct WorkflowPhaseContext<'a> { + pub(crate) current_user_turn: Option<&'a str>, + pub(crate) last_assistant_message: Option<&'a str>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct OwnedWorkflowPhaseContext { + pub(crate) current_user_turn: Option, + pub(crate) last_assistant_message: Option, +} + +impl OwnedWorkflowPhaseContext { + fn borrowed(&self) -> WorkflowPhaseContext<'_> { + WorkflowPhaseContext { + current_user_turn: self.current_user_turn.as_deref(), + last_assistant_message: self.last_assistant_message.as_deref(), + } + } +} + +impl From> for OwnedWorkflowPhaseContext { + fn from(value: WorkflowPhaseContext<'_>) -> Self { + Self { + current_user_turn: value.current_user_turn.map(str::to_owned), + last_assistant_message: value.last_assistant_message.map(str::to_owned), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkflowJobRunResult { + pub(crate) delivery: WorkflowOutputDelivery, + pub(crate) workflow_name: String, + pub(crate) trigger_id: String, + pub(crate) job_name: String, + pub(crate) message: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum BackgroundWorkflowRunOutcome { + Completed(Vec), + Cancelled, + Failed(String), +} + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Debug)] +pub(crate) struct BackgroundWorkflowRunResult { + #[allow(dead_code)] + pub(crate) target: BackgroundWorkflowRunTarget, + pub(crate) outcome: BackgroundWorkflowRunOutcome, +} + +#[derive(Clone)] +struct WorkflowThreadSession { + thread_id: String, + cwd: PathBuf, + notifications: Arc>>, +} + +#[derive(Clone, Copy)] +struct WorkflowStepExecutionContext<'a> { + workflow_name: &'a str, + trigger_id: &'a str, + job: &'a LoadedWorkflowJob, + phase_context: WorkflowPhaseContext<'a>, + cancellation: Option<&'a CancellationToken>, +} + +#[derive(Debug, Clone, PartialEq)] +struct WorkflowTurnState { + status: TurnStatus, + error: Option, + last_agent_message: Option, +} + +trait WorkflowRuntimeClient: Send + Sync { + fn start_workflow_thread(&self) -> BoxFuture<'_, Result>; + fn start_turn( + &self, + thread_id: String, + cwd: PathBuf, + input: String, + ) -> BoxFuture<'_, Result>; + fn read_turn<'a>( + &'a self, + thread: &'a WorkflowThreadSession, + turn_id: String, + ) -> BoxFuture<'a, Result>; + fn interrupt_turn( + &self, + thread_id: String, + turn_id: String, + ) -> BoxFuture<'_, Result<(), String>>; + fn unsubscribe_thread(&self, thread_id: String) -> BoxFuture<'_, Result<(), String>>; +} + +pub(crate) struct AppServerWorkflowRuntimeClient { + request_handle: AppServerRequestHandle, + workflow_thread_notification_channels: WorkflowThreadNotificationChannels, + config: Config, + primary_thread_id: Option, + is_remote: bool, + remote_cwd_override: Option, +} + +impl AppServerWorkflowRuntimeClient { + pub(crate) fn new( + app_server: &AppServerSession, + workflow_thread_notification_channels: WorkflowThreadNotificationChannels, + config: Config, + primary_thread_id: Option, + ) -> Self { + Self { + request_handle: app_server.request_handle(), + workflow_thread_notification_channels, + config, + primary_thread_id, + is_remote: app_server.is_remote(), + remote_cwd_override: app_server.remote_cwd_override().map(PathBuf::from), + } + } +} + +impl WorkflowRuntimeClient for AppServerWorkflowRuntimeClient { + fn start_workflow_thread(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + let fork_source_thread_id = if let Some(primary_thread_id) = self.primary_thread_id { + let response: ThreadReadResponse = self + .request_handle + .request_typed(ClientRequest::ThreadRead { + request_id: request_id(), + params: ThreadReadParams { + thread_id: primary_thread_id.to_string(), + include_turns: false, + }, + }) + .await + .map_err(|err| format!("failed to inspect workflow source thread: {err}"))?; + response + .thread + .path + .as_ref() + .is_some_and(|path| path.exists()) + .then_some(primary_thread_id) + } else { + None + }; + + if let Some(primary_thread_id) = fork_source_thread_id { + let response: ThreadForkResponse = self + .request_handle + .request_typed(ClientRequest::ThreadFork { + request_id: request_id(), + params: workflow_thread_fork_params( + &self.config, + primary_thread_id, + self.is_remote, + self.remote_cwd_override.as_deref(), + ), + }) + .await + .map_err(|err| format!("failed to fork workflow thread: {err}"))?; + let thread_id = ThreadId::from_string(&response.thread.id).map_err(|err| { + format!( + "workflow thread id `{}` is invalid: {err}", + response.thread.id + ) + })?; + let (sender, receiver) = mpsc::unbounded_channel(); + self.workflow_thread_notification_channels + .lock() + .await + .insert(thread_id, sender); + return Ok(WorkflowThreadSession { + thread_id: response.thread.id, + cwd: response.cwd, + notifications: Arc::new(tokio::sync::Mutex::new(receiver)), + }); + } + + let response: ThreadStartResponse = self + .request_handle + .request_typed(ClientRequest::ThreadStart { + request_id: request_id(), + params: workflow_thread_start_params( + &self.config, + self.is_remote, + self.remote_cwd_override.as_deref(), + ), + }) + .await + .map_err(|err| format!("failed to start workflow thread: {err}"))?; + let thread_id = ThreadId::from_string(&response.thread.id).map_err(|err| { + format!( + "workflow thread id `{}` is invalid: {err}", + response.thread.id + ) + })?; + let (sender, receiver) = mpsc::unbounded_channel(); + self.workflow_thread_notification_channels + .lock() + .await + .insert(thread_id, sender); + Ok(WorkflowThreadSession { + thread_id: response.thread.id, + cwd: response.cwd, + notifications: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + } + + fn start_turn( + &self, + thread_id: String, + cwd: PathBuf, + input: String, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let response: TurnStartResponse = self + .request_handle + .request_typed(ClientRequest::TurnStart { + request_id: request_id(), + params: TurnStartParams { + thread_id, + input: vec![ + UserInput::Text { + text: input, + text_elements: Vec::new(), + } + .into(), + ], + cwd: Some(cwd), + approval_policy: Some( + self.config.permissions.approval_policy.value().into(), + ), + approvals_reviewer: Some(self.config.approvals_reviewer.into()), + sandbox_policy: Some( + self.config.permissions.sandbox_policy.get().clone().into(), + ), + model: self.config.model.clone(), + service_tier: None, + effort: self.config.model_reasoning_effort, + summary: self.config.model_reasoning_summary, + personality: None, + output_schema: None, + collaboration_mode: None, + }, + }) + .await + .map_err(|err| format!("failed to start workflow turn: {err}"))?; + Ok(response.turn.id) + }) + } + + fn read_turn<'a>( + &'a self, + thread: &'a WorkflowThreadSession, + turn_id: String, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let mut last_agent_message = None; + loop { + { + let mut notifications = thread.notifications.lock().await; + loop { + match notifications.try_recv() { + Ok(ServerNotification::ItemCompleted(notification)) + if notification.thread_id == thread.thread_id + && notification.turn_id == turn_id => + { + update_last_workflow_agent_message( + &mut last_agent_message, + ¬ification, + ); + } + Ok(ServerNotification::TurnCompleted(notification)) + if notification.thread_id == thread.thread_id + && notification.turn.id == turn_id => + { + let status = notification.turn.status.clone(); + let error = + notification.turn.error.clone().map(|error| error.message); + return Ok(WorkflowTurnState { + status, + error, + last_agent_message: last_agent_message.or_else(|| { + last_agent_message_for_turn_items( + notification.turn.items.as_slice(), + ) + }), + }); + } + Ok(_) => {} + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + | Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + } + } + } + + let response: ThreadReadResponse = match self + .request_handle + .request_typed(ClientRequest::ThreadRead { + request_id: request_id(), + params: ThreadReadParams { + thread_id: thread.thread_id.clone(), + include_turns: true, + }, + }) + .await + { + Ok(response) => response, + Err(err) + if { + let message = err.to_string(); + message + .contains("includeTurns is unavailable before first user message") + || message.contains("ephemeral threads do not support includeTurns") + } => + { + sleep(WORKFLOW_POLL_INTERVAL).await; + continue; + } + Err(err) => { + return Err(format!("failed to read workflow turn `{turn_id}`: {err}")); + } + }; + let Some(turn) = response + .thread + .turns + .into_iter() + .find(|turn| turn.id == turn_id) + else { + sleep(WORKFLOW_POLL_INTERVAL).await; + continue; + }; + if let Some(message) = last_agent_message_for_turn_items(turn.items.as_slice()) { + last_agent_message = Some(message); + } + if !matches!(turn.status, TurnStatus::InProgress) { + return Ok(WorkflowTurnState { + status: turn.status, + error: turn.error.map(|error| error.message), + last_agent_message, + }); + } + sleep(WORKFLOW_POLL_INTERVAL).await; + } + }) + } + + fn interrupt_turn( + &self, + thread_id: String, + turn_id: String, + ) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + let _: TurnInterruptResponse = self + .request_handle + .request_typed(ClientRequest::TurnInterrupt { + request_id: request_id(), + params: TurnInterruptParams { thread_id, turn_id }, + }) + .await + .map_err(|err| format!("failed to interrupt workflow turn: {err}"))?; + Ok(()) + }) + } + + fn unsubscribe_thread(&self, thread_id: String) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + let result: Result = self + .request_handle + .request_typed(ClientRequest::ThreadUnsubscribe { + request_id: request_id(), + params: ThreadUnsubscribeParams { + thread_id: thread_id.clone(), + }, + }) + .await + .map_err(|err| format!("failed to unsubscribe workflow thread: {err}")); + if let Ok(parsed_thread_id) = ThreadId::from_string(&thread_id) { + self.workflow_thread_notification_channels + .lock() + .await + .remove(&parsed_thread_id); + } + result.map(|_| ()) + }) + } +} + +#[allow(dead_code)] +impl App { + pub(crate) async fn run_before_turn_workflows( + &self, + app_server: &AppServerSession, + phase_context: WorkflowPhaseContext<'_>, + ) -> Result, String> { + let registry = load_workflow_registry(self.config.cwd.as_path()) + .map_err(|error| format!("failed to load workflows: {error}"))?; + let client = AppServerWorkflowRuntimeClient::new( + app_server, + self.workflow_thread_notification_channels.clone(), + self.config.clone(), + self.primary_thread_id, + ); + let mut results = Vec::new(); + for workflow in ®istry.files { + for trigger in &workflow.triggers { + if !trigger.enabled || !matches!(trigger.kind, WorkflowTriggerKind::BeforeTurn) { + continue; + } + results.extend( + run_workflow_jobs( + &client, + ®istry, + &workflow.name, + &trigger.id, + &trigger.jobs, + phase_context, + WorkflowDisabledJobBehavior::Skip, + /*cancellation*/ None, + ) + .await + .map_err(workflow_run_error_message)?, + ); + } + } + Ok(results) + } + + pub(crate) fn start_manual_workflow_trigger_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + ) -> Arc { + let label = format!("{workflow_name} · {trigger_id}"); + match self.queue_or_start_trigger_run( + app_server, + workflow_name, + trigger_id, + OwnedWorkflowPhaseContext::default(), + WorkflowTriggerOverlapBehavior::Queue, + ) { + TriggerRunDispatch::Started => Arc::new(history_cell::new_info_event( + "Workflow trigger started".to_string(), + Some(label), + )), + TriggerRunDispatch::Queued => Arc::new(history_cell::new_info_event( + "Workflow trigger queued".to_string(), + Some(label), + )), + TriggerRunDispatch::Skipped => Arc::new(history_cell::new_info_event( + "Workflow trigger skipped".to_string(), + Some(label), + )), + } + } + + pub(crate) fn start_manual_workflow_job_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + job_name: String, + ) -> Arc { + let target = BackgroundWorkflowRunTarget::Job { + workflow_name, + job_name, + }; + let cell: Arc = Arc::new(history_cell::new_info_event( + target.started_message().to_string(), + Some(target.label()), + )); + self.start_background_workflow_run(app_server, target); + cell + } + + pub(crate) fn start_scheduled_workflow_trigger_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + phase_context: WorkflowPhaseContext<'_>, + ) -> Arc { + let label = format!("{workflow_name} · {trigger_id}"); + match self.queue_or_start_trigger_run( + app_server, + workflow_name, + trigger_id, + phase_context.into(), + WorkflowTriggerOverlapBehavior::Queue, + ) { + TriggerRunDispatch::Started => Arc::new(history_cell::new_info_event( + "Workflow trigger started".to_string(), + Some(label), + )), + TriggerRunDispatch::Queued => Arc::new(history_cell::new_info_event( + "Workflow trigger queued".to_string(), + Some(label), + )), + TriggerRunDispatch::Skipped => Arc::new(history_cell::new_info_event( + "Workflow trigger skipped".to_string(), + Some(label), + )), + } + } + + pub(crate) fn start_file_watch_workflow_trigger_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + ) -> Option> { + let label = format!("{workflow_name} · {trigger_id}"); + match self.queue_or_start_trigger_run( + app_server, + workflow_name, + trigger_id, + OwnedWorkflowPhaseContext::default(), + WorkflowTriggerOverlapBehavior::Skip, + ) { + TriggerRunDispatch::Started => Some(Arc::new(history_cell::new_info_event( + "Workflow trigger started".to_string(), + Some(label), + ))), + TriggerRunDispatch::Queued => Some(Arc::new(history_cell::new_info_event( + "Workflow trigger queued".to_string(), + Some(label), + ))), + TriggerRunDispatch::Skipped => None, + } + } + + pub(crate) fn handle_workspace_file_changes_for_workflows( + &mut self, + app_server: &AppServerSession, + changed_paths: &[PathBuf], + ) -> Vec> { + let Some(primary_thread_id) = self.primary_thread_id else { + return Vec::new(); + }; + if changed_paths.is_empty() { + return Vec::new(); + } + + let registry = match load_workflow_registry(self.config.cwd.as_path()) { + Ok(registry) => registry, + Err(error) => { + self.chat_widget.add_error_message(format!( + "Workflow file_watch failed: failed to load workflows: {error}" + )); + return Vec::new(); + } + }; + + let mut visible_cells = Vec::new(); + for workflow in ®istry.files { + for trigger in &workflow.triggers { + if !trigger.enabled || !matches!(trigger.kind, WorkflowTriggerKind::FileWatch) { + continue; + } + + let Some(cell) = self.start_file_watch_workflow_trigger_run( + app_server, + workflow.name.clone(), + trigger.id.clone(), + ) else { + continue; + }; + if let Some(cell) = self.record_workflow_history_cell(primary_thread_id, cell) { + visible_cells.push(cell); + } + } + } + visible_cells + } + + pub(crate) fn dispatch_next_queued_trigger_run(&mut self, app_server: &AppServerSession) { + if self.workflow_scheduler.has_running_trigger_run() { + return; + } + let Some(next) = self.workflow_scheduler.dequeue_trigger_run() else { + return; + }; + self.start_background_workflow_run( + app_server, + BackgroundWorkflowRunTarget::Trigger { + workflow_name: next.workflow_name, + trigger_id: next.trigger_id, + phase_context: next.phase_context, + overlap_behavior: WorkflowTriggerOverlapBehavior::Queue, + }, + ); + } + + pub(crate) async fn finish_background_workflow_run( + &mut self, + app_server: &AppServerSession, + run_id: String, + result: BackgroundWorkflowRunResult, + ) -> Vec> { + let Some(run) = self + .workflow_scheduler + .take_background_workflow_run(&run_id) + else { + return Vec::new(); + }; + let completed_trigger = run.is_trigger; + let _ = run.handle.await; + + let mut visible_cells = Vec::new(); + if let Some(primary_thread_id) = self.primary_thread_id { + match result.outcome { + BackgroundWorkflowRunOutcome::Completed(results) => { + for result in results { + let source = WorkflowReplySource::new( + workflow_job_source(&result), + /*action*/ None, + ); + let completed_cell: Arc = + Arc::new(history_cell::new_info_event( + run.target.completed_message().to_string(), + Some(source.hint()), + )); + if let Some(cell) = + self.record_workflow_history_cell(primary_thread_id, completed_cell) + { + visible_cells.push(cell); + } + + let Some(message) = + result.message.filter(|message| !message.trim().is_empty()) + else { + continue; + }; + + match result.delivery { + WorkflowOutputDelivery::AssistantCell => { + let assistant_cell: Arc = Arc::new( + workflow_result_cell(&message, self.config.cwd.as_path()), + ); + if let Some(cell) = self + .record_workflow_history_cell(primary_thread_id, assistant_cell) + { + visible_cells.push(cell); + } + } + WorkflowOutputDelivery::MainThreadInput + | WorkflowOutputDelivery::UserFollowup => { + if let Some(cell) = + self.queue_workflow_followup_to_primary(message, source) + { + visible_cells.push(cell); + } + } + } + } + } + BackgroundWorkflowRunOutcome::Cancelled => { + let cancelled_cell: Arc = + Arc::new(history_cell::new_info_event( + run.target.stopped_message().to_string(), + Some(run.target.label()), + )); + if let Some(cell) = + self.record_workflow_history_cell(primary_thread_id, cancelled_cell) + { + visible_cells.push(cell); + } + } + BackgroundWorkflowRunOutcome::Failed(error) => { + let error_cell: Arc = Arc::new(history_cell::new_error_event( + format!("{}: {error}", run.target.failed_message()), + )); + if let Some(cell) = + self.record_workflow_history_cell(primary_thread_id, error_cell) + { + visible_cells.push(cell); + } + } + } + } + + if completed_trigger { + self.dispatch_next_queued_trigger_run(app_server); + } + self.sync_background_workflow_status(); + visible_cells + } + + fn queue_or_start_trigger_run( + &mut self, + app_server: &AppServerSession, + workflow_name: String, + trigger_id: String, + phase_context: OwnedWorkflowPhaseContext, + overlap_behavior: WorkflowTriggerOverlapBehavior, + ) -> TriggerRunDispatch { + if matches!(overlap_behavior, WorkflowTriggerOverlapBehavior::Skip) + && (self + .workflow_scheduler + .has_active_trigger_run(&workflow_name, &trigger_id) + || self + .workflow_scheduler + .has_queued_trigger_run(&workflow_name, &trigger_id)) + { + return TriggerRunDispatch::Skipped; + } + + if self.workflow_scheduler.has_running_trigger_run() { + self.workflow_scheduler + .enqueue_trigger_run(workflow_name, trigger_id, phase_context); + self.sync_background_workflow_status(); + return TriggerRunDispatch::Queued; + } + + self.start_background_workflow_run( + app_server, + BackgroundWorkflowRunTarget::Trigger { + workflow_name, + trigger_id, + phase_context, + overlap_behavior, + }, + ); + TriggerRunDispatch::Started + } + + fn start_background_workflow_run( + &mut self, + app_server: &AppServerSession, + target: BackgroundWorkflowRunTarget, + ) { + let run_id = self + .workflow_scheduler + .next_background_run_id(target.workflow_name(), target.slot_key()); + let runtime_client = AppServerWorkflowRuntimeClient::new( + app_server, + self.workflow_thread_notification_channels.clone(), + self.config.clone(), + self.primary_thread_id, + ); + let workflow_cwd = self.config.cwd.to_path_buf(); + let app_event_tx = self.app_event_tx.clone(); + let run_id_for_task = run_id.clone(); + let target_for_task = target.clone(); + let cancellation = CancellationToken::new(); + let cancellation_for_task = cancellation.clone(); + let handle = tokio::spawn(async move { + let result = run_background_workflow( + &runtime_client, + workflow_cwd, + target_for_task, + cancellation_for_task, + ) + .await; + app_event_tx.send(AppEvent::BackgroundWorkflowRunCompleted { + run_id: run_id_for_task, + result: Box::new(result), + }); + }); + self.workflow_scheduler.register_background_workflow_run( + run_id, + target, + cancellation, + handle, + ); + self.sync_background_workflow_status(); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(dead_code)] +enum TriggerRunDispatch { + Started, + Queued, + Skipped, +} + +async fn run_background_workflow( + client: &dyn WorkflowRuntimeClient, + workflow_cwd: PathBuf, + target: BackgroundWorkflowRunTarget, + cancellation: CancellationToken, +) -> BackgroundWorkflowRunResult { + let outcome = + match run_background_workflow_selection(client, workflow_cwd, &target, &cancellation).await + { + Ok(results) => BackgroundWorkflowRunOutcome::Completed(results), + Err(WorkflowRunError::Cancelled) => BackgroundWorkflowRunOutcome::Cancelled, + Err(error) => BackgroundWorkflowRunOutcome::Failed(workflow_run_error_message(error)), + }; + BackgroundWorkflowRunResult { target, outcome } +} + +async fn run_background_workflow_selection( + client: &dyn WorkflowRuntimeClient, + workflow_cwd: PathBuf, + target: &BackgroundWorkflowRunTarget, + cancellation: &CancellationToken, +) -> Result, WorkflowRunError> { + let registry = load_workflow_registry(workflow_cwd.as_path()) + .map_err(|error| WorkflowRunError::Failed(error.to_string()))?; + match target { + BackgroundWorkflowRunTarget::Trigger { + workflow_name, + trigger_id, + phase_context, + overlap_behavior: _, + } => { + let workflow = registry + .files + .iter() + .find(|workflow| workflow.name == *workflow_name) + .ok_or_else(|| { + WorkflowRunError::Failed(format!("workflow `{workflow_name}` does not exist")) + })?; + let trigger = workflow + .triggers + .iter() + .find(|trigger| trigger.id == *trigger_id) + .ok_or_else(|| { + WorkflowRunError::Failed(format!("trigger `{trigger_id}` does not exist")) + })?; + if !trigger.enabled { + return Err(WorkflowRunError::Failed(format!( + "workflow trigger `{workflow_name}/{trigger_id}` is disabled" + ))); + } + run_workflow_jobs( + client, + ®istry, + workflow_name, + trigger_id, + &trigger.jobs, + phase_context.borrowed(), + WorkflowDisabledJobBehavior::Skip, + Some(cancellation), + ) + .await + } + BackgroundWorkflowRunTarget::Job { + workflow_name, + job_name, + } => { + let job = registry.jobs.get(job_name).ok_or_else(|| { + WorkflowRunError::Failed(format!("workflow job `{job_name}` does not exist")) + })?; + if job.workflow_name != *workflow_name { + return Err(WorkflowRunError::Failed(format!( + "workflow `{workflow_name}` does not define job `{job_name}`" + ))); + } + run_workflow_jobs( + client, + ®istry, + workflow_name, + &manual_workflow_job_trigger_id(job_name), + std::slice::from_ref(job_name), + WorkflowPhaseContext { + current_user_turn: None, + last_assistant_message: None, + }, + WorkflowDisabledJobBehavior::RunRootJobs, + Some(cancellation), + ) + .await + } + } +} + +async fn run_workflow_jobs( + client: &dyn WorkflowRuntimeClient, + registry: &LoadedWorkflowRegistry, + workflow_name: &str, + trigger_id: &str, + root_jobs: &[String], + phase_context: WorkflowPhaseContext<'_>, + disabled_job_behavior: WorkflowDisabledJobBehavior, + cancellation: Option<&CancellationToken>, +) -> Result, WorkflowRunError> { + let ordered = ordered_jobs_for_roots(registry, root_jobs) + .map_err(|error| WorkflowRunError::Failed(error.to_string()))?; + let mut results = Vec::new(); + let mut completed = BTreeMap::::new(); + for job_name in ordered { + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err(WorkflowRunError::Cancelled); + } + let job = registry.jobs.get(&job_name).ok_or_else(|| { + WorkflowRunError::Failed(format!("workflow job `{job_name}` does not exist")) + })?; + let should_run_disabled_job = matches!( + disabled_job_behavior, + WorkflowDisabledJobBehavior::RunRootJobs + ) && root_jobs.iter().any(|root_job| root_job == &job_name); + if !job.config.enabled && !should_run_disabled_job { + completed.insert(job_name, false); + continue; + } + if job + .config + .needs + .iter() + .any(|dependency| completed.get(dependency) == Some(&false)) + { + completed.insert(job.name.clone(), false); + continue; + } + let result = run_workflow_job( + client, + workflow_name, + trigger_id, + job, + phase_context, + cancellation, + ) + .await?; + completed.insert(job.name.clone(), true); + results.push(result); + } + if results.is_empty() { + return Err(WorkflowRunError::Failed(format!( + "workflow `{workflow_name}/{trigger_id}` did not run any enabled jobs" + ))); + } + Ok(results) +} + +async fn run_workflow_job( + client: &dyn WorkflowRuntimeClient, + workflow_name: &str, + trigger_id: &str, + job: &LoadedWorkflowJob, + phase_context: WorkflowPhaseContext<'_>, + cancellation: Option<&CancellationToken>, +) -> Result { + if matches!(job.config.context, WorkflowContextMode::Embed) { + let prompt = job + .config + .steps + .iter() + .find_map(|step| match step { + WorkflowStep::Prompt { prompt, .. } => Some(prompt.clone()), + WorkflowStep::Run { .. } => None, + }) + .ok_or_else(|| { + WorkflowRunError::Failed(format!( + "workflow `{workflow_name}` job `{}` uses embed context but has no prompt step", + job.name + )) + })?; + return Ok(WorkflowJobRunResult { + delivery: WorkflowOutputDelivery::MainThreadInput, + workflow_name: workflow_name.to_string(), + trigger_id: trigger_id.to_string(), + job_name: job.name.clone(), + message: Some(prompt), + }); + } + + let mut thread: Option = None; + let mut step_outputs = Vec::new(); + let mut last_prompt_response = None; + for step in &job.config.steps { + let configured_attempts = step.retry_attempts(); + let step_timeout = step.timeout(WORKFLOW_STEP_TIMEOUT).map_err(|err| { + WorkflowRunError::Failed(format!( + "workflow `{workflow_name}` job `{}` has invalid {} step timeout: {err}", + job.name, + step.kind() + )) + })?; + let mut attempt = 1; + let mut used_capacity_retry = false; + let mut used_timeout_retry = false; + let step_error = loop { + if cancellation.is_some_and(CancellationToken::is_cancelled) { + if let Some(thread) = thread.as_ref() { + let _ = client.unsubscribe_thread(thread.thread_id.clone()).await; + } + return Err(WorkflowRunError::Cancelled); + } + let context = WorkflowStepExecutionContext { + workflow_name, + trigger_id, + job, + phase_context, + cancellation, + }; + let result = execute_workflow_step( + client, + &mut thread, + context, + step, + step_timeout, + &step_outputs, + ) + .await; + match result { + Ok(Some(output)) => { + if matches!(step, WorkflowStep::Prompt { .. }) { + last_prompt_response = Some(output.clone()); + } + step_outputs.push(output); + break None; + } + Ok(None) => { + break None; + } + Err(error) => { + let should_retry_capacity = + !used_capacity_retry && should_retry_selected_model_capacity_error(&error); + let should_retry_timeout = + !used_timeout_retry && should_retry_workflow_timeout(&error); + let should_retry = !matches!(error, WorkflowRunError::Cancelled) + && (attempt < configured_attempts + || should_retry_capacity + || should_retry_timeout); + if should_retry { + if attempt >= configured_attempts { + if should_retry_capacity { + used_capacity_retry = true; + } + if should_retry_timeout { + used_timeout_retry = true; + } + } + sleep(retry_backoff_delay(attempt)).await; + attempt = attempt.saturating_add(1); + continue; + } + break Some(error); + } + } + }; + if let Some(error) = step_error { + if let Some(thread) = thread.as_ref() { + let _ = client.unsubscribe_thread(thread.thread_id.clone()).await; + } + return Err(error); + } + } + + if let Some(thread) = thread.as_ref() { + let _ = client.unsubscribe_thread(thread.thread_id.clone()).await; + } + + Ok(WorkflowJobRunResult { + delivery: match job.config.response { + WorkflowResponseMode::Assistant => WorkflowOutputDelivery::AssistantCell, + WorkflowResponseMode::User => WorkflowOutputDelivery::UserFollowup, + }, + workflow_name: workflow_name.to_string(), + trigger_id: trigger_id.to_string(), + job_name: job.name.clone(), + message: last_prompt_response + .or_else(|| (!step_outputs.is_empty()).then(|| step_outputs.join("\n\n"))), + }) +} + +async fn execute_workflow_step( + client: &dyn WorkflowRuntimeClient, + thread: &mut Option, + context: WorkflowStepExecutionContext<'_>, + step: &WorkflowStep, + step_timeout: Duration, + step_outputs: &[String], +) -> Result, WorkflowRunError> { + match step { + WorkflowStep::Run { run, .. } => { + run_workflow_command( + run, + &context.job.workflow_path, + step_timeout, + context.cancellation, + ) + .await + } + WorkflowStep::Prompt { prompt, .. } => { + let thread = match thread { + Some(thread) => thread.clone(), + None => { + let started = client + .start_workflow_thread() + .await + .map_err(WorkflowRunError::Failed)?; + *thread = Some(started.clone()); + started + } + }; + let prompt = build_workflow_prompt_input( + context.workflow_name, + context.trigger_id, + &context.job.name, + prompt, + context.phase_context, + step_outputs, + ); + run_workflow_prompt(client, &thread, prompt, step_timeout, context.cancellation).await + } + } +} + +async fn run_workflow_prompt( + client: &dyn WorkflowRuntimeClient, + thread: &WorkflowThreadSession, + prompt: String, + step_timeout: Duration, + cancellation: Option<&CancellationToken>, +) -> Result, WorkflowRunError> { + let turn_id = client + .start_turn(thread.thread_id.clone(), thread.cwd.clone(), prompt) + .await + .map_err(WorkflowRunError::Failed)?; + let deadline = tokio::time::Instant::now() + step_timeout; + loop { + if cancellation.is_some_and(CancellationToken::is_cancelled) { + interrupt_active_workflow_turn(client, thread, turn_id.clone()).await; + return Err(WorkflowRunError::Cancelled); + } + let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now()) else { + interrupt_active_workflow_turn(client, thread, turn_id.clone()).await; + return Err(WorkflowRunError::TimedOut(format!( + "workflow prompt timed out after {}", + humantime::format_duration(step_timeout) + ))); + }; + let turn = match tokio::time::timeout(remaining, client.read_turn(thread, turn_id.clone())) + .await + { + Ok(turn) => turn.map_err(WorkflowRunError::Failed)?, + Err(_) => { + interrupt_active_workflow_turn(client, thread, turn_id.clone()).await; + return Err(WorkflowRunError::TimedOut(format!( + "workflow prompt timed out after {}", + humantime::format_duration(step_timeout) + ))); + } + }; + match turn.status { + TurnStatus::Completed => return Ok(turn.last_agent_message), + TurnStatus::Interrupted => return Err(WorkflowRunError::Cancelled), + TurnStatus::Failed => { + return Err(WorkflowRunError::Failed( + turn.error + .unwrap_or_else(|| "workflow prompt turn failed".to_string()), + )); + } + TurnStatus::InProgress => { + if tokio::time::Instant::now() >= deadline { + interrupt_active_workflow_turn(client, thread, turn_id.clone()).await; + return Err(WorkflowRunError::TimedOut(format!( + "workflow prompt timed out after {}", + humantime::format_duration(step_timeout) + ))); + } + sleep(WORKFLOW_POLL_INTERVAL).await; + } + } + } +} + +async fn interrupt_active_workflow_turn( + client: &dyn WorkflowRuntimeClient, + thread: &WorkflowThreadSession, + turn_id: String, +) { + let _ = client + .interrupt_turn(thread.thread_id.clone(), turn_id.clone()) + .await; + let deadline = tokio::time::Instant::now() + WORKFLOW_INTERRUPT_SETTLE_TIMEOUT; + while tokio::time::Instant::now() < deadline { + match client.read_turn(thread, turn_id.clone()).await { + Ok(turn) if !matches!(turn.status, TurnStatus::InProgress) => return, + Ok(_) | Err(_) => sleep(WORKFLOW_POLL_INTERVAL).await, + } + } +} + +async fn run_workflow_command( + command: &str, + workflow_path: &std::path::Path, + step_timeout: Duration, + cancellation: Option<&CancellationToken>, +) -> Result, WorkflowRunError> { + #[cfg(windows)] + let mut cmd = { + let mut cmd = Command::new("cmd"); + cmd.arg("/C").arg(command); + cmd + }; + #[cfg(not(windows))] + let mut cmd = { + let mut cmd = Command::new("bash"); + cmd.arg("-lc").arg(command); + cmd + }; + cmd.kill_on_drop(true); + let workflow_dir = workflow_path + .parent() + .and_then(|parent| parent.parent()) + .and_then(|parent| parent.parent()) + .unwrap_or(workflow_path); + let child = cmd + .current_dir(workflow_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + WorkflowRunError::Failed(format!("failed to run workflow command `{command}`: {err}")) + })?; + let wait_with_output = child.wait_with_output(); + tokio::pin!(wait_with_output); + let output = tokio::select! { + _ = async { + if let Some(cancellation) = cancellation { + cancellation.cancelled().await; + } else { + std::future::pending::<()>().await; + } + } => return Err(WorkflowRunError::Cancelled), + output = tokio::time::timeout(step_timeout, &mut wait_with_output) => output + .map_err(|_| { + WorkflowRunError::TimedOut(format!( + "workflow command `{command}` timed out after {}", + humantime::format_duration(step_timeout) + )) + })?, + } + .map_err(|err| { + WorkflowRunError::Failed(format!("failed to run workflow command `{command}`: {err}")) + })?; + let mut text = String::new(); + if !output.stdout.is_empty() { + text.push_str(&String::from_utf8_lossy(&output.stdout)); + } + if !output.stderr.is_empty() { + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(&String::from_utf8_lossy(&output.stderr)); + } + let text = text.trim().to_string(); + if !output.status.success() { + return Err(WorkflowRunError::Failed(match text.is_empty() { + true => format!( + "workflow command `{command}` failed with status {}", + output.status + ), + false => format!( + "workflow command `{command}` failed with status {}: {text}", + output.status + ), + })); + } + Ok((!text.is_empty()).then_some(text)) +} + +fn build_workflow_prompt_input( + workflow_name: &str, + trigger_id: &str, + job_name: &str, + prompt: &str, + phase_context: WorkflowPhaseContext<'_>, + step_outputs: &[String], +) -> String { + let mut sections = vec![format!( + "Workflow: {workflow_name}\nTrigger: {trigger_id}\nJob: {job_name}" + )]; + if let Some(current_user_turn) = phase_context + .current_user_turn + .map(str::trim) + .filter(|text| !text.is_empty()) + { + sections.push(format!( + "Current main-thread user turn:\n{current_user_turn}" + )); + } + if let Some(last_assistant_message) = phase_context + .last_assistant_message + .map(str::trim) + .filter(|text| !text.is_empty()) + { + sections.push(format!( + "Latest main-thread assistant response:\n{last_assistant_message}" + )); + } + if !step_outputs.is_empty() { + sections.push(format!( + "Previous workflow step outputs:\n{}", + step_outputs.join("\n\n") + )); + } + sections.push(format!("Current workflow prompt:\n{prompt}")); + sections.join("\n\n") +} + +fn workflow_thread_start_params( + config: &Config, + is_remote: bool, + remote_cwd_override: Option<&std::path::Path>, +) -> ThreadStartParams { + ThreadStartParams { + model: config.model.clone(), + model_provider: (!is_remote).then_some(config.model_provider_id.clone()), + cwd: workflow_thread_cwd(config, is_remote, remote_cwd_override), + approval_policy: Some(config.permissions.approval_policy.value().into()), + approvals_reviewer: Some(AppServerApprovalsReviewer::from(config.approvals_reviewer)), + sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + config: config.active_profile.as_ref().map(|profile| { + HashMap::from([( + "profile".to_string(), + serde_json::Value::String(profile.clone()), + )]) + }), + ephemeral: Some(true), + persist_extended_history: true, + ..ThreadStartParams::default() + } +} + +fn workflow_thread_fork_params( + config: &Config, + thread_id: ThreadId, + is_remote: bool, + remote_cwd_override: Option<&std::path::Path>, +) -> ThreadForkParams { + ThreadForkParams { + thread_id: thread_id.to_string(), + model: config.model.clone(), + model_provider: (!is_remote).then_some(config.model_provider_id.clone()), + cwd: workflow_thread_cwd(config, is_remote, remote_cwd_override), + approval_policy: Some(config.permissions.approval_policy.value().into()), + approvals_reviewer: Some(AppServerApprovalsReviewer::from(config.approvals_reviewer)), + sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()), + config: config.active_profile.as_ref().map(|profile| { + HashMap::from([( + "profile".to_string(), + serde_json::Value::String(profile.clone()), + )]) + }), + ephemeral: true, + persist_extended_history: true, + ..ThreadForkParams::default() + } +} + +fn workflow_thread_cwd( + config: &Config, + is_remote: bool, + remote_cwd_override: Option<&std::path::Path>, +) -> Option { + if is_remote { + remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string()) + } else { + Some(config.cwd.to_string_lossy().to_string()) + } +} + +fn sandbox_mode_from_policy(policy: SandboxPolicy) -> Option { + match policy { + SandboxPolicy::DangerFullAccess => Some(SandboxMode::DangerFullAccess), + SandboxPolicy::ReadOnly { .. } => Some(SandboxMode::ReadOnly), + SandboxPolicy::WorkspaceWrite { .. } => Some(SandboxMode::WorkspaceWrite), + SandboxPolicy::ExternalSandbox { .. } => None, + } +} + +fn request_id() -> RequestId { + RequestId::String(format!("workflow-{}", Uuid::new_v4())) +} + +fn manual_workflow_job_trigger_id(job_name: &str) -> String { + format!("job:{job_name}") +} + +fn workflow_job_source(result: &WorkflowJobRunResult) -> String { + format!( + "{}/{}:{}", + result.workflow_name, result.trigger_id, result.job_name + ) +} + +fn retry_backoff_delay(attempt: u32) -> Duration { + let seconds = 2u64.saturating_pow(attempt.saturating_sub(1)).min(8); + Duration::from_secs(seconds.max(1)) +} + +fn should_retry_selected_model_capacity_error(error: &WorkflowRunError) -> bool { + matches!( + error, + WorkflowRunError::Failed(message) + if message.contains("Selected model is at capacity. Please try a different model.") + ) +} + +fn should_retry_workflow_timeout(error: &WorkflowRunError) -> bool { + matches!(error, WorkflowRunError::TimedOut(_)) +} + +fn workflow_run_error_message(error: WorkflowRunError) -> String { + match error { + WorkflowRunError::Failed(message) | WorkflowRunError::TimedOut(message) => message, + WorkflowRunError::Cancelled => "workflow run cancelled".to_string(), + } +} + +fn update_last_workflow_agent_message( + last_agent_message: &mut Option, + notification: &ItemCompletedNotification, +) { + if let ThreadItem::AgentMessage { text, .. } = ¬ification.item { + let trimmed = text.trim(); + if !trimmed.is_empty() { + *last_agent_message = Some(trimmed.to_string()); + } + } +} + +fn last_agent_message_for_turn_items(items: &[ThreadItem]) -> Option { + items.iter().fold(None, |_, item| match item { + ThreadItem::AgentMessage { text, .. } => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + _ => None, + }) +} + +#[derive(Debug)] +enum WorkflowRunError { + Failed(String), + TimedOut(String), + Cancelled, +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::TurnCompletedNotification; + use codex_core::config::ConfigBuilder; + use pretty_assertions::assert_eq; + use std::collections::VecDeque; + use std::sync::Mutex; + use tempfile::TempDir; + use tempfile::tempdir; + use tokio::time; + + async fn build_config(temp_dir: &TempDir) -> Config { + ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await + .expect("config should build") + } + + struct FakeWorkflowRuntimeClient { + calls: Mutex>, + thread_id: String, + turn_id: String, + reads: Mutex>>, + } + + impl FakeWorkflowRuntimeClient { + fn new(reads: Vec>) -> Self { + Self { + calls: Mutex::new(Vec::new()), + thread_id: "thr_workflow".to_string(), + turn_id: "turn_workflow".to_string(), + reads: Mutex::new(reads.into()), + } + } + } + + impl WorkflowRuntimeClient for FakeWorkflowRuntimeClient { + fn start_workflow_thread(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + let (_sender, receiver) = mpsc::unbounded_channel(); + self.calls + .lock() + .expect("calls lock") + .push("start_workflow_thread".to_string()); + Ok(WorkflowThreadSession { + thread_id: self.thread_id.clone(), + cwd: PathBuf::from("/tmp/workflow"), + notifications: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + } + + fn start_turn( + &self, + thread_id: String, + _cwd: PathBuf, + input: String, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.calls + .lock() + .expect("calls lock") + .push(format!("start_turn:{thread_id}:{input}")); + Ok(self.turn_id.clone()) + }) + } + + fn read_turn<'a>( + &'a self, + thread: &'a WorkflowThreadSession, + turn_id: String, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls + .lock() + .expect("calls lock") + .push(format!("read_turn:{}:{turn_id}", thread.thread_id)); + self.reads + .lock() + .expect("reads lock") + .pop_front() + .unwrap_or({ + Ok(WorkflowTurnState { + status: TurnStatus::InProgress, + error: None, + last_agent_message: None, + }) + }) + }) + } + + fn interrupt_turn( + &self, + thread_id: String, + turn_id: String, + ) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + self.calls + .lock() + .expect("calls lock") + .push(format!("interrupt_turn:{thread_id}:{turn_id}")); + Ok(()) + }) + } + + fn unsubscribe_thread(&self, thread_id: String) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + self.calls + .lock() + .expect("calls lock") + .push(format!("unsubscribe_thread:{thread_id}")); + Ok(()) + }) + } + } + + #[tokio::test] + async fn prompt_workflow_job_uses_app_server_runtime_sequence() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: manual + jobs: [review_backlog] + +jobs: + review_backlog: + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(vec![ + Ok(WorkflowTurnState { + status: TurnStatus::InProgress, + error: None, + last_agent_message: None, + }), + Ok(WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + }), + ]); + let result = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + CancellationToken::new(), + ) + .await; + + match result.outcome { + BackgroundWorkflowRunOutcome::Completed(results) => { + assert_eq!(results.len(), 1); + assert_eq!(results[0].message.as_deref(), Some("workflow reply")); + } + other => panic!("expected completed run, got {other:?}"), + } + assert_eq!( + client.calls.lock().expect("calls lock").clone(), + vec![ + "start_workflow_thread".to_string(), + "start_turn:thr_workflow:Workflow: director\nTrigger: job:review_backlog\nJob: review_backlog\n\nCurrent workflow prompt:\nsummarize the backlog".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "unsubscribe_thread:thr_workflow".to_string(), + ] + ); + } + + #[tokio::test] + async fn prompt_workflow_job_retries_selected_model_capacity_once() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +jobs: + review_backlog: + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(vec![ + Ok(WorkflowTurnState { + status: TurnStatus::Failed, + error: Some( + "Selected model is at capacity. Please try a different model.".to_string(), + ), + last_agent_message: None, + }), + Ok(WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + }), + ]); + let result = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + CancellationToken::new(), + ) + .await; + + match result.outcome { + BackgroundWorkflowRunOutcome::Completed(results) => { + assert_eq!(results.len(), 1); + assert_eq!(results[0].message.as_deref(), Some("workflow reply")); + } + other => panic!("expected completed run, got {other:?}"), + } + assert_eq!( + client.calls.lock().expect("calls lock").clone(), + vec![ + "start_workflow_thread".to_string(), + "start_turn:thr_workflow:Workflow: director\nTrigger: job:review_backlog\nJob: review_backlog\n\nCurrent workflow prompt:\nsummarize the backlog".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "start_turn:thr_workflow:Workflow: director\nTrigger: job:review_backlog\nJob: review_backlog\n\nCurrent workflow prompt:\nsummarize the backlog".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "unsubscribe_thread:thr_workflow".to_string(), + ] + ); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn prompt_workflow_job_uses_configured_timeout_for_retry() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +jobs: + review_backlog: + steps: + - prompt: summarize the backlog + timeout: 1s +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(Vec::new()); + let run = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + CancellationToken::new(), + ); + tokio::pin!(run); + + let configured_timeout = Duration::from_secs(1); + tokio::task::yield_now().await; + time::advance( + configured_timeout + + WORKFLOW_INTERRUPT_SETTLE_TIMEOUT + + retry_backoff_delay(1) + + configured_timeout + + WORKFLOW_INTERRUPT_SETTLE_TIMEOUT + + Duration::from_secs(1), + ) + .await; + + let result = run.await; + match result.outcome { + BackgroundWorkflowRunOutcome::Failed(error) => { + assert_eq!(error, "workflow prompt timed out after 1s".to_string()) + } + other => panic!("expected failed timeout run, got {other:?}"), + } + + let calls = client.calls.lock().expect("calls lock").clone(); + assert_eq!( + calls + .iter() + .filter(|call| call.starts_with("start_turn:")) + .count(), + 2 + ); + assert_eq!( + calls + .iter() + .filter(|call| call.starts_with("interrupt_turn:")) + .count(), + 2 + ); + assert_eq!( + calls + .iter() + .filter(|call| call.starts_with("unsubscribe_thread:")) + .count(), + 1 + ); + } + + #[tokio::test] + async fn non_manual_trigger_can_run_now_from_workflow_ui() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: after_turn + id: follow_up + jobs: [review_backlog] + +jobs: + review_backlog: + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(vec![Ok(WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + })]); + let result = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Trigger { + workflow_name: "director".to_string(), + trigger_id: "follow_up".to_string(), + phase_context: OwnedWorkflowPhaseContext::default(), + overlap_behavior: WorkflowTriggerOverlapBehavior::Queue, + }, + CancellationToken::new(), + ) + .await; + + match result.outcome { + BackgroundWorkflowRunOutcome::Completed(results) => { + assert_eq!(results.len(), 1); + assert_eq!(results[0].message.as_deref(), Some("workflow reply")); + } + other => panic!("expected completed run, got {other:?}"), + } + } + + #[tokio::test] + async fn manual_job_run_ignores_disabled_root_flag() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +jobs: + review_backlog: + enabled: false + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(vec![Ok(WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + })]); + let result = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + CancellationToken::new(), + ) + .await; + + match result.outcome { + BackgroundWorkflowRunOutcome::Completed(results) => { + assert_eq!(results.len(), 1); + assert_eq!(results[0].message.as_deref(), Some("workflow reply")); + } + other => panic!("expected completed run, got {other:?}"), + } + } + + #[tokio::test] + async fn trigger_run_fails_when_all_target_jobs_are_disabled() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +triggers: + - type: after_turn + id: follow_up + jobs: [review_backlog] + +jobs: + review_backlog: + enabled: false + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(Vec::new()); + let result = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Trigger { + workflow_name: "director".to_string(), + trigger_id: "follow_up".to_string(), + phase_context: OwnedWorkflowPhaseContext::default(), + overlap_behavior: WorkflowTriggerOverlapBehavior::Queue, + }, + CancellationToken::new(), + ) + .await; + + match result.outcome { + BackgroundWorkflowRunOutcome::Failed(error) => assert_eq!( + error, + "workflow `director/follow_up` did not run any enabled jobs".to_string() + ), + other => panic!("expected failed run, got {other:?}"), + } + } + + #[tokio::test] + async fn cancellation_interrupts_active_workflow_turn() { + let tempdir = tempdir().expect("tempdir"); + let workflows_dir = tempdir.path().join(".codex/workflows"); + std::fs::create_dir_all(&workflows_dir).expect("workflow dir"); + std::fs::write( + workflows_dir.join("manual.yaml"), + r#"name: director + +jobs: + review_backlog: + steps: + - prompt: summarize the backlog +"#, + ) + .expect("workflow fixture"); + let client = FakeWorkflowRuntimeClient::new(vec![ + Ok(WorkflowTurnState { + status: TurnStatus::InProgress, + error: None, + last_agent_message: None, + }), + Ok(WorkflowTurnState { + status: TurnStatus::Interrupted, + error: None, + last_agent_message: None, + }), + ]); + let cancellation = CancellationToken::new(); + let run = run_background_workflow( + &client, + tempdir.path().to_path_buf(), + BackgroundWorkflowRunTarget::Job { + workflow_name: "director".to_string(), + job_name: "review_backlog".to_string(), + }, + cancellation.clone(), + ); + tokio::pin!(run); + let result = tokio::select! { + result = &mut run => result, + _ = sleep(Duration::from_millis(10)) => { + cancellation.cancel(); + run.await + } + }; + + assert!(matches!( + result.outcome, + BackgroundWorkflowRunOutcome::Cancelled + )); + assert_eq!( + client.calls.lock().expect("calls lock").clone(), + vec![ + "start_workflow_thread".to_string(), + "start_turn:thr_workflow:Workflow: director\nTrigger: job:review_backlog\nJob: review_backlog\n\nCurrent workflow prompt:\nsummarize the backlog".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "interrupt_turn:thr_workflow:turn_workflow".to_string(), + "read_turn:thr_workflow:turn_workflow".to_string(), + "unsubscribe_thread:thr_workflow".to_string(), + ] + ); + } + + #[tokio::test] + async fn read_turn_uses_forwarded_notifications_for_ephemeral_threads() { + let temp_dir = tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("embedded app server"); + let client = AppServerWorkflowRuntimeClient::new( + &app_server, + Arc::new(tokio::sync::Mutex::new(HashMap::new())), + config, + /*primary_thread_id*/ None, + ); + let (sender, receiver) = mpsc::unbounded_channel(); + let thread = WorkflowThreadSession { + thread_id: "thr_workflow".to_string(), + cwd: PathBuf::from("/tmp/workflow"), + notifications: Arc::new(tokio::sync::Mutex::new(receiver)), + }; + + sender + .send(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "workflow reply".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: thread.thread_id.clone(), + turn_id: "turn-1".to_string(), + }, + )) + .expect("item completed notification"); + sender + .send(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: thread.thread_id.clone(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + error: None, + status: TurnStatus::Completed, + }, + }, + )) + .expect("turn completed notification"); + + let state = client + .read_turn(&thread, "turn-1".to_string()) + .await + .expect("read workflow turn"); + + assert_eq!( + state, + WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + } + ); + } + + #[tokio::test] + async fn read_turn_retries_include_turns_errors_for_ephemeral_threads() { + let temp_dir = tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let workflow_thread_notification_channels = + Arc::new(tokio::sync::Mutex::new(HashMap::new())); + let app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("embedded app server"); + let client = AppServerWorkflowRuntimeClient::new( + &app_server, + workflow_thread_notification_channels.clone(), + config, + /*primary_thread_id*/ None, + ); + let thread = client + .start_workflow_thread() + .await + .expect("start workflow thread"); + + let sender = workflow_thread_notification_channels + .lock() + .await + .get(&ThreadId::from_string(&thread.thread_id).expect("workflow thread id")) + .cloned() + .expect("workflow notification sender"); + + let read_turn = { + let client = client; + let thread = thread.clone(); + tokio::spawn(async move { client.read_turn(&thread, "turn-1".to_string()).await }) + }; + + sleep(Duration::from_millis(10)).await; + sender + .send(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "workflow reply".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: thread.thread_id.clone(), + turn_id: "turn-1".to_string(), + }, + )) + .expect("item completed notification"); + sender + .send(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: thread.thread_id.clone(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + error: None, + status: TurnStatus::Completed, + }, + }, + )) + .expect("turn completed notification"); + + let state = read_turn + .await + .expect("join read_turn task") + .expect("read workflow turn"); + assert_eq!( + state, + WorkflowTurnState { + status: TurnStatus::Completed, + error: None, + last_agent_message: Some("workflow reply".to_string()), + } + ); + } + + #[tokio::test] + async fn start_workflow_thread_starts_fresh_thread_when_primary_thread_is_unmaterialized() { + let temp_dir = tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("embedded app server"); + let primary = app_server + .start_thread(&config) + .await + .expect("start primary thread"); + assert!( + primary + .session + .rollout_path + .as_ref() + .is_some_and(|path| !path.exists()) + ); + let workflow_thread_notification_channels = + Arc::new(tokio::sync::Mutex::new(HashMap::new())); + + let client = AppServerWorkflowRuntimeClient::new( + &app_server, + workflow_thread_notification_channels.clone(), + config, + Some(primary.session.thread_id), + ); + let workflow_thread = client + .start_workflow_thread() + .await + .expect("start workflow thread"); + + assert_ne!( + workflow_thread.thread_id, + primary.session.thread_id.to_string() + ); + let registered_thread_ids = workflow_thread_notification_channels + .lock() + .await + .keys() + .copied() + .collect::>(); + assert_eq!(registered_thread_ids.len(), 1); + assert_eq!( + registered_thread_ids[0].to_string(), + workflow_thread.thread_id + ); + } +} diff --git a/codex-rs/tui/src/app/workflow_scheduler.rs b/codex-rs/tui/src/app/workflow_scheduler.rs new file mode 100644 index 000000000..33d8710a2 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_scheduler.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; +use std::collections::VecDeque; +use std::time::Duration; +use tokio::task::JoinHandle; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; + +use super::workflow_runtime::BackgroundWorkflowRunTarget; +use super::workflow_runtime::OwnedWorkflowPhaseContext; + +pub(crate) struct BackgroundWorkflowRunState { + pub(crate) label: String, + pub(crate) is_trigger: bool, + pub(crate) target: BackgroundWorkflowRunTarget, + pub(crate) cancellation: CancellationToken, + pub(crate) handle: JoinHandle<()>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct QueuedWorkflowTriggerRun { + pub(crate) workflow_name: String, + pub(crate) trigger_id: String, + pub(crate) phase_context: OwnedWorkflowPhaseContext, +} + +#[derive(Default)] +pub(crate) struct WorkflowSchedulerState { + running_workflows: HashMap, + queued_trigger_runs: VecDeque, + next_background_run_id: u64, +} + +impl WorkflowSchedulerState { + pub(crate) fn next_background_run_id( + &mut self, + workflow_name: &str, + target_name: &str, + ) -> String { + self.next_background_run_id = self.next_background_run_id.saturating_add(1); + format!( + "{workflow_name}/{target_name}#{}", + self.next_background_run_id + ) + } + + pub(crate) fn register_background_workflow_run( + &mut self, + run_id: String, + target: BackgroundWorkflowRunTarget, + cancellation: CancellationToken, + handle: JoinHandle<()>, + ) { + let label = target.label(); + let is_trigger = matches!(target, BackgroundWorkflowRunTarget::Trigger { .. }); + self.running_workflows.insert( + run_id, + BackgroundWorkflowRunState { + label, + is_trigger, + target, + cancellation, + handle, + }, + ); + } + + pub(crate) fn take_background_workflow_run( + &mut self, + run_id: &str, + ) -> Option { + self.running_workflows.remove(run_id) + } + + pub(crate) fn background_workflow_labels(&self) -> Vec { + let mut labels = self + .running_workflows + .values() + .map(|run| run.label.clone()) + .collect::>(); + labels.sort(); + labels + } + + pub(crate) fn queued_trigger_labels(&self) -> Vec { + self.queued_trigger_runs + .iter() + .map(|run| format!("{} · {}", run.workflow_name, run.trigger_id)) + .collect() + } + + pub(crate) fn has_running_trigger_run(&self) -> bool { + self.running_workflows.values().any(|run| run.is_trigger) + } + + pub(crate) fn has_active_trigger_run(&self, workflow_name: &str, trigger_id: &str) -> bool { + self.running_workflows.values().any(|run| { + matches!( + &run.target, + BackgroundWorkflowRunTarget::Trigger { + workflow_name: active_workflow_name, + trigger_id: active_trigger_id, + .. + } if active_workflow_name == workflow_name && active_trigger_id == trigger_id + ) + }) + } + + pub(crate) fn has_queued_trigger_run(&self, workflow_name: &str, trigger_id: &str) -> bool { + self.queued_trigger_runs + .iter() + .any(|run| run.workflow_name == workflow_name && run.trigger_id == trigger_id) + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn enqueue_trigger_run( + &mut self, + workflow_name: String, + trigger_id: String, + phase_context: OwnedWorkflowPhaseContext, + ) { + self.queued_trigger_runs + .push_back(QueuedWorkflowTriggerRun { + workflow_name, + trigger_id, + phase_context, + }); + } + + pub(crate) fn dequeue_trigger_run(&mut self) -> Option { + self.queued_trigger_runs.pop_front() + } + + pub(crate) async fn stop_active_workflow_runs(&mut self) -> usize { + let runs = self + .running_workflows + .drain() + .map(|(_, run)| run) + .collect::>(); + let stopped_count = runs.len(); + for mut run in runs { + run.cancellation.cancel(); + if timeout(Duration::from_secs(1), &mut run.handle) + .await + .is_err() + { + run.handle.abort(); + let _ = run.handle.await; + } + } + self.queued_trigger_runs.clear(); + stopped_count + } +} diff --git a/codex-rs/tui/src/app/workflow_yaml.rs b/codex-rs/tui/src/app/workflow_yaml.rs new file mode 100644 index 000000000..a2ae39b45 --- /dev/null +++ b/codex-rs/tui/src/app/workflow_yaml.rs @@ -0,0 +1,242 @@ +use serde_yaml::Mapping; +use serde_yaml::Value as YamlValue; + +pub(crate) fn serialize_yaml_value(value: &YamlValue) -> Result { + let mut output = String::new(); + write_yaml_value(&mut output, value, 0)?; + Ok(output) +} + +fn write_yaml_value(output: &mut String, value: &YamlValue, indent: usize) -> Result<(), String> { + match value { + YamlValue::Mapping(mapping) => write_mapping(output, mapping, indent), + YamlValue::Sequence(sequence) => write_sequence(output, sequence, indent), + YamlValue::String(text) if text.contains('\n') => { + write_block_scalar(output, "", text, indent); + Ok(()) + } + _ => { + output.push_str(&" ".repeat(indent)); + output.push_str(&serialize_inline_value(value)?); + output.push('\n'); + Ok(()) + } + } +} + +fn write_mapping(output: &mut String, mapping: &Mapping, indent: usize) -> Result<(), String> { + for (key, value) in mapping { + write_mapping_entry(output, key, value, indent)?; + } + Ok(()) +} + +fn write_mapping_entry( + output: &mut String, + key: &YamlValue, + value: &YamlValue, + indent: usize, +) -> Result<(), String> { + let prefix = format!("{}{}:", " ".repeat(indent), serialize_yaml_key(key)?); + match value { + YamlValue::Mapping(mapping) if mapping.is_empty() => { + output.push_str(&prefix); + output.push_str(" {}\n"); + } + YamlValue::Sequence(sequence) if sequence.is_empty() => { + output.push_str(&prefix); + output.push_str(" []\n"); + } + YamlValue::Mapping(mapping) => { + output.push_str(&prefix); + output.push('\n'); + write_mapping(output, mapping, indent + 2)?; + } + YamlValue::Sequence(sequence) => { + output.push_str(&prefix); + output.push('\n'); + write_sequence(output, sequence, indent + 2)?; + } + YamlValue::String(text) if text.contains('\n') => { + write_block_scalar(output, &prefix, text, indent + 2); + } + _ => { + output.push_str(&prefix); + output.push(' '); + output.push_str(&serialize_inline_value(value)?); + output.push('\n'); + } + } + Ok(()) +} + +fn write_sequence( + output: &mut String, + sequence: &[YamlValue], + indent: usize, +) -> Result<(), String> { + for value in sequence { + match value { + YamlValue::Mapping(mapping) => write_sequence_mapping_item(output, mapping, indent)?, + YamlValue::Sequence(sequence) if sequence.is_empty() => { + output.push_str(&" ".repeat(indent)); + output.push_str("- []\n"); + } + YamlValue::Sequence(sequence) => { + output.push_str(&" ".repeat(indent)); + output.push_str("-\n"); + write_sequence(output, sequence, indent + 2)?; + } + YamlValue::String(text) if text.contains('\n') => { + write_block_scalar( + output, + &format!("{}-", " ".repeat(indent)), + text, + indent + 2, + ); + } + _ => { + output.push_str(&" ".repeat(indent)); + output.push_str("- "); + output.push_str(&serialize_inline_value(value)?); + output.push('\n'); + } + } + } + Ok(()) +} + +fn write_sequence_mapping_item( + output: &mut String, + mapping: &Mapping, + indent: usize, +) -> Result<(), String> { + if mapping.is_empty() { + output.push_str(&" ".repeat(indent)); + output.push_str("- {}\n"); + return Ok(()); + } + + let mut entries = mapping.iter(); + let Some((first_key, first_value)) = entries.next() else { + return Ok(()); + }; + let prefix = format!( + "{}- {}:", + " ".repeat(indent), + serialize_yaml_key(first_key)? + ); + match first_value { + YamlValue::Mapping(mapping) if mapping.is_empty() => { + output.push_str(&prefix); + output.push_str(" {}\n"); + } + YamlValue::Sequence(sequence) if sequence.is_empty() => { + output.push_str(&prefix); + output.push_str(" []\n"); + } + YamlValue::Mapping(mapping) => { + output.push_str(&prefix); + output.push('\n'); + write_mapping(output, mapping, indent + 4)?; + } + YamlValue::Sequence(sequence) => { + output.push_str(&prefix); + output.push('\n'); + write_sequence(output, sequence, indent + 4)?; + } + YamlValue::String(text) if text.contains('\n') => { + write_block_scalar(output, &prefix, text, indent + 4); + } + _ => { + output.push_str(&prefix); + output.push(' '); + output.push_str(&serialize_inline_value(first_value)?); + output.push('\n'); + } + } + + for (key, value) in entries { + write_mapping_entry(output, key, value, indent + 2)?; + } + + Ok(()) +} + +fn write_block_scalar(output: &mut String, prefix: &str, text: &str, content_indent: usize) { + if !prefix.is_empty() { + output.push_str(prefix); + output.push(' '); + } + output.push_str(block_scalar_header(text)); + output.push('\n'); + + let trailing_newlines = text.chars().rev().take_while(|ch| *ch == '\n').count(); + let content = if trailing_newlines == 0 { + text + } else { + &text[..text.len() - trailing_newlines] + }; + + if content.is_empty() { + output.push_str(&" ".repeat(content_indent)); + output.push('\n'); + return; + } + + for line in content.split('\n') { + output.push_str(&" ".repeat(content_indent)); + output.push_str(line); + output.push('\n'); + } +} + +fn block_scalar_header(text: &str) -> &'static str { + let trailing_newlines = text.chars().rev().take_while(|ch| *ch == '\n').count(); + match trailing_newlines { + 0 => "|-", + 1 => "|", + _ => "|+", + } +} + +fn serialize_yaml_key(value: &YamlValue) -> Result { + match value { + YamlValue::String(text) if text.contains('\n') => { + Err("workflow yaml keys cannot be multiline strings".to_string()) + } + _ => serialize_inline_value(value), + } +} + +fn serialize_inline_value(value: &YamlValue) -> Result { + let rendered = serde_yaml::to_string(value).map_err(|err| err.to_string())?; + Ok(rendered.trim_start_matches("---\n").trim_end().to_string()) +} + +#[cfg(test)] +mod tests { + use super::serialize_yaml_value; + use pretty_assertions::assert_eq; + use serde_yaml::Value as YamlValue; + + #[test] + fn serializes_multiline_strings_as_block_scalars() { + let value: YamlValue = serde_yaml::from_str( + r#"steps: + - prompt: "line one\nline two" +"#, + ) + .expect("yaml"); + + assert_eq!( + serialize_yaml_value(&value).expect("serialize"), + concat!( + "steps:\n", + " - prompt: |-\n", + " line one\n", + " line two\n", + ) + ); + } +} diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index db1149e76..d2bac10b9 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -459,6 +459,25 @@ impl App { tui.frame_requester().schedule_frame(); } + pub(crate) fn undo_last_user_message(&mut self) -> bool { + self.reset_backtrack_state(); + + let Some(nth_user_message) = user_count(&self.transcript_cells).checked_sub(1) else { + self.chat_widget + .add_error_message("No prior user message to restore.".to_string()); + return false; + }; + + let Some(selection) = self.backtrack_selection(nth_user_message) else { + self.chat_widget + .add_error_message("Failed to restore the last user message.".to_string()); + return false; + }; + + self.apply_backtrack_rollback(selection); + true + } + pub(crate) fn handle_backtrack_rollback_succeeded(&mut self, num_turns: u32) { if self.backtrack.pending_rollback.is_some() { self.finish_pending_backtrack(); @@ -507,8 +526,9 @@ impl App { } fn backtrack_selection(&self, nth_user_message: usize) -> Option { - let base_id = self.backtrack.base_id?; - if self.chat_widget.thread_id() != Some(base_id) { + if let Some(base_id) = self.backtrack.base_id + && self.chat_widget.thread_id() != Some(base_id) + { return None; } diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297..6e614cc35 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -279,6 +279,10 @@ impl AppCommand { self.0 } + pub(crate) fn from_core(op: Op) -> Self { + Self(op) + } + pub(crate) fn is_review(&self) -> bool { matches!(self.view(), AppCommandView::Review { .. }) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index d727963f0..380a710b2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -16,7 +16,10 @@ use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::Turn as AppServerTurn; use codex_chatgpt::connectors::AppInfo; +use codex_clawbot::ClawbotTurnMode; +use codex_clawbot::ProviderEvent as ClawbotProviderEvent; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; @@ -26,10 +29,13 @@ use codex_protocol::protocol::RateLimitSnapshot; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; +use crate::app::workflow_runtime::BackgroundWorkflowRunResult; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; +use crate::display_preferences::DisplayPreferenceKey; use crate::history_cell::HistoryCell; +use crate::profile_router::ProfileFallbackAction; use codex_config::types::ApprovalsReviewer; use codex_features::Feature; @@ -62,6 +68,28 @@ impl RealtimeAudioDeviceKind { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RuntimeProfileTarget { + Default, + Named(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClawbotFeishuConfigField { + AppId, + AppSecret, + VerificationToken, + EncryptKey, + BotOpenId, + BotUserId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClawbotForwardingChannel { + Inbound, + Outbound, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxEnableMode { @@ -69,6 +97,56 @@ pub(crate) enum WindowsSandboxEnableMode { Legacy, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorkflowControlsDestination { + Root, + File { + workflow_path: PathBuf, + }, + Jobs { + workflow_path: PathBuf, + }, + Job { + workflow_path: PathBuf, + job_name: String, + }, + ManualTriggers { + workflow_path: PathBuf, + }, + ManualTrigger { + workflow_path: PathBuf, + trigger_id: String, + }, + TriggerType { + workflow_path: PathBuf, + trigger_id: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowJobEditableField { + Needs, + Steps, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowTriggerEditableField { + Id, + Jobs, + Parameter, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WorkflowTriggerType { + Manual, + BeforeTurn, + AfterTurn, + FileWatch, + Idle, + Interval, + Cron, +} + #[derive(Debug, Clone)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) struct ConnectorsSnapshot { @@ -89,6 +167,12 @@ pub(crate) enum AppEvent { op: Op, }, + /// Submit a workflow-generated follow-up back to the primary thread. + SubmitWorkflowFollowup { + thread_id: ThreadId, + op: Op, + }, + /// Deliver a synthetic history lookup response to a specific thread channel. ThreadHistoryEntryResponse { thread_id: ThreadId, @@ -105,9 +189,37 @@ pub(crate) enum AppEvent { /// Open the resume picker inside the running TUI session. OpenResumePicker, + /// Open the local TUI display preferences panel. + OpenDisplayPreferencesPanel, + + /// Open the routed profile management panel. + OpenProfileManagementPanel, + + /// Edit the fallback route for named profiles. + EditProfileFallbackConfig, + + /// Open thread-specific actions for the current conversation. + OpenThreadPanel, + + /// Open the committed transcript jump picker. + OpenJumpToMessagePanel, + + /// Open the transcript overlay and jump to the selected committed cell. + JumpToTranscriptCell { + cell_index: usize, + }, + /// Fork the current session into a new thread. ForkCurrentSession, + /// Restore the last user input and roll back one committed turn. + UndoLastUserMessage, + + /// Switch the current runtime to the selected config profile. + SwitchRuntimeProfile { + target: RuntimeProfileTarget, + }, + /// Request to exit the application. /// /// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the @@ -265,7 +377,144 @@ pub(crate) enum AppEvent { }, InsertHistoryCell(Box), + ClawbotProviderEvent { + event: ClawbotProviderEvent, + }, + + /// Replay stored workflow-only transcript cells for a specific thread after its turn replay. + ReplayWorkflowHistory { + thread_id: ThreadId, + }, + + /// Final result for one background workflow execution. + BackgroundWorkflowRunCompleted { + run_id: String, + result: Box, + }, + OpenWorkflowControls, + + StartManualWorkflowTrigger { + workflow_name: String, + trigger_id: String, + }, + + StartManualWorkflowJob { + workflow_name: String, + job_name: String, + }, + + ShowWorkflowBackgroundTasks, + OpenWorkflowControlView { + destination: WorkflowControlsDestination, + }, + + CreateDefaultWorkflowTemplate, + + EditWorkflowFile { + workflow_path: PathBuf, + reopen: WorkflowControlsDestination, + }, + + ToggleWorkflowTriggerEnabled { + workflow_path: PathBuf, + trigger_id: String, + }, + + ToggleWorkflowJobEnabled { + workflow_path: PathBuf, + job_name: String, + }, + + CycleWorkflowJobContext { + workflow_path: PathBuf, + job_name: String, + }, + + CycleWorkflowJobResponse { + workflow_path: PathBuf, + job_name: String, + }, + + EditWorkflowJobField { + workflow_path: PathBuf, + job_name: String, + field: WorkflowJobEditableField, + }, + + SetWorkflowTriggerType { + workflow_path: PathBuf, + trigger_id: String, + trigger_type: WorkflowTriggerType, + }, + + EditWorkflowTriggerField { + workflow_path: PathBuf, + trigger_id: String, + field: WorkflowTriggerEditableField, + }, + + WorkflowWorkspaceFilesChanged { + changed_paths: Vec, + }, + + StartBtwDiscussion { + prompt: String, + }, + + BtwCompleted { + thread_id: ThreadId, + result: Result, + }, + + BtwInsertSummary, + + BtwInsertFull, + + BtwDiscard, + /// Retry the last turn using the routed profile fallback policy. + RetryLastUserTurnWithProfileFallback { + action: ProfileFallbackAction, + error_message: String, + }, + ClawbotTurnCompleted { + thread_id: ThreadId, + turn: AppServerTurn, + }, + + OpenClawbotManagement, + + OpenClawbotFeishuConfigPrompt { + field: ClawbotFeishuConfigField, + }, + + SaveClawbotFeishuConfigValue { + field: ClawbotFeishuConfigField, + value: String, + }, + + OpenClawbotManualBindPrompt, + + SaveClawbotManualBindSessionId { + session_id: String, + }, + + ClawbotSetTurnMode { + mode: ClawbotTurnMode, + }, + + ClawbotDisconnectCurrentThread, + + ClawbotSetCurrentThreadForwarding { + channel: ClawbotForwardingChannel, + enabled: bool, + }, + + ScanClawbotFeishuSessions, + + ClearClawbotFeishuSessions, + + RetryClawbotFeishuConnection, /// Apply rollback semantics to local transcript cells. /// /// This is emitted when rollback was not initiated by the current @@ -422,6 +671,9 @@ pub(crate) enum AppEvent { updates: Vec<(Feature, bool)>, }, + /// Toggle one local TUI display preference and persist the updated config. + ToggleDisplayPreference(DisplayPreferenceKey), + /// Update whether the full access warning prompt has been acknowledged. UpdateFullAccessWarningAcknowledged(bool), @@ -583,6 +835,8 @@ pub(crate) enum ExitMode { /// This skips `Op::Shutdown`, so any in-flight work may be dropped and /// cleanup that normally runs before `ShutdownComplete` can be missed. Immediate, + /// Exit the UI loop immediately and request the outer CLI to respawn this session. + RespawnImmediate, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6cf6c18d3..35b1161a0 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -170,8 +170,16 @@ use super::skill_popup::MentionItem; use super::skill_popup::SkillPopup; use super::slash_commands; use super::slash_commands::BuiltinCommandFlags; +use crate::bottom_pane::CustomPrompt; +use crate::bottom_pane::PROMPTS_CMD_PREFIX; use crate::bottom_pane::paste_burst::FlushResult; +use crate::bottom_pane::prompt_args::PromptSelectionAction; +use crate::bottom_pane::prompt_args::PromptSelectionMode; +use crate::bottom_pane::prompt_args::expand_custom_prompt; +use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::bottom_pane::prompt_args::prompt_argument_names; +use crate::bottom_pane::prompt_args::prompt_selection_action; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::Renderable; @@ -305,6 +313,7 @@ pub(crate) struct ChatComposer { paste_burst: PasteBurst, // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, + custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, remote_image_urls: Vec, @@ -371,7 +380,6 @@ impl ChatComposer { fast_command_enabled: self.fast_command_enabled, personality_command_enabled: self.personality_command_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, - audio_device_selection_enabled: self.audio_device_selection_enabled, allow_elevate_sandbox: self.windows_degraded_sandbox_active, } } @@ -430,6 +438,7 @@ impl ChatComposer { input_disabled_placeholder: None, paste_burst: PasteBurst::default(), disable_paste_burst: false, + custom_prompts: Vec::new(), footer_mode: FooterMode::ComposerEmpty, footer_hint_override: None, remote_image_urls: Vec::new(), @@ -480,6 +489,13 @@ impl ChatComposer { self.skills = skills; } + pub fn set_custom_prompts(&mut self, prompts: Vec) { + self.custom_prompts = prompts.clone(); + if let ActivePopup::Command(popup) = &mut self.active_popup { + popup.set_prompts(prompts); + } + } + pub fn set_plugin_mentions(&mut self, plugins: Option>) { self.plugins = plugins; self.sync_popups(); @@ -1271,26 +1287,48 @@ impl ChatComposer { KeyEvent { code: KeyCode::Tab, .. } => { - // Ensure popup filtering/selection reflects the latest composer text - // before applying completion. let first_line = self.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); - if let Some(sel) = popup.selected_item() { - let CommandItem::Builtin(cmd) = sel; - if cmd == SlashCommand::Skills { - self.textarea.set_text_clearing_elements(""); - return (InputResult::Command(cmd), true); - } + let text_elements = self.textarea.text_elements(); + let selected = { + popup.on_composer_text_change(first_line.to_string()); + popup.selected_item() + }; + if let Some(selection) = selected { + match selection { + CommandItem::Builtin(cmd) => { + if cmd == SlashCommand::Skills { + self.textarea.set_text_clearing_elements(""); + return (InputResult::Command(cmd), true); + } - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); - if !starts_with_cmd { - self.textarea - .set_text_clearing_elements(&format!("/{} ", cmd.command())); - } - if !self.textarea.text().is_empty() { - self.textarea.set_cursor(self.textarea.text().len()); + let starts_with_cmd = first_line + .trim_start() + .starts_with(&format!("/{}", cmd.command())); + if !starts_with_cmd { + self.textarea + .set_text_clearing_elements(&format!("/{} ", cmd.command())); + } + if !self.textarea.text().is_empty() { + self.textarea.set_cursor(self.textarea.text().len()); + } + } + CommandItem::UserPrompt(index) => { + if let Some(prompt) = popup.prompt(index).cloned() { + match prompt_selection_action( + &prompt, + first_line, + PromptSelectionMode::Completion, + &text_elements, + ) { + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text_clearing_elements(&text); + self.textarea.set_cursor(target); + } + PromptSelectionAction::Submit { .. } => {} + } + } + } } } (InputResult::None, true) @@ -1300,10 +1338,82 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - if let Some(sel) = popup.selected_item() { - let CommandItem::Builtin(cmd) = sel; + let mut text = self.textarea.text().to_string(); + let mut text_elements = self.textarea.text_elements(); + if !self.pending_pastes.is_empty() { + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; + } + let first_line = text.lines().next().unwrap_or(""); + if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line) + && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) + && let Some(prompt) = self + .custom_prompts + .iter() + .find(|prompt| prompt.name == prompt_name) + && let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line, &text_elements) + { + self.prune_attached_images_for_submission( + &expanded.text, + &expanded.text_elements, + ); + self.pending_pastes.clear(); self.textarea.set_text_clearing_elements(""); - return (InputResult::Command(cmd), true); + return ( + InputResult::Submitted { + text: expanded.text, + text_elements: expanded.text_elements, + }, + true, + ); + } + + if let Some(selection) = popup.selected_item() { + match selection { + CommandItem::Builtin(cmd) => { + self.textarea.set_text_clearing_elements(""); + return (InputResult::Command(cmd), true); + } + CommandItem::UserPrompt(index) => { + if let Some(prompt) = popup.prompt(index).cloned() { + match prompt_selection_action( + &prompt, + first_line, + PromptSelectionMode::Submit, + &text_elements, + ) { + PromptSelectionAction::Submit { + text, + text_elements, + } => { + self.prune_attached_images_for_submission( + &text, + &text_elements, + ); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text, + text_elements, + }, + true, + ); + } + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text_clearing_elements(&text); + self.textarea.set_cursor(target); + return (InputResult::None, true); + } + } + } + self.active_popup = ActivePopup::None; + return (InputResult::None, true); + } + } } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -2091,7 +2201,15 @@ impl ChatComposer { let is_builtin = slash_commands::find_builtin_command(name, self.builtin_command_flags()) .is_some(); - if !is_builtin { + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = + name.strip_prefix(&prompt_prefix) + .is_some_and(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }); + if !is_builtin && !is_known_prompt { let message = format!( r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# ); @@ -2111,6 +2229,31 @@ impl ChatComposer { } } + if self.slash_commands_enabled() { + let expanded_prompt = + match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.set_text_content_with_mention_bindings( + original_input.clone(), + original_text_elements, + original_local_image_paths, + original_mention_bindings, + ); + self.pending_pastes.clone_from(&original_pending_pastes); + self.textarea.set_cursor(original_input.len()); + return None; + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded.text; + text_elements = expanded.text_elements; + } + } + let actual_chars = text.chars().count(); if actual_chars > MAX_USER_INPUT_TEXT_CHARS { let message = user_input_too_large_message(actual_chars); @@ -2177,6 +2320,10 @@ impl ChatComposer { return (result, true); } + if self.try_insert_bare_custom_prompt_for_editing() { + return (InputResult::None, true); + } + // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst // and accumulate it rather than submitting or inserting immediately. // Do not treat as paste inside a slash-command context. @@ -2278,6 +2425,44 @@ impl ChatComposer { } } + pub(crate) fn try_insert_bare_custom_prompt_for_editing(&mut self) -> bool { + if !self.slash_commands_enabled() { + return false; + } + + let composer_text = self.textarea.text().to_string(); + let first_line = composer_text.lines().next().unwrap_or(""); + if composer_text.trim() != first_line.trim() { + return false; + } + + let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) else { + return false; + }; + if !rest.is_empty() { + return false; + } + + let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return false; + }; + let Some(prompt) = self + .custom_prompts + .iter() + .find(|prompt| prompt.name == prompt_name) + else { + return false; + }; + if !prompt_argument_names(&prompt.content).is_empty() { + return false; + } + + self.textarea.set_text_clearing_elements(&prompt.content); + self.textarea.set_cursor(prompt.content.len()); + self.active_popup = ActivePopup::None; + true + } + /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_slash_command_with_args(&mut self) -> Option { @@ -2977,7 +3162,18 @@ impl ChatComposer { } fn is_known_slash_name(&self, name: &str) -> bool { - slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some() + let is_builtin = + slash_commands::find_builtin_command(name, self.builtin_command_flags()).is_some(); + if is_builtin { + return true; + } + name.strip_prefix(PROMPTS_CMD_PREFIX) + .and_then(|rest| rest.strip_prefix(':')) + .is_some_and(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) } /// If the cursor is currently within a slash command on the first line, @@ -3019,7 +3215,17 @@ impl ChatComposer { return rest_after_name.is_empty(); } - slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) + if slash_commands::has_builtin_prefix(name, self.builtin_command_flags()) { + return true; + } + + let name_lower = name.to_ascii_lowercase(); + self.custom_prompts.iter().any(|prompt| { + prompt.name.to_ascii_lowercase().starts_with(&name_lower) + || format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name) + .to_ascii_lowercase() + .starts_with(&name_lower) + }) } /// Synchronize `self.command_popup` with the current text in the @@ -3079,6 +3285,7 @@ impl ChatComposer { audio_device_selection_enabled, windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, }); + command_popup.set_prompts(self.custom_prompts.clone()); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -6024,6 +6231,9 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "model") } + Some(CommandItem::UserPrompt(_)) => { + panic!("expected builtin command selection for '/mo'") + } None => panic!("no selected command for '/mo'"), }, _ => panic!("slash popup not active after typing '/mo'"), @@ -6077,12 +6287,105 @@ mod tests { Some(CommandItem::Builtin(cmd)) => { assert_eq!(cmd.command(), "resume") } + Some(CommandItem::UserPrompt(_)) => { + panic!("expected builtin command selection for '/res'") + } None => panic!("no selected command for '/res'"), }, _ => panic!("slash popup not active after typing '/res'"), } } + #[test] + fn slash_popup_custom_prompt_snapshot() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + 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_custom_prompts(vec![CustomPrompt { + name: "specialist".to_string(), + path: "/tmp/specialist.md".into(), + content: "Act as a specialist".to_string(), + description: Some("saved prompt".to_string()), + argument_hint: None, + }]); + type_chars_humanlike(&mut composer, &['/', 's', 'p', 'e', 'c']); + + let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_custom_prompt", terminal.backend()); + } + + #[test] + fn bare_custom_prompt_enter_inserts_prompt_for_editing() { + 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_custom_prompts(vec![CustomPrompt { + name: "review".to_string(), + path: "/tmp/review.md".into(), + content: "Review these changes".to_string(), + description: Some("saved prompt".to_string()), + argument_hint: None, + }]); + composer.handle_paste("/prompts:review".to_string()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(result, InputResult::None); + assert_eq!(composer.textarea.text(), "Review these changes"); + } + + #[test] + fn custom_prompt_arguments_expand_on_submit() { + 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_custom_prompts(vec![CustomPrompt { + name: "elegant".to_string(), + path: "/tmp/elegant.md".into(), + content: "Echo: $ARGUMENTS".to_string(), + description: Some("saved prompt".to_string()), + argument_hint: None, + }]); + composer.handle_paste("/prompts:elegant hello world".to_string()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + result, + InputResult::Submitted { + text: "Echo: hello world".to_string(), + text_elements: Vec::new(), + } + ); + } + fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool { std::thread::sleep(PasteBurst::recommended_active_flush_delay()); composer.flush_paste_burst_if_due() diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 8bef8ddbc..4049f077e 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -7,9 +7,12 @@ use super::scroll_state::ScrollState; use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::render_rows; use super::slash_commands; +use crate::bottom_pane::CustomPrompt; +use crate::bottom_pane::PROMPTS_CMD_PREFIX; use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; +use std::collections::HashSet; // Hide alias commands in the default popup list so each unique action appears once. // `quit` is an alias of `exit`, so we skip `quit` here. @@ -20,11 +23,13 @@ const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Appr #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { Builtin(SlashCommand), + UserPrompt(usize), } pub(crate) struct CommandPopup { command_filter: String, builtins: Vec<(&'static str, SlashCommand)>, + prompts: Vec, state: ScrollState, } @@ -36,6 +41,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, + #[allow(dead_code)] pub(crate) audio_device_selection_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, } @@ -49,7 +55,6 @@ impl From for slash_commands::BuiltinCommandFlags { fast_command_enabled: value.fast_command_enabled, personality_command_enabled: value.personality_command_enabled, realtime_conversation_enabled: value.realtime_conversation_enabled, - audio_device_selection_enabled: value.audio_device_selection_enabled, allow_elevate_sandbox: value.windows_degraded_sandbox_active, } } @@ -67,10 +72,26 @@ impl CommandPopup { Self { command_filter: String::new(), builtins, + prompts: Vec::new(), state: ScrollState::new(), } } + pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { + let exclude: HashSet = self + .builtins + .iter() + .map(|(name, _)| (*name).to_string()) + .collect(); + prompts.retain(|prompt| !exclude.contains(&prompt.name)); + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + self.prompts = prompts; + } + + pub(crate) fn prompt(&self, index: usize) -> Option<&CustomPrompt> { + self.prompts.get(index) + } + /// Update the filter string based on the current composer text. The text /// passed in is expected to start with a leading '/'. Everything after the /// *first* '/' on the *first* line becomes the active filter that is used @@ -124,6 +145,9 @@ impl CommandPopup { } out.push((CommandItem::Builtin(*cmd), None)); } + for index in 0..self.prompts.len() { + out.push((CommandItem::UserPrompt(index), None)); + } return out; } @@ -131,6 +155,7 @@ impl CommandPopup { let filter_chars = filter.chars().count(); let mut exact: Vec<(CommandItem, Option>)> = Vec::new(); let mut prefix: Vec<(CommandItem, Option>)> = Vec::new(); + let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1; let indices_for = |offset| Some((offset..offset + filter_chars).collect()); let mut push_match = @@ -157,6 +182,15 @@ impl CommandPopup { for (_, cmd) in self.builtins.iter() { push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); } + for (index, prompt) in self.prompts.iter().enumerate() { + let display = format!("{PROMPTS_CMD_PREFIX}:{}", prompt.name); + push_match( + CommandItem::UserPrompt(index), + &display, + Some(&prompt.name), + prompt_prefix_len, + ); + } out.extend(exact); out.extend(prefix); @@ -174,9 +208,21 @@ impl CommandPopup { matches .into_iter() .map(|(item, indices)| { - let CommandItem::Builtin(cmd) = item; - let name = format!("/{}", cmd.command()); - let description = cmd.description().to_string(); + let (name, description) = match item { + CommandItem::Builtin(cmd) => { + (format!("/{}", cmd.command()), cmd.description().to_string()) + } + CommandItem::UserPrompt(index) => { + let prompt = &self.prompts[index]; + ( + format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), + prompt + .description + .clone() + .unwrap_or_else(|| "send saved prompt".to_string()), + ) + } + }; GenericDisplayRow { name, name_prefix_spans: Vec::new(), @@ -249,6 +295,7 @@ mod tests { let matches = popup.filtered_items(); let has_init = matches.iter().any(|item| match item { CommandItem::Builtin(cmd) => cmd.command() == "init", + CommandItem::UserPrompt(_) => false, }); assert!( has_init, @@ -266,6 +313,9 @@ mod tests { let selected = popup.selected_item(); match selected { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), + Some(CommandItem::UserPrompt(_)) => { + panic!("expected a built-in command for exact match") + } None => panic!("expected a selected command for exact match"), } } @@ -277,6 +327,9 @@ mod tests { let matches = popup.filtered_items(); match matches.first() { Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), + Some(CommandItem::UserPrompt(_)) => { + panic!("expected a built-in command for '/mo'") + } None => panic!("expected at least one match for '/mo'"), } } @@ -291,6 +344,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); assert_eq!(cmds, vec!["model", "mention", "mcp"]); @@ -306,6 +360,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); assert!( @@ -336,6 +391,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); assert!( @@ -407,6 +463,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); assert!( @@ -436,7 +493,7 @@ mod tests { } #[test] - fn settings_command_hidden_when_audio_device_selection_is_disabled() { + fn settings_command_remains_visible_when_audio_device_selection_is_disabled() { let mut popup = CommandPopup::new(CommandPopupFlags { collaboration_modes_enabled: false, connectors_enabled: false, @@ -447,19 +504,20 @@ mod tests { audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, }); - popup.on_composer_text_change("/aud".to_string()); + popup.on_composer_text_change("/set".to_string()); let cmds: Vec<&str> = popup .filtered_items() .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); assert!( - !cmds.contains(&"settings"), - "expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}" + cmds.contains(&"settings"), + "expected '/settings' to stay visible when audio device selection is disabled, got {cmds:?}" ); } @@ -471,6 +529,7 @@ mod tests { .into_iter() .map(|item| match item { CommandItem::Builtin(cmd) => cmd.command(), + CommandItem::UserPrompt(_) => "", }) .collect(); @@ -479,4 +538,19 @@ mod tests { "expected no /debug* command in popup menu, got {cmds:?}" ); } + + #[test] + fn custom_prompt_matches_by_name() { + let mut popup = CommandPopup::new(CommandPopupFlags::default()); + popup.set_prompts(vec![CustomPrompt { + name: "specialist".to_string(), + path: "/tmp/specialist.md".into(), + content: "Act as a specialist".to_string(), + description: Some("saved prompt".to_string()), + argument_hint: None, + }]); + popup.on_composer_text_change("/spec".to_string()); + + assert_eq!(popup.selected_item(), Some(CommandItem::UserPrompt(0))); + } } diff --git a/codex-rs/tui/src/bottom_pane/custom_prompts.rs b/codex-rs/tui/src/bottom_pane/custom_prompts.rs new file mode 100644 index 000000000..ea61359be --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/custom_prompts.rs @@ -0,0 +1,153 @@ +use std::collections::HashSet; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +/// Base namespace for custom prompt slash commands (without trailing colon). +pub(crate) const PROMPTS_CMD_PREFIX: &str = "prompts"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CustomPrompt { + pub(crate) name: String, + pub(crate) path: PathBuf, + pub(crate) content: String, + pub(crate) description: Option, + pub(crate) argument_hint: Option, +} + +pub(crate) fn discover_prompts_in(dir: &Path) -> Vec { + discover_prompts_in_excluding(dir, &HashSet::new()) +} + +fn discover_prompts_in_excluding(dir: &Path, exclude: &HashSet) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + + let mut prompts = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let is_md = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); + if !is_md { + continue; + } + let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + if exclude.contains(name) { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + let (description, argument_hint, body) = parse_frontmatter(&content); + prompts.push(CustomPrompt { + name: name.to_string(), + path, + content: body, + description, + argument_hint, + }); + } + + prompts.sort_by(|a, b| a.name.cmp(&b.name)); + prompts +} + +fn parse_frontmatter(content: &str) -> (Option, Option, String) { + let mut segments = content.split_inclusive('\n'); + let Some(first_segment) = segments.next() else { + return (None, None, String::new()); + }; + let first_line = first_segment.trim_end_matches(['\r', '\n']); + if first_line.trim() != "---" { + return (None, None, content.to_string()); + } + + let mut description = None; + let mut argument_hint = None; + let mut frontmatter_closed = false; + let mut consumed = first_segment.len(); + + for segment in segments { + let line = segment.trim_end_matches(['\r', '\n']); + let trimmed = line.trim(); + + if trimmed == "---" { + frontmatter_closed = true; + consumed += segment.len(); + break; + } + + if trimmed.is_empty() || trimmed.starts_with('#') { + consumed += segment.len(); + continue; + } + + if let Some((key, value)) = trimmed.split_once(':') { + let normalized_key = key.trim().to_ascii_lowercase(); + let mut normalized_value = value.trim().to_string(); + if normalized_value.len() >= 2 { + let bytes = normalized_value.as_bytes(); + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') { + normalized_value = normalized_value[1..normalized_value.len() - 1].to_string(); + } + } + match normalized_key.as_str() { + "description" => description = Some(normalized_value), + "argument-hint" | "argument_hint" => argument_hint = Some(normalized_value), + _ => {} + } + } + + consumed += segment.len(); + } + + if !frontmatter_closed { + return (None, None, content.to_string()); + } + + let body = if consumed >= content.len() { + String::new() + } else { + content[consumed..].to_string() + }; + (description, argument_hint, body) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parse_frontmatter_extracts_description_and_hint() { + let content = "---\n\ +description: review prompt\n\ +argument-hint: USER=\"...\"\n\ +---\n\ +Body text\n"; + + let (description, argument_hint, body) = parse_frontmatter(content); + assert_eq!(description, Some("review prompt".to_string())); + assert_eq!(argument_hint, Some("USER=\"...\"".to_string())); + assert_eq!(body, "Body text\n".to_string()); + } + + #[test] + fn parse_frontmatter_leaves_plain_markdown_unchanged() { + let content = "# Prompt\n\nBody\n"; + let (description, argument_hint, body) = parse_frontmatter(content); + assert_eq!(description, None); + assert_eq!(argument_hint, None); + assert_eq!(body, content.to_string()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 194f662b3..875bf1bc5 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -756,6 +756,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut paste_image = Line::from(""); let mut external_editor = Line::from(""); let mut edit_previous = Line::from(""); + let mut undo_last_message = Line::from(""); + let mut copy_latest_output = Line::from(""); + let mut respawn_current_session = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); let mut change_mode = Line::from(""); @@ -771,6 +774,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::PasteImage => paste_image = text, ShortcutId::ExternalEditor => external_editor = text, ShortcutId::EditPrevious => edit_previous = text, + ShortcutId::UndoLastMessage => undo_last_message = text, + ShortcutId::CopyLatestOutput => copy_latest_output = text, + ShortcutId::RespawnCurrentSession => respawn_current_session = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, ShortcutId::ChangeMode => change_mode = text, @@ -787,6 +793,9 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { paste_image, external_editor, edit_previous, + undo_last_message, + copy_latest_output, + respawn_current_session, quit, ]; if change_mode.width() > 0 { @@ -869,6 +878,9 @@ enum ShortcutId { PasteImage, ExternalEditor, EditPrevious, + UndoLastMessage, + CopyLatestOutput, + RespawnCurrentSession, Quit, ShowTranscript, ChangeMode, @@ -911,6 +923,7 @@ struct ShortcutDescriptor { id: ShortcutId, bindings: &'static [ShortcutBinding], prefix: &'static str, + display_label: Option<&'static str>, label: &'static str, } @@ -921,7 +934,12 @@ impl ShortcutDescriptor { fn overlay_entry(&self, state: ShortcutsState) -> Option> { let binding = self.binding_for(state)?; - let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]); + let mut line = Line::from(vec![self.prefix.into()]); + if let Some(display_label) = self.display_label { + line.push_span(display_label); + } else { + line.push_span(Span::from(binding.key)); + } match self.id { ShortcutId::EditPrevious => { if state.esc_backtrack_hint { @@ -948,6 +966,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " for commands", }, ShortcutDescriptor { @@ -957,6 +976,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " for shell commands", }, ShortcutDescriptor { @@ -972,6 +992,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ], prefix: "", + display_label: None, label: " for newline", }, ShortcutDescriptor { @@ -981,6 +1002,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to queue message", }, ShortcutDescriptor { @@ -990,6 +1012,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " for file paths", }, ShortcutDescriptor { @@ -1007,6 +1030,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ], prefix: "", + display_label: None, label: " to paste images", }, ShortcutDescriptor { @@ -1016,6 +1040,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to edit in external editor", }, ShortcutDescriptor { @@ -1025,8 +1050,39 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: "", }, + ShortcutDescriptor { + id: ShortcutId::UndoLastMessage, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('x')), + condition: DisplayCondition::Always, + }], + prefix: "", + display_label: Some("ctrl + x then ctrl + u"), + label: " to undo last message", + }, + ShortcutDescriptor { + id: ShortcutId::CopyLatestOutput, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('x')), + condition: DisplayCondition::Always, + }], + prefix: "", + display_label: Some("ctrl + x then ctrl + y"), + label: " to copy last output", + }, + ShortcutDescriptor { + id: ShortcutId::RespawnCurrentSession, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('x')), + condition: DisplayCondition::Always, + }], + prefix: "", + display_label: Some("ctrl + x then ctrl + r"), + label: " to restart Codex", + }, ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { @@ -1034,6 +1090,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to exit", }, ShortcutDescriptor { @@ -1043,6 +1100,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::Always, }], prefix: "", + display_label: None, label: " to view transcript", }, ShortcutDescriptor { @@ -1052,6 +1110,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ condition: DisplayCondition::WhenCollaborationModesEnabled, }], prefix: "", + display_label: None, label: " to change mode", }, ]; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index da5e2421f..3bee52817 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -26,7 +26,6 @@ use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; -use bottom_pane_view::BottomPaneView; use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_features::Features; @@ -59,6 +58,7 @@ pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; pub(crate) use request_user_input::RequestUserInputOverlay; mod bottom_pane_view; +pub(crate) use bottom_pane_view::BottomPaneView; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LocalImageAttachment { @@ -77,10 +77,11 @@ mod chat_composer; mod chat_composer_history; mod command_popup; pub mod custom_prompt_view; +mod custom_prompts; mod experimental_features_view; mod file_search_popup; mod footer; -mod list_selection_view; +pub(crate) mod list_selection_view; mod prompt_args; mod skill_popup; mod skills_toggle_view; @@ -149,8 +150,13 @@ pub(crate) use chat_composer::InputResult; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; +pub(crate) use custom_prompts::CustomPrompt; +pub(crate) use custom_prompts::PROMPTS_CMD_PREFIX; +pub(crate) use custom_prompts::discover_prompts_in; pub(crate) use experimental_features_view::ExperimentalFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; +#[cfg(test)] +pub(crate) use list_selection_view::ListSelectionView; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; @@ -249,6 +255,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_custom_prompts(&mut self, prompts: Vec) { + self.composer.set_custom_prompts(prompts); + self.request_redraw(); + } + /// Update image-paste behavior for the active composer and repaint immediately. /// /// Callers use this to keep composer affordances aligned with model capabilities. @@ -831,17 +842,26 @@ impl BottomPane { self.pending_thread_approvals.threads() } - /// Update the unified-exec process set and refresh whichever summary surface is active. + /// Update the unified-exec activity set and refresh whichever summary surface is active. /// /// The summary may be displayed inline in the status row or as a dedicated /// footer row depending on whether a status indicator is currently visible. - pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec) { - if self.unified_exec_footer.set_processes(processes) { + pub(crate) fn set_unified_exec_activity( + &mut self, + processes: Vec, + workflows: Vec, + ) { + if self.unified_exec_footer.set_activity(processes, workflows) { self.sync_status_inline_message(); self.request_redraw(); } } + #[cfg(test)] + pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec) { + self.set_unified_exec_activity(processes, Vec::new()); + } + /// Copy unified-exec summary text into the active status row, if any. /// /// This keeps status-line inline text synchronized without forcing the diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 7c816d5ee..5f3085c25 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,3 +1,18 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::LazyLock; + +use regex_lite::Regex; +use shlex::Shlex; + +use super::custom_prompts::CustomPrompt; +use super::custom_prompts::PROMPTS_CMD_PREFIX; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; + +static PROMPT_ARG_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort())); + /// Parse a first-line slash command of the form `/name `. /// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/` /// and contains a non-empty name; otherwise returns `None`. @@ -23,3 +38,638 @@ pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> { let rest_offset = rest_start_in_stripped + 1; Some((name, rest, rest_offset)) } + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PromptArg { + pub(crate) text: String, + pub(crate) text_elements: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PromptExpansion { + pub(crate) text: String, + pub(crate) text_elements: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum PromptSelectionMode { + Completion, + Submit, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum PromptSelectionAction { + Insert { + text: String, + cursor: Option, + }, + Submit { + text: String, + text_elements: Vec, + }, +} + +#[derive(Debug)] +pub(crate) enum PromptArgsError { + MissingAssignment { token: String }, + MissingKey { token: String }, +} + +impl PromptArgsError { + fn describe(&self, command: &str) -> String { + match self { + PromptArgsError::MissingAssignment { token } => format!( + "Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces." + ), + PromptArgsError::MissingKey { token } => { + format!("Could not parse {command}: expected a name before '=' in '{token}'.") + } + } + } +} + +#[derive(Debug)] +pub(crate) enum PromptExpansionError { + Args { + command: String, + error: PromptArgsError, + }, + MissingArgs { + command: String, + missing: Vec, + }, +} + +impl PromptExpansionError { + pub(crate) fn user_message(&self) -> String { + match self { + PromptExpansionError::Args { command, error } => error.describe(command), + PromptExpansionError::MissingArgs { command, missing } => { + let list = missing.join(", "); + format!( + "Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)." + ) + } + } + } +} + +pub(crate) fn prompt_argument_names(content: &str) -> Vec { + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for matched in PROMPT_ARG_REGEX.find_iter(content) { + if matched.start() > 0 && content.as_bytes()[matched.start() - 1] == b'$' { + continue; + } + let name = &content[matched.start() + 1..matched.end()]; + if name == "ARGUMENTS" { + continue; + } + let name = name.to_string(); + if seen.insert(name.clone()) { + names.push(name); + } + } + names +} + +pub(crate) fn prompt_has_numeric_placeholders(content: &str) -> bool { + if content.contains("$ARGUMENTS") { + return true; + } + let bytes = content.as_bytes(); + let mut index = 0; + while index + 1 < bytes.len() { + if bytes[index] == b'$' && (b'1'..=b'9').contains(&bytes[index + 1]) { + return true; + } + index += 1; + } + false +} + +pub(crate) fn prompt_selection_action( + prompt: &CustomPrompt, + first_line: &str, + mode: PromptSelectionMode, + text_elements: &[TextElement], +) -> PromptSelectionAction { + let named_args = prompt_argument_names(&prompt.content); + let has_numeric = prompt_has_numeric_placeholders(&prompt.content); + + match mode { + PromptSelectionMode::Completion => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + PromptSelectionAction::Insert { + cursor: Some(text.len()), + text, + } + } + PromptSelectionMode::Submit => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + if let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line, text_elements) + { + return PromptSelectionAction::Submit { + text: expanded.text, + text_elements: expanded.text_elements, + }; + } + return PromptSelectionAction::Insert { + text: format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name), + cursor: None, + }; + } + PromptSelectionAction::Insert { + text: prompt.content.clone(), + cursor: Some(prompt.content.len()), + } + } + } +} + +pub(crate) fn expand_custom_prompt( + text: &str, + text_elements: &[TextElement], + custom_prompts: &[CustomPrompt], +) -> Result, PromptExpansionError> { + let Some((name, rest, rest_offset)) = parse_slash_name(text) else { + return Ok(None); + }; + let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Ok(None); + }; + let Some(prompt) = custom_prompts + .iter() + .find(|prompt| prompt.name == prompt_name) + else { + return Ok(None); + }; + + let required = prompt_argument_names(&prompt.content); + let local_elements: Vec = text_elements + .iter() + .filter_map(|element| { + let mut shifted = shift_text_element_left(element, rest_offset)?; + if shifted.byte_range.start >= rest.len() { + return None; + } + shifted.byte_range.end = shifted.byte_range.end.min(rest.len()); + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); + + if !required.is_empty() { + let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| { + PromptExpansionError::Args { + command: format!("/{name}"), + error, + } + })?; + let missing: Vec = required + .into_iter() + .filter(|key| !inputs.contains_key(key)) + .collect(); + if !missing.is_empty() { + return Err(PromptExpansionError::MissingArgs { + command: format!("/{name}"), + missing, + }); + } + let (expanded_text, expanded_elements) = + expand_named_placeholders_with_elements(&prompt.content, &inputs); + return Ok(Some(PromptExpansion { + text: expanded_text, + text_elements: expanded_elements, + })); + } + + let positional_args = parse_positional_args(rest, &local_elements); + Ok(Some(expand_numeric_placeholders( + &prompt.content, + &positional_args, + ))) +} + +pub(crate) fn expand_if_numeric_with_positional_args( + prompt: &CustomPrompt, + first_line: &str, + text_elements: &[TextElement], +) -> Option { + if !prompt_argument_names(&prompt.content).is_empty() { + return None; + } + if !prompt_has_numeric_placeholders(&prompt.content) { + return None; + } + let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements); + if args.is_empty() { + return None; + } + Some(expand_numeric_placeholders(&prompt.content, &args)) +} + +fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec { + parse_tokens_with_elements(rest, text_elements) +} + +fn parse_prompt_inputs( + rest: &str, + text_elements: &[TextElement], +) -> Result, PromptArgsError> { + let mut inputs = HashMap::new(); + if rest.trim().is_empty() { + return Ok(inputs); + } + + for token in parse_tokens_with_elements(rest, text_elements) { + let Some((key, value)) = token.text.split_once('=') else { + return Err(PromptArgsError::MissingAssignment { token: token.text }); + }; + if key.is_empty() { + return Err(PromptArgsError::MissingKey { token: token.text }); + } + let value_start = key.len() + 1; + let value_elements = token + .text_elements + .iter() + .filter_map(|element| shift_text_element_left(element, value_start)) + .collect(); + inputs.insert( + key.to_string(), + PromptArg { + text: value.to_string(), + text_elements: value_elements, + }, + ); + } + Ok(inputs) +} + +fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec { + let mut elements = text_elements.to_vec(); + elements.sort_by_key(|element| element.byte_range.start); + let (text_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements); + Shlex::new(&text_for_shlex) + .map(|token| apply_replacements_to_token(token, &replacements)) + .collect() +} + +fn replace_text_elements_with_sentinels( + rest: &str, + elements: &[TextElement], +) -> (String, Vec) { + let mut out = String::with_capacity(rest.len()); + let mut replacements = Vec::new(); + let mut cursor = 0; + + for (index, element) in elements.iter().enumerate() { + let start = element.byte_range.start; + let end = element.byte_range.end; + out.push_str(&rest[cursor..start]); + let mut sentinel = format!("__CODEX_ELEM_{index}__"); + while rest.contains(&sentinel) { + sentinel.push('_'); + } + out.push_str(&sentinel); + replacements.push(ElementReplacement { + sentinel, + text: rest[start..end].to_string(), + placeholder: element.placeholder(rest).map(str::to_string), + }); + cursor = end; + } + + out.push_str(&rest[cursor..]); + (out, replacements) +} + +fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg { + if replacements.is_empty() { + return PromptArg { + text: token, + text_elements: Vec::new(), + }; + } + + let mut out = String::with_capacity(token.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + + while cursor < token.len() { + let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else { + out.push_str(&token[cursor..]); + break; + }; + let start_in_token = cursor + offset; + out.push_str(&token[cursor..start_in_token]); + let start = out.len(); + out.push_str(&replacement.text); + let end = out.len(); + if start < end { + out_elements.push(TextElement::new( + ByteRange { start, end }, + replacement.placeholder.clone(), + )); + } + cursor = start_in_token + replacement.sentinel.len(); + } + + PromptArg { + text: out, + text_elements: out_elements, + } +} + +fn next_replacement<'a>( + token: &str, + cursor: usize, + replacements: &'a [ElementReplacement], +) -> Option<(usize, &'a ElementReplacement)> { + let slice = &token[cursor..]; + let mut best = None; + for replacement in replacements { + if let Some(position) = slice.find(&replacement.sentinel) { + match best { + Some((best_position, _)) if best_position <= position => {} + _ => best = Some((position, replacement)), + } + } + } + best +} + +fn expand_named_placeholders_with_elements( + content: &str, + args: &HashMap, +) -> (String, Vec) { + let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + + for matched in PROMPT_ARG_REGEX.find_iter(content) { + let start = matched.start(); + let end = matched.end(); + if start > 0 && content.as_bytes()[start - 1] == b'$' { + out.push_str(&content[cursor..end]); + cursor = end; + continue; + } + out.push_str(&content[cursor..start]); + cursor = end; + let key = &content[start + 1..end]; + if let Some(arg) = args.get(key) { + append_arg_with_elements(&mut out, &mut out_elements, arg); + } else { + out.push_str(&content[start..end]); + } + } + + out.push_str(&content[cursor..]); + (out, out_elements) +} + +fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion { + let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); + let mut index = 0; + + while let Some(offset) = content[index..].find('$') { + let placeholder_start = index + offset; + out.push_str(&content[index..placeholder_start]); + let rest = &content[placeholder_start..]; + let bytes = rest.as_bytes(); + if bytes.len() >= 2 { + match bytes[1] { + b'$' => { + out.push_str("$$"); + index = placeholder_start + 2; + continue; + } + b'1'..=b'9' => { + let arg_index = (bytes[1] - b'1') as usize; + if let Some(arg) = args.get(arg_index) { + append_arg_with_elements(&mut out, &mut out_elements, arg); + } + index = placeholder_start + 2; + continue; + } + _ => {} + } + } + if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { + if !args.is_empty() { + append_joined_args_with_elements(&mut out, &mut out_elements, args); + } + index = placeholder_start + 1 + "ARGUMENTS".len(); + continue; + } + out.push('$'); + index = placeholder_start + 1; + } + + out.push_str(&content[index..]); + PromptExpansion { + text: out, + text_elements: out_elements, + } +} + +fn extract_positional_args_for_prompt_line( + line: &str, + prompt_name: &str, + text_elements: &[TextElement], +) -> Vec { + let trimmed = line.trim_start(); + let trim_offset = line.len() - trimmed.len(); + let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else { + return Vec::new(); + }; + let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + return Vec::new(); + }; + if after_prefix != prompt_name { + return Vec::new(); + } + let rest_trimmed_start = rest.trim_start(); + let args_str = rest_trimmed_start.trim_end(); + if args_str.is_empty() { + return Vec::new(); + } + let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len()); + let local_elements: Vec = text_elements + .iter() + .filter_map(|element| { + let mut shifted = shift_text_element_left(element, args_offset)?; + if shifted.byte_range.start >= args_str.len() { + return None; + } + shifted.byte_range.end = shifted.byte_range.end.min(args_str.len()); + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); + parse_positional_args(args_str, &local_elements) +} + +fn shift_text_element_left(element: &TextElement, offset: usize) -> Option { + if element.byte_range.end <= offset { + return None; + } + let start = element.byte_range.start.saturating_sub(offset); + let end = element.byte_range.end.saturating_sub(offset); + (start < end).then_some(element.map_range(|_| ByteRange { start, end })) +} + +fn append_arg_with_elements( + out: &mut String, + out_elements: &mut Vec, + arg: &PromptArg, +) { + let start = out.len(); + out.push_str(&arg.text); + if arg.text_elements.is_empty() { + return; + } + out_elements.extend(arg.text_elements.iter().map(|element| { + element.map_range(|range| ByteRange { + start: start + range.start, + end: start + range.end, + }) + })); +} + +fn append_joined_args_with_elements( + out: &mut String, + out_elements: &mut Vec, + args: &[PromptArg], +) { + for (index, arg) in args.iter().enumerate() { + if index > 0 { + out.push(' '); + } + append_arg_with_elements(out, out_elements, arg); + } +} + +fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) { + let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); + let mut cursor = text.len(); + for (index, arg) in args.iter().enumerate() { + text.push_str(format!(" {arg}=\"\"").as_str()); + if index == 0 { + cursor = text.len() - 1; + } + } + (text, cursor) +} + +#[derive(Debug, Clone)] +struct ElementReplacement { + sentinel: String, + text: String, + placeholder: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn prompt(name: &str, content: &str) -> CustomPrompt { + CustomPrompt { + name: name.to_string(), + path: format!("/tmp/{name}.md").into(), + content: content.to_string(), + description: None, + argument_hint: None, + } + } + + #[test] + fn expand_named_arguments() { + let prompts = vec![prompt("review", "Review $USER changes on $BRANCH")]; + let expanded = + expand_custom_prompt("/prompts:review USER=Alice BRANCH=main", &[], &prompts) + .expect("expand custom prompt"); + assert_eq!( + expanded, + Some(PromptExpansion { + text: "Review Alice changes on main".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn expand_numeric_arguments() { + let prompts = vec![prompt("rewrite", "Rewrite $1 as $2")]; + let expanded = expand_custom_prompt("/prompts:rewrite draft polished", &[], &prompts) + .expect("expand custom prompt"); + assert_eq!( + expanded, + Some(PromptExpansion { + text: "Rewrite draft as polished".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn missing_required_args_reports_error() { + let prompts = vec![prompt("review", "Review $USER changes on $BRANCH")]; + let err = expand_custom_prompt("/prompts:review USER=Alice", &[], &prompts) + .expect_err("missing args should fail") + .user_message(); + assert!(err.contains("BRANCH")); + } + + #[test] + fn prompt_selection_submit_inserts_plain_prompt_body() { + let action = prompt_selection_action( + &prompt("rewrite", "Please rewrite this draft"), + "/prompts:rewrite", + PromptSelectionMode::Submit, + &[], + ); + assert_eq!( + action, + PromptSelectionAction::Insert { + text: "Please rewrite this draft".to_string(), + cursor: Some("Please rewrite this draft".len()), + } + ); + } + + #[test] + fn prompt_selection_completion_for_named_args_inserts_placeholders() { + let action = prompt_selection_action( + &prompt("review", "Review $USER changes on $BRANCH"), + "/review", + PromptSelectionMode::Completion, + &[], + ); + assert_eq!( + action, + PromptSelectionAction::Insert { + text: "/prompts:review USER=\"\" BRANCH=\"\"".to_string(), + cursor: Some("/prompts:review USER=\"".len()), + } + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index f7db7c65d..cd571ccca 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1110,11 +1110,9 @@ impl BottomPaneView for RequestUserInputOverlay { KeyCode::Backspace | KeyCode::Delete => { self.clear_selection(); } - KeyCode::Tab => { - if self.selected_option_index().is_some() { - self.focus = Focus::Notes; - self.ensure_selected_for_notes(); - } + KeyCode::Tab if self.selected_option_index().is_some() => { + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); } KeyCode::Enter => { let has_selection = self.selected_option_index().is_some(); diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index cff3a34f7..aca850109 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -155,9 +155,6 @@ fn compute_desc_col( .map(|(_, row)| { let mut spans = row.name_prefix_spans.clone(); spans.push(row.name.clone().into()); - if row.disabled_reason.is_some() { - spans.push(" (disabled)".dim()); - } Line::from(spans).width() }) .max() @@ -167,9 +164,6 @@ fn compute_desc_col( .map(|row| { let mut spans = row.name_prefix_spans.clone(); spans.push(row.name.clone().into()); - if row.disabled_reason.is_some() { - spans.push(" (disabled)".dim()); - } Line::from(spans).width() }) .max() @@ -413,12 +407,7 @@ fn adjust_start_for_wrapped_selection_visibility( /// at `desc_col`. Applies fuzzy-match bolding when indices are present and /// dims the description. fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { - let combined_description = match (&row.description, &row.disabled_reason) { - (Some(desc), Some(reason)) => Some(format!("{desc} (disabled: {reason})")), - (Some(desc), None) => Some(desc.clone()), - (None, Some(reason)) => Some(format!("disabled: {reason}")), - (None, None) => None, - }; + let combined_description = row.description.clone(); // Enforce single-line name: allow at most desc_col - 2 cells for name, // reserving two spaces before the description column. @@ -469,10 +458,6 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { name_spans.push("…".into()); } - if row.disabled_reason.is_some() { - name_spans.push(" (disabled)".dim()); - } - let this_name_width = name_prefix_width + Line::from(name_spans.clone()).width(); let mut full_spans: Vec = row.name_prefix_spans.clone(); full_spans.extend(name_spans); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 54b1a8cf4..90845bc8b 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -18,7 +18,6 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) fast_command_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) realtime_conversation_enabled: bool, - pub(crate) audio_device_selection_enabled: bool, pub(crate) allow_elevate_sandbox: bool, } @@ -36,7 +35,6 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast) .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) - .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) .collect() } @@ -69,7 +67,6 @@ mod tests { fast_command_enabled: true, personality_command_enabled: true, realtime_conversation_enabled: true, - audio_device_selection_enabled: true, allow_elevate_sandbox: true, } } @@ -119,17 +116,20 @@ mod tests { } #[test] - fn settings_command_is_hidden_when_realtime_is_disabled() { + fn settings_command_stays_available_when_realtime_is_disabled() { let mut flags = all_enabled_flags(); flags.realtime_conversation_enabled = false; - flags.audio_device_selection_enabled = false; - assert_eq!(find_builtin_command("settings", flags), None); + assert_eq!( + find_builtin_command("settings", flags), + Some(SlashCommand::Settings) + ); } #[test] - fn settings_command_is_hidden_when_audio_device_selection_is_disabled() { - let mut flags = all_enabled_flags(); - flags.audio_device_selection_enabled = false; - assert_eq!(find_builtin_command("settings", flags), None); + fn settings_command_stays_available_when_audio_device_selection_is_disabled() { + assert_eq!( + find_builtin_command("settings", all_enabled_flags()), + Some(SlashCommand::Settings) + ); } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 8486a9ec6..cf6256e02 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -10,9 +10,10 @@ expression: terminal.backend() " " " " " " -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + x then ctrl + u to undo last message ctrl + x then ctrl + y to copy last output " +" ctrl + x then ctrl + r to restart Codex ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_custom_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_custom_prompt.snap new file mode 100644 index 000000000..24523ba06 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_custom_prompt.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 6340 +expression: terminal.backend() +--- +" " +"› /spec " +" " +" " +" " +" /prompts:specialist saved prompt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap index 1bb213bbe..4d0bd340b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -2,9 +2,11 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands ! for shell commands " -" ctrl + j for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc esc to edit previous message " -" ctrl + c to exit shift + tab to change mode " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" ctrl + j for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc esc to edit previous me" +" ctrl + x then ctrl + u to undo last message ctrl + x then ctrl + y to c" +" ctrl + x then ctrl + r to restart Codex ctrl + c to exit " +" shift + tab to change mode " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index c1f00d443..5e0d28f23 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,9 +2,10 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous " +" ctrl + x then ctrl + u to undo last message ctrl + x then ctrl + y to c" +" ctrl + x then ctrl + r to restart Codex ctrl + c to exit " +" ctrl + t to view transcript" diff --git a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs index 9b69387c1..433bc1e80 100644 --- a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs +++ b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs @@ -13,28 +13,31 @@ use ratatui::widgets::Paragraph; use crate::live_wrap::take_prefix_by_width; use crate::render::renderable::Renderable; -/// Tracks active unified-exec processes and renders a compact summary. +/// Tracks active unified-exec processes and workflows and renders a compact summary. pub(crate) struct UnifiedExecFooter { - processes: Vec, + terminals: Vec, + workflows: Vec, } impl UnifiedExecFooter { pub(crate) fn new() -> Self { Self { - processes: Vec::new(), + terminals: Vec::new(), + workflows: Vec::new(), } } - pub(crate) fn set_processes(&mut self, processes: Vec) -> bool { - if self.processes == processes { + pub(crate) fn set_activity(&mut self, terminals: Vec, workflows: Vec) -> bool { + if self.terminals == terminals && self.workflows == workflows { return false; } - self.processes = processes; + self.terminals = terminals; + self.workflows = workflows; true } pub(crate) fn is_empty(&self) -> bool { - self.processes.is_empty() + self.terminals.is_empty() && self.workflows.is_empty() } /// Returns the unindented summary text used by both footer and status-row rendering. @@ -43,14 +46,27 @@ impl UnifiedExecFooter { /// callers can choose layout-specific framing (inline separator vs. row /// indentation). Returning `None` means there is nothing to surface. pub(crate) fn summary_text(&self) -> Option { - if self.processes.is_empty() { + let terminal_count = self.terminals.len(); + let workflow_count = self.workflows.len(); + if terminal_count == 0 && workflow_count == 0 { return None; } - - let count = self.processes.len(); - let plural = if count == 1 { "" } else { "s" }; + let mut parts = Vec::new(); + if terminal_count > 0 { + let plural = if terminal_count == 1 { "" } else { "s" }; + parts.push(format!( + "{terminal_count} background terminal{plural} running" + )); + } + if workflow_count > 0 { + let plural = if workflow_count == 1 { "" } else { "s" }; + parts.push(format!( + "{workflow_count} background workflow{plural} running" + )); + } Some(format!( - "{count} background terminal{plural} running · /ps to view · /stop to close" + "{} · /ps to view · /stop to close", + parts.join(" · ") )) } @@ -96,7 +112,7 @@ mod tests { #[test] fn render_more_sessions() { let mut footer = UnifiedExecFooter::new(); - footer.set_processes(vec!["rg \"foo\" src".to_string()]); + footer.set_activity(vec!["rg \"foo\" src".to_string()], Vec::new()); let width = 50; let height = footer.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); @@ -107,11 +123,31 @@ mod tests { #[test] fn render_many_sessions() { let mut footer = UnifiedExecFooter::new(); - footer.set_processes((0..123).map(|idx| format!("cmd {idx}")).collect()); + footer.set_activity( + (0..123).map(|idx| format!("cmd {idx}")).collect(), + Vec::new(), + ); let width = 50; let height = footer.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); footer.render(Rect::new(0, 0, width, height), &mut buf); assert_snapshot!("render_many_sessions", format!("{buf:?}")); } + + #[test] + fn summary_text_includes_workflows() { + let mut footer = UnifiedExecFooter::new(); + footer.set_activity( + vec!["rg \"foo\" src".to_string()], + vec!["director · after_turn".to_string()], + ); + + assert_eq!( + footer.summary_text(), + Some( + "1 background terminal running · 1 background workflow running · /ps to view · /stop to close" + .to_string() + ) + ); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b0ddb631b..390f21c4f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -47,6 +47,7 @@ use crate::app_server_approval_conversions::network_approval_context_to_core; use crate::app_server_session::ThreadSessionState; #[cfg(not(target_os = "linux"))] use crate::audio_device::list_realtime_audio_device_names; +use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; @@ -56,6 +57,10 @@ use crate::mention_codec::LinkedMention; use crate::mention_codec::encode_history_mentions; use crate::model_catalog::ModelCatalog; use crate::multi_agents; +use crate::profile_router::ProfileFallbackAction; +use crate::profile_router::app_server_profile_fallback_action; +#[cfg(test)] +use crate::profile_router::core_profile_fallback_action; use crate::status::RateLimitWindowDisplay; use crate::status::StatusAccountDisplay; use crate::status::StatusHistoryHandle; @@ -314,11 +319,13 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::discover_prompts_in; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::clipboard_paste::paste_image_to_temp_png; use crate::clipboard_text; use crate::collaboration_modes; use crate::diff_render::display_path_for; +use crate::display_preferences::DisplayPreferences; use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCell; use crate::exec_cell::new_active_exec_command; @@ -332,6 +339,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::WebSearchCell; +use crate::insight; use crate::key_hint; use crate::key_hint::KeyBinding; #[cfg(test)] @@ -548,6 +556,7 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, + pub(crate) display_preferences: DisplayPreferences, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, pub(crate) initial_user_message: Option, @@ -603,6 +612,13 @@ enum RateLimitErrorKind { Generic, } +fn profile_fallback_action_label(action: ProfileFallbackAction) -> &'static str { + match action { + ProfileFallbackAction::RetrySameProfileFirst => "retry_same_profile_first", + ProfileFallbackAction::SwitchProfileImmediately => "switch_profile_immediately", + } +} + #[cfg(test)] fn core_rate_limit_error_kind(info: &CoreCodexErrorInfo) -> Option { match info { @@ -791,6 +807,8 @@ pub(crate) struct ChatWidget { turn_sleep_inhibitor: SleepInhibitor, task_complete_pending: bool, unified_exec_processes: Vec, + background_workflow_labels: Vec, + queued_workflow_labels: Vec, /// Tracks whether codex-core currently considers an agent turn to be in progress. /// /// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion) @@ -822,10 +840,15 @@ pub(crate) struct ChatWidget { plugin_install_auth_flow: Option, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, + display_preferences: DisplayPreferences, // Accumulates the current reasoning block text to extract a header reasoning_buffer: String, // Accumulates full reasoning content for transcript-only recording full_reasoning_buffer: String, + // Accumulates the current raw reasoning block for TUI-only visibility toggles. + raw_reasoning_buffer: String, + // Accumulates the full raw reasoning content for transcript-only recording. + full_raw_reasoning_buffer: String, // The currently rendered footer state. We keep the already-formatted // details here so transient stream interruptions can restore the footer // exactly as it was shown. @@ -894,8 +917,8 @@ pub(crate) struct ChatWidget { needs_final_message_separator: bool, // Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). // - // This gates rendering of the "Worked for …" separator so purely conversational turns don't - // show an empty divider. It is reset when the separator is emitted. + // This only gates rendering of the "Worked for …" label on separators. It is reset when a + // work separator is emitted or when the turn completes. had_work_activity: bool, // Whether the current turn emitted a plan update. saw_plan_update_this_turn: bool, @@ -903,6 +926,8 @@ pub(crate) struct ChatWidget { // later steer. This is cleared when the user submits a steer so the plan popup only appears // if a newer proposed plan arrives afterward. saw_plan_item_this_turn: bool, + // Produces the timestamp label shown on completed-turn history separators. + history_timestamp_label_provider: Arc String + Send + Sync>, // Latest `update_plan` checklist task counts for terminal-title rendering. last_plan_progress: Option<(usize, usize)>, // Incremental buffer for streamed plan content. @@ -955,6 +980,8 @@ pub(crate) struct ChatWidget { realtime_conversation: RealtimeConversationUiState, last_rendered_user_message_event: Option, last_non_retry_error: Option<(String, String)>, + last_submitted_user_turn: Option, + profile_retry_attempted: bool, } /// Cached nickname and role for a collab agent thread, used to attach human-readable labels to @@ -1040,6 +1067,8 @@ pub(crate) struct ThreadInputState { pending_steers: VecDeque, rejected_steers_queue: VecDeque, queued_user_messages: VecDeque, + last_submitted_user_turn: Option, + profile_retry_attempted: bool, current_collaboration_mode: CollaborationMode, active_collaboration_mask: Option, task_running: bool, @@ -1686,9 +1715,11 @@ impl ChatWidget { return; }; self.needs_final_message_separator = true; - let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new()); - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(cell))); + self.add_to_history(history_cell::new_unified_exec_interaction( + wait.command_display, + String::new(), + self.display_preferences.clone(), + )); self.restore_reasoning_status_header(); } @@ -2250,6 +2281,8 @@ impl ChatWidget { fn on_agent_reasoning_final(&mut self) { // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + self.full_raw_reasoning_buffer + .push_str(&self.raw_reasoning_buffer); if !self.full_reasoning_buffer.is_empty() { let cell = history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), @@ -2257,8 +2290,18 @@ impl ChatWidget { ); self.add_boxed_history(cell); } + if !self.full_raw_reasoning_buffer.is_empty() { + let cell = history_cell::new_reasoning_raw_block( + self.full_raw_reasoning_buffer.clone(), + &self.config.cwd, + self.display_preferences.clone(), + ); + self.add_boxed_history(cell); + } self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); + self.raw_reasoning_buffer.clear(); + self.full_raw_reasoning_buffer.clear(); self.request_redraw(); } @@ -2267,9 +2310,15 @@ impl ChatWidget { self.full_reasoning_buffer.push_str(&self.reasoning_buffer); self.full_reasoning_buffer.push_str("\n\n"); self.reasoning_buffer.clear(); + self.full_raw_reasoning_buffer + .push_str(&self.raw_reasoning_buffer); + self.full_raw_reasoning_buffer.push_str("\n\n"); + self.raw_reasoning_buffer.clear(); } - // Raw reasoning uses the same flow as summarized reasoning + fn on_raw_reasoning_delta(&mut self, delta: String) { + self.raw_reasoning_buffer.push_str(&delta); + } fn on_task_started(&mut self) { self.agent_turn_running = true; @@ -2297,6 +2346,8 @@ impl ChatWidget { self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); + self.full_raw_reasoning_buffer.clear(); + self.raw_reasoning_buffer.clear(); self.request_redraw(); } @@ -2320,9 +2371,10 @@ impl ChatWidget { self.collect_runtime_metrics_delta(); let runtime_metrics = (!self.turn_runtime_metrics.is_empty()).then_some(self.turn_runtime_metrics); - let show_work_separator = self.needs_final_message_separator && self.had_work_activity; - if show_work_separator || runtime_metrics.is_some() { - let elapsed_seconds = if show_work_separator { + let should_add_turn_separator = + self.needs_final_message_separator || runtime_metrics.is_some(); + if should_add_turn_separator { + let elapsed_seconds = if self.had_work_activity { self.bottom_pane .status_widget() .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) @@ -2331,6 +2383,7 @@ impl ChatWidget { None }; self.add_to_history(history_cell::FinalMessageSeparator::new( + Some(self.current_history_timestamp_label()), elapsed_seconds, runtime_metrics, )); @@ -2753,6 +2806,21 @@ impl ChatWidget { .as_ref() .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) { + } else if let Some(action) = codex_error_info + .as_ref() + .filter(|_| self.has_retryable_user_turn()) + .and_then(app_server_profile_fallback_action) + { + self.session_telemetry.counter( + "codex.profile_fallback.app_server", + /*inc*/ 1, + &[("action", profile_fallback_action_label(action))], + ); + self.app_event_tx + .send(AppEvent::RetryLastUserTurnWithProfileFallback { + action, + error_message: message, + }); } else if let Some(info) = codex_error_info .as_ref() .and_then(app_server_rate_limit_error_kind) @@ -3131,6 +3199,8 @@ impl ChatWidget { .collect(), rejected_steers_queue: self.rejected_steers_queue.clone(), queued_user_messages: self.queued_user_messages.clone(), + last_submitted_user_turn: self.last_submitted_user_turn.clone(), + profile_retry_attempted: self.profile_retry_attempted, current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), task_running: self.bottom_pane.is_task_running(), @@ -3185,6 +3255,8 @@ impl ChatWidget { .collect(); self.rejected_steers_queue = input_state.rejected_steers_queue; self.queued_user_messages = input_state.queued_user_messages; + self.last_submitted_user_turn = input_state.last_submitted_user_turn; + self.profile_retry_attempted = input_state.profile_retry_attempted; } else { self.agent_turn_running = false; self.pending_steers.clear(); @@ -3198,6 +3270,8 @@ impl ChatWidget { ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); self.queued_user_messages.clear(); + self.last_submitted_user_turn = None; + self.profile_retry_attempted = false; } self.turn_sleep_inhibitor .set_turn_running(self.agent_turn_running); @@ -3377,7 +3451,10 @@ impl ChatWidget { .iter() .map(|path| path.display().to_string()) .collect::>(); - history_cell::new_guardian_denied_patch_request(files) + history_cell::new_guardian_denied_patch_request( + files, + self.display_preferences.clone(), + ) } GuardianAssessmentAction::McpToolCall { server, tool_name, .. @@ -3476,13 +3553,19 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); - self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal; - self.set_status( - "Waiting for background terminal".to_string(), - command_display.clone(), - StatusDetailsCapitalization::Preserve, - /*details_max_lines*/ 1, - ); + if self.display_preferences.show_tool_results() { + self.terminal_title_status_kind = + TerminalTitleStatusKind::WaitingForBackgroundTerminal; + self.set_status( + "Waiting for background terminal".to_string(), + command_display.clone(), + StatusDetailsCapitalization::Preserve, + /*details_max_lines*/ 1, + ); + } else { + self.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.set_status_header(String::from("Working")); + } match &mut self.unified_exec_wait_streak { Some(wait) if wait.process_id == ev.process_id => { wait.update_command_display(command_display); @@ -3509,6 +3592,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_unified_exec_interaction( command_display, ev.stdin, + self.display_preferences.clone(), )); } } @@ -3517,6 +3601,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_patch_event( event.changes, &self.config.cwd, + self.display_preferences.clone(), )); } @@ -3616,7 +3701,18 @@ impl ChatWidget { .iter() .map(|process| process.command_display.clone()) .collect(); - self.bottom_pane.set_unified_exec_processes(processes); + self.bottom_pane + .set_unified_exec_activity(processes, self.background_workflow_labels.clone()); + } + + pub(crate) fn sync_background_workflow_status( + &mut self, + running_workflows: Vec, + queued_workflows: Vec, + ) { + self.background_workflow_labels = running_workflows; + self.queued_workflow_labels = queued_workflows; + self.sync_unified_exec_footer(); } /// Record recent stdout/stderr lines for the unified exec footer. @@ -3662,6 +3758,7 @@ impl ChatWidget { ev.call_id, String::new(), self.config.animations, + self.display_preferences.clone(), ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -3689,7 +3786,12 @@ impl ChatWidget { } if !handled { - self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); + self.add_to_history(history_cell::new_web_search_call( + call_id, + query, + action, + self.display_preferences.clone(), + )); } self.had_work_activity = true; } @@ -4145,6 +4247,7 @@ impl ChatWidget { .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) .map(|current| self.worked_elapsed_from(current)); self.add_to_history(history_cell::FinalMessageSeparator::new( + None, elapsed_seconds, /*runtime_metrics*/ None, )); @@ -4179,6 +4282,10 @@ impl ChatWidget { elapsed } + fn current_history_timestamp_label(&self) -> String { + (self.history_timestamp_label_provider)() + } + /// Finalizes an exec call while preserving the active exec cell grouping contract. /// /// Exec begin/end events usually pair through `running_commands`, but unified exec can emit an @@ -4266,6 +4373,7 @@ impl ChatWidget { source, ev.interaction_input.clone(), self.config.animations, + self.display_preferences.clone(), ); let completed = orphan.complete_call(&ev.call_id, output, ev.duration); debug_assert!( @@ -4273,9 +4381,7 @@ impl ChatWidget { "new orphan exec cell should contain {}", ev.call_id ); - self.needs_final_message_separator = true; - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(orphan))); + self.insert_boxed_history_without_flushing(Box::new(orphan)); self.request_redraw(); } ExecEndTarget::NewCell => { @@ -4287,6 +4393,7 @@ impl ChatWidget { source, ev.interaction_input.clone(), self.config.animations, + self.display_preferences.clone(), ); let completed = cell.complete_call(&ev.call_id, output, ev.duration); debug_assert!(completed, "new exec cell should contain {}", ev.call_id); @@ -4479,6 +4586,7 @@ impl ChatWidget { ev.source, interaction_input, self.config.animations, + self.display_preferences.clone(), ))); self.bump_active_cell_revision(); } @@ -4493,6 +4601,7 @@ impl ChatWidget { ev.call_id, ev.invocation, self.config.animations, + self.display_preferences.clone(), ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -4519,6 +4628,7 @@ impl ChatWidget { call_id, invocation, self.config.animations, + self.display_preferences.clone(), ); let extra_cell = cell.complete(duration, result); self.active_cell = Some(Box::new(cell)); @@ -4549,6 +4659,7 @@ impl ChatWidget { fn new_with_op_target(common: ChatWidgetInit, codex_op_target: CodexOpTarget) -> Self { let ChatWidgetInit { config, + display_preferences, frame_requester, app_event_tx, initial_user_message, @@ -4644,6 +4755,8 @@ impl ChatWidget { turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), + background_workflow_labels: Vec::new(), + queued_workflow_labels: Vec::new(), agent_turn_running: false, mcp_startup_status: None, pending_turn_copyable_output: None, @@ -4661,8 +4774,11 @@ impl ChatWidget { plugin_install_apps_needing_auth: Vec::new(), plugin_install_auth_flow: None, interrupts: InterruptManager::new(), + display_preferences, reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + raw_reasoning_buffer: String::new(), + full_raw_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), pending_guardian_review_status: PendingGuardianReviewStatus::default(), terminal_title_status_kind: TerminalTitleStatusKind::Working, @@ -4690,6 +4806,9 @@ impl ChatWidget { had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + history_timestamp_label_provider: Arc::new(|| { + Local::now().format("%Y-%m-%d %H:%M:%S %:z").to_string() + }), last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, @@ -4714,6 +4833,8 @@ impl ChatWidget { realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_event: None, last_non_retry_error: None, + last_submitted_user_turn: None, + profile_retry_attempted: false, }; widget @@ -4747,6 +4868,10 @@ impl ChatWidget { widget .bottom_pane .set_connectors_enabled(widget.connectors_enabled()); + let prompts_dir = widget.config.codex_home.join("prompts"); + widget + .bottom_pane + .set_custom_prompts(discover_prompts_in(&prompts_dir)); widget.refresh_status_surfaces(); widget @@ -4883,6 +5008,8 @@ impl ChatWidget { // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); + self.raw_reasoning_buffer.clear(); + self.full_raw_reasoning_buffer.clear(); self.set_status_header(String::from("Working")); self.submit_user_message(user_message); } else { @@ -4967,6 +5094,27 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn show_view(&mut self, view: Box) { + self.bottom_pane.show_view(view); + self.request_redraw(); + } + pub(crate) fn replace_selection_view_if_active( + &mut self, + view_id: &'static str, + params: SelectionViewParams, + ) -> bool { + let replaced = self + .bottom_pane + .replace_selection_view_if_active(view_id, params); + if replaced { + self.request_redraw(); + } + replaced + } + + pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option { + self.bottom_pane.selected_index_for_active_view(view_id) + } pub(crate) fn no_modal_or_popup_active(&self) -> bool { self.bottom_pane.no_modal_or_popup_active() } @@ -4988,6 +5136,39 @@ impl ChatWidget { false } + pub(crate) fn can_run_respawn_now(&mut self) -> bool { + if !self.bottom_pane.is_task_running() { + return true; + } + + let message = "Ctrl-X Ctrl-R is disabled while a task is in progress.".to_string(); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + false + } + + pub(crate) fn copy_latest_output_to_clipboard(&mut self) { + let Some(text) = self.last_copyable_output.as_deref() else { + self.add_info_message( + "`/copy` is unavailable before the first Codex output or right after a rollback." + .to_string(), + /*hint*/ None, + ); + return; + }; + + match clipboard_text::copy_text_to_clipboard(text) { + Ok(()) => { + let hint = self.agent_turn_running.then_some( + "Current turn is still running; copied the latest completed output (not the in-progress response)." + .to_string(), + ); + self.add_info_message("Copied latest Codex output to clipboard.".to_string(), hint); + } + Err(err) => self.add_error_message(format!("Failed to copy to clipboard: {err}")), + } + } + fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -5025,6 +5206,12 @@ impl ChatWidget { SlashCommand::Fork => { self.app_event_tx.send(AppEvent::ForkCurrentSession); } + SlashCommand::Thread => { + self.app_event_tx.send(AppEvent::OpenThreadPanel); + } + SlashCommand::Profile => { + self.app_event_tx.send(AppEvent::OpenProfileManagementPanel); + } SlashCommand::Init => { let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) { Ok(path) => path, @@ -5082,10 +5269,10 @@ impl ChatWidget { } } SlashCommand::Settings => { - if !self.realtime_audio_device_selection_enabled() { - return; - } - self.open_realtime_audio_popup(); + self.open_settings_popup(); + } + SlashCommand::Clawbot => { + self.app_event_tx.send(AppEvent::OpenClawbotManagement); } SlashCommand::Personality => { self.open_personality_popup(); @@ -5217,32 +5404,7 @@ impl ChatWidget { }); } SlashCommand::Copy => { - let Some(text) = self.last_copyable_output.as_deref() else { - self.add_info_message( - "`/copy` is unavailable before the first Codex output or right after a rollback." - .to_string(), - /*hint*/ None, - ); - return; - }; - - let copy_result = clipboard_text::copy_text_to_clipboard(text); - - match copy_result { - Ok(()) => { - let hint = self.agent_turn_running.then_some( - "Current turn is still running; copied the latest completed output (not the in-progress response)." - .to_string(), - ); - self.add_info_message( - "Copied latest Codex output to clipboard.".to_string(), - hint, - ); - } - Err(err) => { - self.add_error_message(format!("Failed to copy to clipboard: {err}")) - } - } + self.copy_latest_output_to_clipboard(); } SlashCommand::Mention => { self.insert_str("@"); @@ -5264,6 +5426,10 @@ impl ChatWidget { ); } } + SlashCommand::Insight => { + self.add_info_message(insight::start_message(), /*hint*/ None); + insight::spawn_report_generation(self.config.clone(), self.app_event_tx.clone()); + } SlashCommand::DebugConfig => { self.add_debug_config_output(); } @@ -5297,6 +5463,12 @@ impl ChatWidget { SlashCommand::Plugins => { self.add_plugins_output(); } + SlashCommand::Workflow => { + self.app_event_tx.send(AppEvent::OpenWorkflowControls); + } + SlashCommand::Btw => { + self.add_error_message("Usage: /btw ".to_string()); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( @@ -5435,6 +5607,8 @@ impl ChatWidget { if self.is_session_configured() { self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); + self.raw_reasoning_buffer.clear(); + self.full_raw_reasoning_buffer.clear(); self.set_status_header(String::from("Working")); self.submit_user_message(user_message); } else { @@ -5469,6 +5643,18 @@ impl ChatWidget { }); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::Btw 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::StartBtwDiscussion { + prompt: prepared_args, + }); + self.bottom_pane.drain_pending_submission_state(); + } _ => self.dispatch_command(cmd), } } @@ -5529,8 +5715,7 @@ impl ChatWidget { fn flush_active_cell(&mut self) { if let Some(active) = self.active_cell.take() { - self.needs_final_message_separator = true; - self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); + self.insert_boxed_history_without_flushing(active); } } @@ -5538,7 +5723,24 @@ impl ChatWidget { self.add_boxed_history(Box::new(cell)); } + fn insert_boxed_history_without_flushing(&mut self, cell: Box) { + let has_visible_display_lines = !cell.display_lines(u16::MAX).is_empty(); + let has_visible_transcript_lines = !cell.transcript_lines(u16::MAX).is_empty(); + if !has_visible_display_lines && !has_visible_transcript_lines { + return; + } + + self.needs_final_message_separator = true; + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + } + fn add_boxed_history(&mut self, cell: Box) { + let has_visible_display_lines = !cell.display_lines(u16::MAX).is_empty(); + let has_visible_transcript_lines = !cell.transcript_lines(u16::MAX).is_empty(); + if !has_visible_display_lines && !has_visible_transcript_lines { + return; + } + // Keep the placeholder session header as the active cell until real session info arrives, // so we can merge headers instead of committing a duplicate box to history. let keep_placeholder_header_active = !self.is_session_configured() @@ -5547,12 +5749,11 @@ impl ChatWidget { .as_ref() .is_some_and(|c| c.as_any().is::()); - if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() { + if !keep_placeholder_header_active && has_visible_display_lines { // Only break exec grouping if the cell renders visible lines. self.flush_active_cell(); - self.needs_final_message_separator = true; } - self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + self.insert_boxed_history_without_flushing(cell); } fn queue_user_message(&mut self, user_message: UserMessage) { @@ -5595,6 +5796,16 @@ impl ChatWidget { } let render_in_history = !self.agent_turn_running; + if render_in_history { + self.last_submitted_user_turn = Some(UserMessage { + text: text.clone(), + local_images: local_images.clone(), + remote_image_urls: remote_image_urls.clone(), + text_elements: text_elements.clone(), + mention_bindings: mention_bindings.clone(), + }); + self.profile_retry_attempted = false; + } let mut items: Vec = Vec::new(); // Special-case: "!cmd" executes a local shell command instead of sending to the model. @@ -6020,10 +6231,8 @@ impl ChatWidget { for delta in summary { self.on_agent_reasoning_delta(delta); } - if self.config.show_raw_agent_reasoning { - for delta in content { - self.on_agent_reasoning_delta(delta); - } + for delta in content { + self.on_raw_reasoning_delta(delta); } } self.on_agent_reasoning_final(); @@ -6339,9 +6548,7 @@ impl ChatWidget { self.on_agent_reasoning_delta(notification.delta); } ServerNotification::ReasoningTextDelta(notification) => { - if self.config.show_raw_agent_reasoning { - self.on_agent_reasoning_delta(notification.delta); - } + self.on_raw_reasoning_delta(notification.delta); } ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(), ServerNotification::TerminalInteraction(notification) => { @@ -6683,10 +6890,8 @@ impl ChatWidget { reasoning_effort, agents_states, }), - ThreadItem::EnteredReviewMode { review, .. } => { - if !from_replay { - self.enter_review_mode_with_hint(review, /*from_replay*/ false); - } + ThreadItem::EnteredReviewMode { review, .. } if !from_replay => { + self.enter_review_mode_with_hint(review, /*from_replay*/ false); } _ => {} } @@ -6830,13 +7035,15 @@ impl ChatWidget { self.on_agent_message_delta(delta) } EventMsg::PlanDelta(event) => self.on_plan_delta(event.delta), - EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) - | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { + EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { + self.on_agent_reasoning_delta(delta) + } + EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta, - }) => self.on_agent_reasoning_delta(delta), + }) => self.on_raw_reasoning_delta(delta), EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - self.on_agent_reasoning_delta(text); + self.on_raw_reasoning_delta(text); self.on_agent_reasoning_final(); } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), @@ -6866,6 +7073,21 @@ impl ChatWidget { .as_ref() .is_some_and(|info| self.handle_steer_rejected_error(info)) { + } else if let Some(action) = codex_error_info + .as_ref() + .filter(|_| self.has_retryable_user_turn()) + .and_then(core_profile_fallback_action) + { + self.session_telemetry.counter( + "codex.profile_fallback.core", + /*inc*/ 1, + &[("action", profile_fallback_action_label(action))], + ); + self.app_event_tx + .send(AppEvent::RetryLastUserTurnWithProfileFallback { + action, + error_message: message, + }); } else if let Some(kind) = codex_error_info .as_ref() .and_then(core_rate_limit_error_kind) @@ -7454,13 +7676,17 @@ impl ChatWidget { recent_chunks: process.recent_chunks.clone(), }) .collect(); - self.add_to_history(history_cell::new_unified_exec_processes_output(processes)); + self.add_to_history(history_cell::new_background_tasks_output( + processes, + self.background_workflow_labels.clone(), + self.queued_workflow_labels.clone(), + )); } fn clean_background_terminals(&mut self) { self.submit_op(AppCommand::clean_background_terminals()); self.add_info_message( - "Stopping all background terminals.".to_string(), + "Stopping all background tasks.".to_string(), /*hint*/ None, ); } @@ -7765,33 +7991,49 @@ impl ChatWidget { }); } - pub(crate) fn open_realtime_audio_popup(&mut self) { - let items = [ - RealtimeAudioDeviceKind::Microphone, - RealtimeAudioDeviceKind::Speaker, - ] - .into_iter() - .map(|kind| { - let description = Some(format!( - "Current: {}", - self.current_realtime_audio_selection_label(kind) - )); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind }); - })]; - SelectionItem { - name: kind.title().to_string(), - description, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); + pub(crate) fn open_settings_popup(&mut self) { + let mut items = vec![SelectionItem { + name: "UI".to_string(), + description: Some( + "Configure local TUI-only transcript visibility for reasoning, tool activity, and diffs." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenDisplayPreferencesPanel) + })], + dismiss_on_select: true, + ..Default::default() + }]; + + if self.realtime_audio_device_selection_enabled() { + items.extend( + [ + RealtimeAudioDeviceKind::Microphone, + RealtimeAudioDeviceKind::Speaker, + ] + .into_iter() + .map(|kind| { + let description = Some(format!( + "Current: {}", + self.current_realtime_audio_selection_label(kind) + )); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind }); + })]; + SelectionItem { + name: kind.title().to_string(), + description, + actions, + dismiss_on_select: true, + ..Default::default() + } + }), + ); + } self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Settings".to_string()), - subtitle: Some("Configure settings for Codex.".to_string()), + subtitle: Some("Configure UI and realtime settings for Codex.".to_string()), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() @@ -10301,6 +10543,10 @@ impl ChatWidget { self.bottom_pane.composer_is_empty() } + pub(crate) fn is_task_running(&self) -> bool { + self.bottom_pane.is_task_running() + } + #[cfg(test)] pub(crate) fn is_task_running_for_test(&self) -> bool { self.bottom_pane.is_task_running() @@ -10557,6 +10803,88 @@ impl ChatWidget { self.config.config_layer_stack = config.config_layer_stack.clone(); } + pub(crate) fn sync_config_for_profile_switch(&mut self, config: &Config) { + self.config = config.clone(); + self.set_approval_policy(self.config.permissions.approval_policy.value()); + if let Err(err) = + self.set_sandbox_policy(self.config.permissions.sandbox_policy.get().clone()) + { + tracing::warn!(%err, "failed to set sandbox policy after profile switch"); + } + self.set_approvals_reviewer(self.config.approvals_reviewer); + self.set_reasoning_effort(self.config.model_reasoning_effort); + self.set_plan_mode_reasoning_effort(self.config.plan_mode_reasoning_effort); + self.set_service_tier(self.config.service_tier); + if let Some(model) = self.config.model.clone() { + self.set_model(&model); + } + self.sync_fast_command_enabled(); + self.sync_personality_command_enabled(); + self.sync_plugins_command_enabled(); + self.refresh_plugin_mentions(); + } + + pub(crate) fn active_profile_label(&self) -> Option { + self.config.active_profile.clone() + } + + pub(crate) fn profile_retry_attempted(&self) -> bool { + self.profile_retry_attempted + } + + pub(crate) fn has_retryable_user_turn(&self) -> bool { + self.last_submitted_user_turn.is_some() + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn last_submitted_user_turn(&self) -> Option { + self.last_submitted_user_turn.clone() + } + + pub(crate) fn retry_last_user_turn_for_profile_fallback( + &mut self, + history_message: String, + ) -> bool { + if self.profile_retry_attempted { + return false; + } + if self.last_submitted_user_turn.is_none() { + return false; + } + + self.profile_retry_attempted = true; + self.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.add_to_history(history_cell::new_info_event( + history_message, + /*hint*/ None, + )); + self.submit_user_message(UserMessage::from("continue")); + true + } + + pub(crate) fn submit_profile_fallback_retry(&mut self, history_message: String) { + let user_message = UserMessage::from("continue"); + self.last_submitted_user_turn = Some(user_message.clone()); + self.profile_retry_attempted = true; + self.submit_pending_steers_after_interrupt = false; + self.add_to_history(history_cell::new_info_event( + history_message, + /*hint*/ None, + )); + self.submit_user_message(user_message); + } + + pub(crate) fn finish_failed_turn_for_profile_fallback(&mut self) { + if !self.bottom_pane.is_task_running() { + return; + } + + self.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.request_redraw(); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_hidden_diff_history.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_hidden_diff_history.snap new file mode 100644 index 000000000..5c59e8fe1 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_hidden_diff_history.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/exec_flow.rs +expression: lines_to_single_string(&approved_lines) +--- +• Edited files: README.md, src/lib.rs diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap index 8017384f5..015f29e36 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- @@ -34,7 +34,6 @@ expression: term.backend().vt100().screen().contents() - • -- Indented code block (4 spaces) @@ -51,3 +50,4 @@ expression: term.backend().vt100().screen().contents() "path": "C:\\Program Files\\App", "regex": "^foo.*(bar)?$" } +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completed_turn_history_renders_timestamp_separator.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completed_turn_history_renders_timestamp_separator.snap new file mode 100644 index 000000000..6d2bca972 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__completed_turn_history_renders_timestamp_separator.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: combined +--- + +› Add a timestamp to history + + +• Timestamp rendered. + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap index 8c60f961f..71f1c3bec 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap @@ -1,11 +1,11 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Settings - Configure settings for Codex. + Configure UI and realtime settings for Codex. -› 1. Microphone Current: System default - 2. Speaker Current: System default +› 1. UI Configure local TUI-only transcript visibility for reasoning, tool + activity, and diffs. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap index 8c60f961f..7f9698726 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap @@ -1,11 +1,12 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Settings - Configure settings for Codex. + Configure UI and realtime settings for Codex. -› 1. Microphone Current: System default - 2. Speaker Current: System default +› 1. UI Configure local TUI-only transcript + visibility for reasoning, tool activity, and + diffs. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap index 00392bc9d..5f27c1d62 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap @@ -1,17 +1,14 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Microphone Saved devices apply to realtime voice only. - 1. System default Use your operating system - default device. -› Unavailable: Studio Mic (current) (disabled) Configured device is not - currently available. - (disabled: Reconnect the - device or choose another - one.) + 1. System default Use your operating system default + device. +› Unavailable: Studio Mic (current) Configured device is not currently + available. 3. Built-in Mic 4. USB Mic diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_insight_report_ready_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_insight_report_ready_message.snap new file mode 100644 index 000000000..18193d2cf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_insight_report_ready_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: sanitized +--- +• Insight report generated: $CODEX_HOME/reports/insight-.html Patterns and suggestions used local heuristics fallback in this build. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap index 952205e73..893654419 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_non_empty_then_empty_after.snap @@ -1,8 +1,10 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: combined --- ↳ Interacted with background terminal · just fix └ pwd • Waited for background terminal · just fix + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap index fdbdffc5d..c54aaa733 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap @@ -1,7 +1,9 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: combined --- • Waited for background terminal · cargo test -p codex-core • Final response. + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap index f91637e2d..ef9296ca1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap @@ -1,7 +1,9 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: combined --- • Waited for background terminal · cargo test -p codex-core • Streaming response. + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap index 99bf8e2bd..97d24391c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_waiting_multiple_empty_after.snap @@ -1,5 +1,7 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: combined --- • Waited for background terminal · just fix + +─ 2026-04-08 03:04:05 +08:00 ─────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4156082cb..7fbe5cdb2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -261,5 +261,6 @@ mod status_and_layout; mod status_command_tests; pub(crate) use helpers::make_chatwidget_manual_with_sender; +pub(crate) use helpers::render_bottom_popup; pub(crate) use helpers::set_chatgpt_auth; pub(super) use helpers::*; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index a5678a6b9..f15ddb27d 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -795,6 +795,8 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { pending_steers: VecDeque::new(), rejected_steers_queue: VecDeque::new(), queued_user_messages: VecDeque::new(), + last_submitted_user_turn: None, + profile_retry_attempted: false, current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: true, @@ -812,6 +814,78 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn capture_and_restore_thread_input_state_preserves_profile_fallback_retry_state() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(codex_protocol::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }), + }); + drain_insert_history(&mut rx); + + chat.bottom_pane + .set_composer_text("retry me".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let _ = next_submit_op(&mut op_rx); + + assert_eq!( + chat.last_submitted_user_turn(), + Some(UserMessage::from("retry me")) + ); + assert!(!chat.profile_retry_attempted()); + + let snapshot = chat.capture_thread_input_state(); + chat.restore_thread_input_state(/*input_state*/ None); + + assert_eq!(chat.last_submitted_user_turn(), None); + assert!(!chat.profile_retry_attempted()); + + chat.restore_thread_input_state(snapshot); + + assert_eq!( + chat.last_submitted_user_turn(), + Some(UserMessage::from("retry me")) + ); + assert!(!chat.profile_retry_attempted()); + assert!(chat.retry_last_user_turn_for_profile_fallback( + "Retrying the last turn on profile `primary`.".to_string() + )); + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Text { + text: "continue".to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!( + chat.last_submitted_user_turn(), + Some(UserMessage::from("continue")) + ); +} + #[tokio::test] async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 3719ecf3c..0c0174bec 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -732,6 +732,36 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { assert_eq!(status.details(), Some("sleep 5")); } +#[tokio::test] +async fn unified_exec_wait_status_hides_tool_activity_when_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + /*enabled*/ false, + ); + chat.on_task_started(); + chat.unified_exec_processes.push(UnifiedExecProcessSummary { + key: "proc-1".to_string(), + call_id: "call-1".to_string(), + command_display: "sleep 5".to_string(), + recent_chunks: Vec::new(), + }); + + chat.on_terminal_interaction(TerminalInteractionEvent { + call_id: "call-1".to_string(), + process_id: "proc-1".to_string(), + stdin: String::new(), + }); + + assert_eq!(chat.current_status.header, "Working"); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Working"); + assert_eq!(status.details(), None); +} + #[tokio::test] async fn unified_exec_waiting_multiple_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1556,6 +1586,46 @@ async fn apply_patch_manual_flow_snapshot() { ); } +#[tokio::test] +async fn apply_patch_hidden_diff_history_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::PatchDiffs, + /*enabled*/ false, + ); + + let mut apply_changes = HashMap::new(); + apply_changes.insert( + test_project_path().join("src").join("lib.rs"), + FileChange::Add { + content: "pub fn hidden() {}\n".to_string(), + }, + ); + apply_changes.insert( + test_project_path().join("README.md"), + FileChange::Add { + content: "# hidden patch\n".to_string(), + }, + ); + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: "c1".into(), + turn_id: "turn-c1".into(), + auto_approved: true, + changes: apply_changes, + }), + }); + let approved_lines = drain_insert_history(&mut rx) + .pop() + .expect("hidden patch summary cell"); + + assert_chatwidget_snapshot!( + "apply_patch_hidden_diff_history", + lines_to_single_string(&approved_lines) + ); +} + #[tokio::test] async fn apply_patch_approval_sends_op_with_call_id() { 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 46a4129a4..3ed5cc4c8 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -179,6 +179,7 @@ pub(super) async fn make_chatwidget_manual( }; let current_collaboration_mode = base_mode; let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref()); + let display_preferences = crate::display_preferences::DisplayPreferences::from_config(&cfg); let mut widget = ChatWidget { app_event_tx, codex_op_target: super::CodexOpTarget::Direct(op_tx), @@ -219,6 +220,8 @@ pub(super) async fn make_chatwidget_manual( turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), + background_workflow_labels: Vec::new(), + queued_workflow_labels: Vec::new(), agent_turn_running: false, mcp_startup_status: None, mcp_startup_expected_servers: None, @@ -235,8 +238,11 @@ pub(super) async fn make_chatwidget_manual( plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), interrupts: InterruptManager::new(), + display_preferences, reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), + raw_reasoning_buffer: String::new(), + full_raw_reasoning_buffer: String::new(), current_status: StatusIndicatorState::working(), retry_status_header: None, pending_status_indicator_restore: false, @@ -263,6 +269,7 @@ pub(super) async fn make_chatwidget_manual( had_work_activity: false, saw_plan_update_this_turn: false, saw_plan_item_this_turn: false, + history_timestamp_label_provider: Arc::new(|| "2026-04-08 03:04:05 +08:00".to_string()), last_plan_progress: None, plan_delta_buffer: String::new(), plan_item_active: false, @@ -287,6 +294,8 @@ pub(super) async fn make_chatwidget_manual( realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_event: None, last_non_retry_error: None, + last_submitted_user_turn: None, + profile_retry_attempted: false, }; widget.set_model(&resolved_model); (widget, rx, op_rx) @@ -669,7 +678,7 @@ pub(super) fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { String::new() } -pub(super) fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { +pub(crate) fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58993d67e..b0f3bab50 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -583,7 +583,6 @@ async fn replayed_thread_closed_notification_does_not_exit_tui() { #[tokio::test] async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.show_raw_agent_reasoning = false; chat.handle_codex_event(Event { id: "configured".into(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -630,7 +629,10 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { #[tokio::test] async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.show_raw_agent_reasoning = true; + chat.display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::RawThinking, + /*enabled*/ true, + ); chat.handle_codex_event(Event { id: "configured".into(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -664,12 +666,11 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { ReplayKind::ThreadSnapshot, ); - let rendered = match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => { - lines_to_single_string(&cell.transcript_lines(/*width*/ 80)) - } - other => panic!("expected InsertHistoryCell, got {other:?}"), - }; + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::>() + .join("\n\n"); assert!(rendered.contains("Raw reasoning")); } diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index d9ad9d9a3..8665fe4fc 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -176,12 +176,12 @@ async fn approvals_popup_shows_disabled_presets() { let screen = terminal.backend().vt100().screen().contents(); let collapsed = screen.split_whitespace().collect::>().join(" "); assert!( - collapsed.contains("(disabled)"), - "disabled preset label should be shown" + !collapsed.contains("(disabled)"), + "disabled preset label should stay hidden" ); assert!( - collapsed.contains("this message should be printed in the description"), - "disabled preset reason should be shown" + !collapsed.contains("this message should be printed in the description"), + "disabled preset reason should stay hidden" ); } diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 265339f5d..d635c66e2 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1219,6 +1219,7 @@ async fn collaboration_modes_defaults_to_code_on_startup() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), + display_preferences: crate::display_preferences::DisplayPreferences::from_config(&cfg), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), initial_user_message: None, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index f89926785..a41459211 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -62,6 +62,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), + display_preferences: crate::display_preferences::DisplayPreferences::from_config(&cfg), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), initial_user_message: None, @@ -1525,7 +1526,7 @@ async fn personality_selection_popup_snapshot() { #[tokio::test] async fn realtime_audio_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; - chat.open_realtime_audio_popup(); + chat.open_settings_popup(); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("realtime_audio_selection_popup", popup); @@ -1535,7 +1536,7 @@ async fn realtime_audio_selection_popup_snapshot() { #[tokio::test] async fn realtime_audio_selection_popup_narrow_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; - chat.open_realtime_audio_popup(); + chat.open_settings_popup(); let popup = render_bottom_popup(&chat, /*width*/ 56); assert_chatwidget_snapshot!("realtime_audio_selection_popup_narrow", popup); diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 2034921a0..6ff03a79d 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -354,6 +354,8 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ pending_steers, rejected_steers_queue, queued_user_messages, + last_submitted_user_turn: None, + profile_retry_attempted: false, current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: false, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 76ab5bc95..616ccabca 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,5 +1,6 @@ use super::*; use pretty_assertions::assert_eq; +use std::time::Duration; #[tokio::test] async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { @@ -79,6 +80,58 @@ async fn slash_init_skips_when_project_doc_exists() { ); } +#[tokio::test] +async fn slash_btw_requires_prompt() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Btw); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one error message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Usage: /btw "), + "expected usage message, got {rendered:?}" + ); +} + +#[tokio::test] +async fn slash_thread_dispatches_open_thread_panel_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Thread); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenThreadPanel)); +} + +#[tokio::test] +async fn slash_profile_dispatches_open_profile_management_panel_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Profile); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenProfileManagementPanel)); +} + +#[tokio::test] +async fn slash_btw_dispatches_start_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.bottom_pane.set_composer_text( + "/btw compare the two approaches".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::StartBtwDiscussion { prompt }) => { + assert_eq!(prompt, "compare the two approaches"); + } + other => panic!("expected StartBtwDiscussion event, got {other:?}"), + } +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -189,6 +242,140 @@ async fn slash_copy_state_clears_on_thread_rollback() { assert_eq!(chat.last_copyable_output, None); } +#[tokio::test] +async fn slash_insight_generates_report_and_announces_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let temp_home = tempdir().unwrap(); + let codex_home = temp_home.path().to_path_buf(); + let sessions_dir = codex_home.join("sessions"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + chat.config.codex_home = codex_home.clone(); + chat.config.sqlite_home = codex_home.clone(); + + let thread_id = ThreadId::new(); + let rollout_path = + sessions_dir.join("rollout-2026-04-08T12-00-00-00000000-0000-0000-0000-000000000001.jsonl"); + let rollout = [ + serde_json::to_string(&codex_protocol::protocol::RolloutLine { + timestamp: "2026-04-08T12:00:00Z".to_string(), + item: codex_protocol::protocol::RolloutItem::SessionMeta( + codex_protocol::protocol::SessionMetaLine { + meta: codex_protocol::protocol::SessionMeta { + id: thread_id, + forked_from_id: None, + timestamp: "2026-04-08T12:00:00Z".to_string(), + cwd: PathBuf::from("/repo"), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, + agent_path: None, + model_provider: Some("openai".to_string()), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }, + git: None, + }, + ), + }) + .unwrap(), + serde_json::to_string(&codex_protocol::protocol::RolloutLine { + timestamp: "2026-04-08T12:00:05Z".to_string(), + item: codex_protocol::protocol::RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "## My request for Codex: analyze local sessions".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + )), + }) + .unwrap(), + serde_json::to_string(&codex_protocol::protocol::RolloutLine { + timestamp: "2026-04-08T12:00:10Z".to_string(), + item: codex_protocol::protocol::RolloutItem::EventMsg(EventMsg::TokenCount( + TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 80, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 5, + total_tokens: 100, + }, + last_token_usage: TokenUsage::default(), + model_context_window: Some(128000), + }), + rate_limits: None, + }, + )), + }) + .unwrap(), + serde_json::to_string(&codex_protocol::protocol::RolloutLine { + timestamp: "2026-04-08T12:00:12Z".to_string(), + item: codex_protocol::protocol::RolloutItem::EventMsg(EventMsg::ExecCommandEnd( + ExecCommandEndEvent { + call_id: "call-1".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command: vec!["rg".to_string(), "insight".to_string()], + cwd: PathBuf::from("/repo"), + parsed_cmd: Vec::new(), + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: Duration::from_secs(1), + formatted_output: String::new(), + status: CoreExecCommandStatus::Completed, + }, + )), + }) + .unwrap(), + ] + .join("\n"); + std::fs::write(&rollout_path, rollout).unwrap(); + + chat.dispatch_command(SlashCommand::Insight); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected immediate status message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Generating /insight report from local sessions"), + "unexpected initial message: {rendered:?}" + ); + + let final_cell = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match rx.recv().await { + Some(AppEvent::InsertHistoryCell(cell)) => break cell, + Some(_) => continue, + None => panic!("expected report completion cell"), + } + } + }) + .await + .expect("completion event should arrive"); + let rendered = lines_to_single_string(&final_cell.display_lines(u16::MAX)); + let mut sanitized = rendered.replace(codex_home.display().to_string().as_str(), "$CODEX_HOME"); + if let Some(start) = sanitized.find("insight-") + && let Some(end) = sanitized[start..].find(".html") + { + sanitized.replace_range( + start..start + end + ".html".len(), + "insight-.html", + ); + } + assert_chatwidget_snapshot!("slash_insight_report_ready_message", sanitized); + assert!(rendered.contains("Insight report generated:")); + assert!(codex_home.join("reports").exists()); +} + #[tokio::test] async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_turn_complete() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -336,11 +523,23 @@ async fn slash_stop_submits_background_terminal_cleanup() { assert_eq!(cells.len(), 1, "expected cleanup confirmation message"); let rendered = lines_to_single_string(&cells[0]); assert!( - rendered.contains("Stopping all background terminals."), + rendered.contains("Stopping all background tasks."), "expected cleanup confirmation, got {rendered:?}" ); } +#[tokio::test] +async fn slash_stop_confirmation_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Stop); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + insta::assert_snapshot!(rendered); +} + #[tokio::test] async fn slash_clear_requests_ui_clear_when_idle() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -401,6 +600,25 @@ async fn slash_mcp_requests_inventory_via_app_server() { assert_matches!(rx.try_recv(), Ok(AppEvent::FetchMcpInventory)); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); } +#[tokio::test] +async fn slash_workflow_opens_controls_popup() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Workflow); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenWorkflowControls)); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_clawbot_opens_management_popup() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Clawbot); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenClawbotManagement)); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} #[tokio::test] async fn slash_memory_update_reports_stubbed_feature() { diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_stop_confirmation_snapshot.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_stop_confirmation_snapshot.snap new file mode 100644 index 000000000..d8a09084d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__slash_commands__slash_stop_confirmation_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +assertion_line: 405 +expression: rendered +--- +• Stopping all background tasks. diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7e1a6ba8..929eb1b8b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -120,6 +120,7 @@ async fn helpers_are_available_and_do_not_panic() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), + display_preferences: crate::display_preferences::DisplayPreferences::from_config(&cfg), frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, initial_user_message: None, @@ -1193,6 +1194,36 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { ); } +#[tokio::test] +async fn completed_turn_history_renders_timestamp_separator_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + complete_user_message(&mut chat, "msg-user", "Add a timestamp to history"); + complete_assistant_message( + &mut chat, + "msg-assistant", + "Timestamp rendered.", + /*phase*/ None, + ); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("Timestamp rendered.".into()), + }), + }); + + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_chatwidget_snapshot!( + "completed_turn_history_renders_timestamp_separator", + combined + ); +} + #[tokio::test] async fn deltas_then_same_final_message_are_rendered_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/display_preferences.rs b/codex-rs/tui/src/display_preferences.rs new file mode 100644 index 000000000..3dc22226f --- /dev/null +++ b/codex-rs/tui/src/display_preferences.rs @@ -0,0 +1,252 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use codex_core::config::Config; +use codex_core::config::edit::ConfigEdit; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DisplayPreferenceKey { + StartupTooltips, + RawThinking, + ToolResults, + PatchDiffs, +} + +#[derive(Clone, Debug)] +pub(crate) struct DisplayPreferences { + show_startup_tooltips: Arc, + show_raw_thinking: Arc, + show_tool_results: Arc, + show_patch_diffs: Arc, +} + +impl Default for DisplayPreferences { + fn default() -> Self { + Self { + show_startup_tooltips: Arc::new(AtomicBool::new(true)), + show_raw_thinking: Arc::new(AtomicBool::new(false)), + show_tool_results: Arc::new(AtomicBool::new(true)), + show_patch_diffs: Arc::new(AtomicBool::new(true)), + } + } +} + +impl DisplayPreferences { + pub(crate) fn from_config(config: &Config) -> Self { + let preferences = Self::default(); + preferences.sync_from_config(config); + preferences + } + + pub(crate) fn is_enabled(&self, key: DisplayPreferenceKey) -> bool { + match key { + DisplayPreferenceKey::StartupTooltips => self.show_startup_tooltips(), + DisplayPreferenceKey::RawThinking => self.show_raw_thinking(), + DisplayPreferenceKey::ToolResults => self.show_tool_results(), + DisplayPreferenceKey::PatchDiffs => self.show_patch_diffs(), + } + } + + pub(crate) fn set_enabled(&self, key: DisplayPreferenceKey, enabled: bool) { + match key { + DisplayPreferenceKey::StartupTooltips => { + self.show_startup_tooltips.store(enabled, Ordering::Relaxed); + } + DisplayPreferenceKey::RawThinking => { + self.show_raw_thinking.store(enabled, Ordering::Relaxed); + } + DisplayPreferenceKey::ToolResults => { + self.show_tool_results.store(enabled, Ordering::Relaxed); + } + DisplayPreferenceKey::PatchDiffs => { + self.show_patch_diffs.store(enabled, Ordering::Relaxed); + } + } + } + + pub(crate) fn show_startup_tooltips(&self) -> bool { + self.show_startup_tooltips.load(Ordering::Relaxed) + } + + pub(crate) fn show_raw_thinking(&self) -> bool { + self.show_raw_thinking.load(Ordering::Relaxed) + } + + pub(crate) fn show_tool_results(&self) -> bool { + self.show_tool_results.load(Ordering::Relaxed) + } + + pub(crate) fn show_patch_diffs(&self) -> bool { + self.show_patch_diffs.load(Ordering::Relaxed) + } + + pub(crate) fn sync_from_config(&self, config: &Config) { + self.show_startup_tooltips + .store(config.show_tooltips, Ordering::Relaxed); + self.show_raw_thinking + .store(config.show_raw_agent_reasoning, Ordering::Relaxed); + self.show_tool_results.store( + config.tui_display_preferences.show_tool_results, + Ordering::Relaxed, + ); + self.show_patch_diffs.store( + config.tui_display_preferences.show_patch_diffs, + Ordering::Relaxed, + ); + } +} + +pub(crate) fn display_preference_edit(key: DisplayPreferenceKey, enabled: bool) -> ConfigEdit { + match key { + DisplayPreferenceKey::StartupTooltips => ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "show_tooltips".to_string()], + value: enabled.into(), + }, + DisplayPreferenceKey::RawThinking => ConfigEdit::SetPath { + segments: vec!["show_raw_agent_reasoning".to_string()], + value: enabled.into(), + }, + DisplayPreferenceKey::ToolResults => ConfigEdit::SetPath { + segments: vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_tool_results".to_string(), + ], + value: enabled.into(), + }, + DisplayPreferenceKey::PatchDiffs => ConfigEdit::SetPath { + segments: vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_patch_diffs".to_string(), + ], + value: enabled.into(), + }, + } +} + +pub(crate) fn set_display_preference_in_config( + config: &mut Config, + key: DisplayPreferenceKey, + enabled: bool, +) { + match key { + DisplayPreferenceKey::StartupTooltips => config.show_tooltips = enabled, + DisplayPreferenceKey::RawThinking => config.show_raw_agent_reasoning = enabled, + DisplayPreferenceKey::ToolResults => { + config.tui_display_preferences.show_tool_results = enabled; + } + DisplayPreferenceKey::PatchDiffs => { + config.tui_display_preferences.show_patch_diffs = enabled; + } + } +} + +#[cfg(test)] +mod tests { + use super::DisplayPreferenceKey; + use super::DisplayPreferences; + use super::display_preference_edit; + use super::set_display_preference_in_config; + use codex_core::config::ConfigBuilder; + use codex_core::config::edit::ConfigEdit; + + #[tokio::test] + async fn startup_tooltips_follow_config_and_setters() { + let mut config = ConfigBuilder::default().build().await.expect("config"); + config.show_tooltips = false; + + let preferences = DisplayPreferences::from_config(&config); + assert!(!preferences.show_startup_tooltips()); + + preferences.set_enabled(DisplayPreferenceKey::StartupTooltips, /*enabled*/ true); + assert!(preferences.show_startup_tooltips()); + + set_display_preference_in_config( + &mut config, + DisplayPreferenceKey::StartupTooltips, + /*enabled*/ true, + ); + assert!(config.show_tooltips); + } + + #[tokio::test] + async fn transcript_visibility_preferences_follow_config_and_setters() { + let mut config = ConfigBuilder::default().build().await.expect("config"); + config.tui_display_preferences.show_tool_results = false; + config.tui_display_preferences.show_patch_diffs = false; + + let preferences = DisplayPreferences::from_config(&config); + assert!(!preferences.show_tool_results()); + assert!(!preferences.show_patch_diffs()); + + preferences.set_enabled(DisplayPreferenceKey::ToolResults, /*enabled*/ true); + preferences.set_enabled(DisplayPreferenceKey::PatchDiffs, /*enabled*/ true); + assert!(preferences.show_tool_results()); + assert!(preferences.show_patch_diffs()); + + set_display_preference_in_config( + &mut config, + DisplayPreferenceKey::ToolResults, + /*enabled*/ true, + ); + set_display_preference_in_config( + &mut config, + DisplayPreferenceKey::PatchDiffs, + /*enabled*/ true, + ); + assert!(config.tui_display_preferences.show_tool_results); + assert!(config.tui_display_preferences.show_patch_diffs); + } + + #[test] + fn startup_tooltips_edit_targets_tui_show_tooltips() { + match display_preference_edit( + DisplayPreferenceKey::StartupTooltips, + /*enabled*/ false, + ) { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + vec!["tui".to_string(), "show_tooltips".to_string()] + ); + assert_eq!(value.to_string(), "false"); + } + other => panic!("unexpected config edit: {other:?}"), + } + } + + #[test] + fn transcript_visibility_edits_target_tui_display_preferences() { + match display_preference_edit(DisplayPreferenceKey::ToolResults, /*enabled*/ false) { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_tool_results".to_string(), + ] + ); + assert_eq!(value.to_string(), "false"); + } + other => panic!("unexpected config edit: {other:?}"), + } + + match display_preference_edit(DisplayPreferenceKey::PatchDiffs, /*enabled*/ false) { + ConfigEdit::SetPath { segments, value } => { + assert_eq!( + segments, + vec![ + "tui".to_string(), + "display_preferences".to_string(), + "show_patch_diffs".to_string(), + ] + ); + assert_eq!(value.to_string(), "false"); + } + other => panic!("unexpected config edit: {other:?}"), + } + } +} diff --git a/codex-rs/tui/src/display_preferences_menu.rs b/codex-rs/tui/src/display_preferences_menu.rs new file mode 100644 index 000000000..44f26501f --- /dev/null +++ b/codex-rs/tui/src/display_preferences_menu.rs @@ -0,0 +1,146 @@ +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::display_preferences::DisplayPreferenceKey; +use crate::display_preferences::DisplayPreferences; +use ratatui::style::Stylize; + +pub(crate) const DISPLAY_PREFERENCES_SELECTION_VIEW_ID: &str = "display-preferences-panel"; + +pub(crate) fn display_preferences_items( + display_preferences: &DisplayPreferences, +) -> Vec { + [ + DisplayPreferenceKey::StartupTooltips, + DisplayPreferenceKey::RawThinking, + DisplayPreferenceKey::ToolResults, + DisplayPreferenceKey::PatchDiffs, + ] + .into_iter() + .map(|key| display_preference_item(display_preferences, key)) + .collect() +} + +pub(crate) fn display_preferences_panel_params( + display_preferences: &DisplayPreferences, + initial_selected_idx: Option, +) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(DISPLAY_PREFERENCES_SELECTION_VIEW_ID), + title: Some("UI".to_string()), + subtitle: Some("These settings only affect local TUI rendering.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + footer_note: Some( + "Model context and persisted rollout history are unchanged." + .dim() + .into(), + ), + items: display_preferences_items(display_preferences), + initial_selected_idx, + ..Default::default() + } +} + +fn display_preference_item( + display_preferences: &DisplayPreferences, + key: DisplayPreferenceKey, +) -> SelectionItem { + let enabled = display_preferences.is_enabled(key); + let (name, description) = match (key, enabled) { + (DisplayPreferenceKey::StartupTooltips, true) => ( + "Hide Startup Tooltips", + "Currently visible. Hide first-run and local startup tooltip hints in this TUI.", + ), + (DisplayPreferenceKey::StartupTooltips, false) => ( + "Show Startup Tooltips", + "Currently hidden. Restore first-run and local startup tooltip hints in this TUI.", + ), + (DisplayPreferenceKey::RawThinking, true) => ( + "Hide Raw Thinking", + "Currently visible. Hide raw reasoning text while keeping summaries.", + ), + (DisplayPreferenceKey::RawThinking, false) => ( + "Show Raw Thinking", + "Currently hidden. Reveal raw reasoning text in this TUI only.", + ), + (DisplayPreferenceKey::ToolResults, true) => ( + "Hide Tool Activity", + "Currently visible. Hide tool calls and result details in transcript cells.", + ), + (DisplayPreferenceKey::ToolResults, false) => ( + "Show Tool Activity", + "Currently hidden. Reveal tool calls and result details in transcript cells.", + ), + (DisplayPreferenceKey::PatchDiffs, true) => ( + "Hide Patch Diffs", + "Currently visible. Collapse patch/edit diff summaries in transcript cells.", + ), + (DisplayPreferenceKey::PatchDiffs, false) => ( + "Show Patch Diffs", + "Currently hidden. Reveal patch/edit diff summaries in transcript cells.", + ), + }; + + SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ToggleDisplayPreference(key)); + })], + dismiss_on_select: false, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::render::renderable::Renderable; + + fn render_lines(view: &ListSelectionView) -> String { + let width = 48; + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line + }) + .collect::>() + .join("\n") + } + + #[test] + fn display_preferences_menu_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + display_preferences_panel_params( + &DisplayPreferences::default(), + /*initial_selected_idx*/ None, + ), + tx, + ); + + assert_snapshot!("display_preferences_menu", render_lines(&view)); + } +} diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 878d42c71..f99f96151 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -8,6 +8,7 @@ use std::time::Duration; use std::time::Instant; +use crate::display_preferences::DisplayPreferences; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ExecCommandSource; @@ -36,6 +37,7 @@ pub(crate) struct ExecCall { pub(crate) struct ExecCell { pub(crate) calls: Vec, animations_enabled: bool, + display_preferences: DisplayPreferences, } impl ExecCell { @@ -43,9 +45,18 @@ impl ExecCell { Self { calls: vec![call], animations_enabled, + display_preferences: DisplayPreferences::default(), } } + pub(crate) fn with_display_preferences( + mut self, + display_preferences: DisplayPreferences, + ) -> Self { + self.display_preferences = display_preferences; + self + } + pub(crate) fn with_added_call( &self, call_id: String, @@ -68,6 +79,7 @@ impl ExecCell { Some(Self { calls: [self.calls.clone(), vec![call]].concat(), animations_enabled: self.animations_enabled, + display_preferences: self.display_preferences.clone(), }) } else { None @@ -135,6 +147,10 @@ impl ExecCell { self.animations_enabled } + pub(crate) fn show_tool_results(&self) -> bool { + self.display_preferences.show_tool_results() + } + pub(crate) fn iter_calls(&self) -> impl Iterator { self.calls.iter() } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 075b5a7ad..81fd82fce 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -3,6 +3,7 @@ use std::time::Instant; use super::model::CommandOutput; use super::model::ExecCall; use super::model::ExecCell; +use crate::display_preferences::DisplayPreferences; use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell::HistoryCell; use crate::render::highlight::highlight_bash_to_lines; @@ -44,6 +45,7 @@ pub(crate) fn new_active_exec_command( source: ExecCommandSource, interaction_input: Option, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> ExecCell { ExecCell::new( ExecCall { @@ -58,6 +60,7 @@ pub(crate) fn new_active_exec_command( }, animations_enabled, ) + .with_display_preferences(display_preferences) } fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String { @@ -197,6 +200,9 @@ pub(crate) fn spinner(start_time: Option, animations_enabled: bool) -> impl HistoryCell for ExecCell { fn display_lines(&self, width: u16) -> Vec> { + if !self.show_tool_results() { + return Vec::new(); + } if self.is_exploring_cell() { self.exploring_display_lines(width) } else { @@ -205,6 +211,9 @@ impl HistoryCell for ExecCell { } fn transcript_lines(&self, width: u16) -> Vec> { + if !self.show_tool_results() { + return Vec::new(); + } let mut lines: Vec> = vec![]; for (i, call) in self.iter_calls().enumerate() { if i > 0 { diff --git a/codex-rs/tui/src/external_editor.rs b/codex-rs/tui/src/external_editor.rs index 2818503d0..a27c7a92c 100644 --- a/codex-rs/tui/src/external_editor.rs +++ b/codex-rs/tui/src/external_editor.rs @@ -1,5 +1,6 @@ use std::env; use std::fs; +use std::path::Path; use std::process::Stdio; use color_eyre::eyre::Report; @@ -10,7 +11,7 @@ use tokio::process::Command; #[derive(Debug, Error)] pub(crate) enum EditorError { - #[error("neither VISUAL nor EDITOR is set")] + #[error("no usable editor found in VISUAL, EDITOR, or vim")] MissingEditor, #[cfg(not(windows))] #[error("failed to parse editor command")] @@ -28,66 +29,128 @@ fn resolve_windows_program(program: &str) -> std::path::PathBuf { which::which(program).unwrap_or_else(|_| std::path::PathBuf::from(program)) } -/// Resolve the editor command from environment variables. -/// Prefers `VISUAL` over `EDITOR`. -pub(crate) fn resolve_editor_command() -> std::result::Result, EditorError> { - let raw = env::var("VISUAL") - .or_else(|_| env::var("EDITOR")) - .map_err(|_| EditorError::MissingEditor)?; - let parts = { - #[cfg(windows)] - { - winsplit::split(&raw) +/// Resolve editor commands from environment variables, then fall back to `vim`. +/// Prefers `VISUAL` over `EDITOR`, but will try each configured editor before `vim`. +pub(crate) fn resolve_editor_commands() -> std::result::Result>, EditorError> { + let mut commands = Vec::new(); + let mut last_error = None; + + for key in ["VISUAL", "EDITOR"] { + let Ok(raw) = env::var(key) else { + continue; + }; + let parts = { + #[cfg(windows)] + { + winsplit::split(&raw) + } + #[cfg(not(windows))] + { + match shlex::split(&raw) { + Some(parts) => parts, + None => { + last_error = Some(EditorError::ParseFailed); + continue; + } + } + } + }; + if parts.is_empty() { + last_error = Some(EditorError::EmptyCommand); + continue; } - #[cfg(not(windows))] - { - shlex::split(&raw).ok_or(EditorError::ParseFailed)? + if !commands.contains(&parts) { + commands.push(parts); } - }; - if parts.is_empty() { - return Err(EditorError::EmptyCommand); } - Ok(parts) + + let vim = vec!["vim".to_string()]; + if !commands.contains(&vim) { + commands.push(vim); + } + + if commands.is_empty() { + return Err(last_error.unwrap_or(EditorError::MissingEditor)); + } + + Ok(commands) +} + +/// Write `seed` to a temp file, launch an editor, and return the updated content. +pub(crate) async fn run_editor(seed: &str, editor_cmds: &[Vec]) -> Result { + run_editor_with_suffix(seed, editor_cmds, ".md").await } -/// Write `seed` to a temp file, launch the editor command, and return the updated content. -pub(crate) async fn run_editor(seed: &str, editor_cmd: &[String]) -> Result { - if editor_cmd.is_empty() { - return Err(Report::msg("editor command is empty")); +/// Write `seed` to a temp file with a custom suffix, launch an editor, +/// and return the updated content. +pub(crate) async fn run_editor_with_suffix( + seed: &str, + editor_cmds: &[Vec], + suffix: &str, +) -> Result { + if editor_cmds.is_empty() { + return Err(Report::msg("editor command list is empty")); } // Convert to TempPath immediately so no file handle stays open on Windows. - let temp_path = Builder::new().suffix(".md").tempfile()?.into_temp_path(); + let temp_path = Builder::new().suffix(suffix).tempfile()?.into_temp_path(); fs::write(&temp_path, seed)?; + launch_editor_for_path(&temp_path, editor_cmds).await?; + let contents = fs::read_to_string(&temp_path)?; + Ok(contents) +} - let mut cmd = { - #[cfg(windows)] - { - // handles .cmd/.bat shims - Command::new(resolve_windows_program(&editor_cmd[0])) +/// Launch the editor against an existing file path. +pub(crate) async fn edit_file(path: &Path, editor_cmds: &[Vec]) -> Result<()> { + if editor_cmds.is_empty() { + return Err(Report::msg("editor command list is empty")); + } + launch_editor_for_path(path, editor_cmds).await +} + +async fn launch_editor_for_path(path: &Path, editor_cmds: &[Vec]) -> Result<()> { + let mut failures = Vec::new(); + + for editor_cmd in editor_cmds { + if editor_cmd.is_empty() { + failures.push("editor command is empty".to_string()); + continue; } - #[cfg(not(windows))] + + let mut cmd = { + #[cfg(windows)] + { + // handles .cmd/.bat shims + Command::new(resolve_windows_program(&editor_cmd[0])) + } + #[cfg(not(windows))] + { + Command::new(&editor_cmd[0]) + } + }; + if editor_cmd.len() > 1 { + cmd.args(&editor_cmd[1..]); + } + let command = shlex::try_join(editor_cmd.iter().map(String::as_str)) + .unwrap_or_else(|_| editor_cmd.join(" ")); + match cmd + .arg(path) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await { - Command::new(&editor_cmd[0]) + Ok(status) if status.success() => return Ok(()), + Ok(status) => failures.push(format!("`{command}` exited with status {status}")), + Err(err) => failures.push(format!("`{command}`: {err}")), } - }; - if editor_cmd.len() > 1 { - cmd.args(&editor_cmd[1..]); - } - let status = cmd - .arg(&temp_path) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .await?; - - if !status.success() { - return Err(Report::msg(format!("editor exited with status {status}"))); } - let contents = fs::read_to_string(&temp_path)?; - Ok(contents) + Err(Report::msg(format!( + "failed to open any editor: {}", + failures.join("; ") + ))) } #[cfg(test)] @@ -95,12 +158,12 @@ mod tests { use super::*; use pretty_assertions::assert_eq; use serial_test::serial; - #[cfg(unix)] use tempfile::tempdir; struct EnvGuard { visual: Option, editor: Option, + path: Option, } impl EnvGuard { @@ -108,6 +171,7 @@ mod tests { Self { visual: env::var("VISUAL").ok(), editor: env::var("EDITOR").ok(), + path: env::var("PATH").ok(), } } } @@ -116,6 +180,7 @@ mod tests { fn drop(&mut self) { restore_env("VISUAL", self.visual.take()); restore_env("EDITOR", self.editor.take()); + restore_env("PATH", self.path.take()); } } @@ -134,22 +199,20 @@ mod tests { env::set_var("VISUAL", "vis"); env::set_var("EDITOR", "ed"); } - let cmd = resolve_editor_command().unwrap(); - assert_eq!(cmd, vec!["vis".to_string()]); + let commands = resolve_editor_commands().unwrap(); + assert_eq!(commands[0], vec!["vis".to_string()]); } #[test] #[serial] - fn resolve_editor_errors_when_unset() { + fn resolve_editor_falls_back_to_vim_when_unset() { let _guard = EnvGuard::new(); unsafe { env::remove_var("VISUAL"); env::remove_var("EDITOR"); } - assert!(matches!( - resolve_editor_command(), - Err(EditorError::MissingEditor) - )); + let commands = resolve_editor_commands().unwrap(); + assert_eq!(commands, vec![vec!["vim".to_string()]]); } #[tokio::test] @@ -164,8 +227,60 @@ mod tests { perms.set_mode(0o755); fs::set_permissions(&script_path, perms).unwrap(); - let cmd = vec![script_path.to_string_lossy().to_string()]; - let result = run_editor("seed", &cmd).await.unwrap(); + let commands = vec![vec![script_path.to_string_lossy().to_string()]]; + let result = run_editor("seed", &commands).await.unwrap(); assert_eq!(result, "edited".to_string()); } + + #[tokio::test] + #[cfg(unix)] + async fn edit_file_updates_existing_path() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let script_path = dir.path().join("edit.sh"); + fs::write(&script_path, "#!/bin/sh\nprintf \"updated\" > \"$1\"\n").unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + + let file_path = dir.path().join("workflow.yaml"); + fs::write(&file_path, "seed").unwrap(); + + let commands = vec![vec![script_path.to_string_lossy().to_string()]]; + edit_file(&file_path, &commands).await.unwrap(); + assert_eq!(fs::read_to_string(&file_path).unwrap(), "updated"); + } + + #[tokio::test] + #[cfg(unix)] + #[serial] + async fn run_editor_falls_back_when_primary_editor_fails() { + use std::os::unix::fs::PermissionsExt; + + let _guard = EnvGuard::new(); + let dir = tempdir().unwrap(); + + let failing_editor = dir.path().join("broken-editor.sh"); + fs::write(&failing_editor, "#!/bin/sh\nexit 1\n").unwrap(); + let mut perms = fs::metadata(&failing_editor).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&failing_editor, perms).unwrap(); + + let vim_path = dir.path().join("vim"); + fs::write(&vim_path, "#!/bin/sh\nprintf \"edited\" > \"$1\"\n").unwrap(); + let mut perms = fs::metadata(&vim_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&vim_path, perms).unwrap(); + + unsafe { + env::set_var("VISUAL", failing_editor.as_os_str()); + env::remove_var("EDITOR"); + env::set_var("PATH", dir.path()); + } + + let commands = resolve_editor_commands().unwrap(); + let result = run_editor("seed", &commands).await.unwrap(); + assert_eq!(result, "edited"); + } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index cf65f918e..11608db72 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -12,6 +12,7 @@ use crate::diff_render::create_diff_summary; use crate::diff_render::display_path_for; +use crate::display_preferences::DisplayPreferences; use crate::exec_cell::CommandOutput; use crate::exec_cell::OutputLinesParams; use crate::exec_cell::TOOL_CALL_MAX_LINES; @@ -446,6 +447,36 @@ impl HistoryCell for ReasoningSummaryCell { } } +#[derive(Debug)] +pub(crate) struct ReasoningRawContentCell { + content: String, + cwd: PathBuf, + display_preferences: DisplayPreferences, +} + +impl HistoryCell for ReasoningRawContentCell { + fn display_lines(&self, width: u16) -> Vec> { + if !self.display_preferences.show_raw_thinking() { + return Vec::new(); + } + + let mut lines = vec![vec!["• ".dim(), "Raw Thinking".dim().italic()].into()]; + let mut body = Vec::new(); + append_markdown( + &self.content, + Some((width as usize).saturating_sub(4)), + Some(self.cwd.as_path()), + &mut body, + ); + lines.extend(prefix_lines(body, " ".into(), " ".into())); + lines + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.display_lines(width) + } +} + #[derive(Debug)] pub(crate) struct AgentMessageCell { lines: Vec>, @@ -588,19 +619,28 @@ impl HistoryCell for PrefixedWrappedHistoryCell { pub(crate) struct UnifiedExecInteractionCell { command_display: Option, stdin: String, + display_preferences: DisplayPreferences, } impl UnifiedExecInteractionCell { - pub(crate) fn new(command_display: Option, stdin: String) -> Self { + pub(crate) fn new( + command_display: Option, + stdin: String, + display_preferences: DisplayPreferences, + ) -> Self { Self { command_display, stdin, + display_preferences, } } } impl HistoryCell for UnifiedExecInteractionCell { fn display_lines(&self, width: u16) -> Vec> { + if !self.display_preferences.show_tool_results() { + return Vec::new(); + } if width == 0 { return Vec::new(); } @@ -648,18 +688,38 @@ impl HistoryCell for UnifiedExecInteractionCell { pub(crate) fn new_unified_exec_interaction( command_display: Option, stdin: String, + display_preferences: DisplayPreferences, ) -> UnifiedExecInteractionCell { - UnifiedExecInteractionCell::new(command_display, stdin) + UnifiedExecInteractionCell::new(command_display, stdin, display_preferences) } #[derive(Debug)] struct UnifiedExecProcessesCell { processes: Vec, + running_workflows: Vec, + queued_workflows: Vec, } impl UnifiedExecProcessesCell { + #[cfg(test)] fn new(processes: Vec) -> Self { - Self { processes } + Self { + processes, + running_workflows: Vec::new(), + queued_workflows: Vec::new(), + } + } + + fn new_with_workflows( + processes: Vec, + running_workflows: Vec, + queued_workflows: Vec, + ) -> Self { + Self { + processes, + running_workflows, + queued_workflows, + } } } @@ -678,14 +738,63 @@ impl HistoryCell for UnifiedExecProcessesCell { let wrap_width = width as usize; let max_processes = 16usize; let mut out: Vec> = Vec::new(); - out.push(vec!["Background terminals".bold()].into()); + let show_workflows = + !self.running_workflows.is_empty() || !self.queued_workflows.is_empty(); + out.push( + vec![if show_workflows { + "Background tasks".bold() + } else { + "Background terminals".bold() + }] + .into(), + ); out.push("".into()); - if self.processes.is_empty() { + if !show_workflows && self.processes.is_empty() { out.push(" • No background terminals running.".italic().into()); return out; } + if show_workflows && self.processes.is_empty() { + if !self.running_workflows.is_empty() { + out.push("Running workflows".bold().into()); + for workflow in &self.running_workflows { + out.push(vec![" • ".dim(), workflow.clone().cyan()].into()); + } + } + if !self.running_workflows.is_empty() && !self.queued_workflows.is_empty() { + out.push("".into()); + } + if !self.queued_workflows.is_empty() { + out.push("Queued workflows".bold().into()); + for workflow in &self.queued_workflows { + out.push(vec![" • ".dim(), workflow.clone().magenta()].into()); + } + } + return out; + } + + if !self.running_workflows.is_empty() { + out.push("Running workflows".bold().into()); + for workflow in &self.running_workflows { + out.push(vec![" • ".dim(), workflow.clone().cyan()].into()); + } + out.push("".into()); + } + + if !self.queued_workflows.is_empty() { + out.push("Queued workflows".bold().into()); + for workflow in &self.queued_workflows { + out.push(vec![" • ".dim(), workflow.clone().magenta()].into()); + } + out.push("".into()); + } + + if show_workflows { + out.push("Background terminals".bold().into()); + out.push("".into()); + } + let prefix = " • "; let prefix_width = UnicodeWidthStr::width(prefix); let truncation_suffix = " [...]"; @@ -779,6 +888,7 @@ impl HistoryCell for UnifiedExecProcessesCell { } } +#[cfg(test)] pub(crate) fn new_unified_exec_processes_output( processes: Vec, ) -> CompositeHistoryCell { @@ -787,6 +897,20 @@ pub(crate) fn new_unified_exec_processes_output( CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) } +pub(crate) fn new_background_tasks_output( + processes: Vec, + running_workflows: Vec, + queued_workflows: Vec, +) -> CompositeHistoryCell { + let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]); + let summary = UnifiedExecProcessesCell::new_with_workflows( + processes, + running_workflows, + queued_workflows, + ); + CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) +} + fn truncate_exec_snippet(full_cmd: &str) -> String { let mut snippet = match full_cmd.split_once('\n') { Some((first, _)) => format!("{first} ..."), @@ -927,19 +1051,26 @@ impl ApprovalDecisionActor { } } -pub fn new_guardian_denied_patch_request(files: Vec) -> Box { +pub fn new_guardian_denied_patch_request( + files: Vec, + display_preferences: DisplayPreferences, +) -> Box { let mut summary = vec![ "Request ".into(), "denied".bold(), " for codex to apply ".into(), ]; - if files.len() == 1 { + if display_preferences.show_patch_diffs() && files.len() == 1 { summary.push("a patch touching ".into()); summary.push(Span::from(files[0].clone()).dim()); } else { + let noun = if files.len() == 1 { "file" } else { "files" }; summary.push("a patch touching ".into()); summary.push(Span::from(files.len().to_string()).dim()); - summary.push(" files".into()); + summary.push(format!(" {noun}").into()); + if !display_preferences.show_patch_diffs() { + summary.push(" (diff hidden)".dim()); + } } Box::new(PrefixedWrappedHistoryCell::new( @@ -980,10 +1111,26 @@ pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { pub(crate) struct PatchHistoryCell { changes: HashMap, cwd: PathBuf, + display_preferences: DisplayPreferences, } impl HistoryCell for PatchHistoryCell { fn display_lines(&self, width: u16) -> Vec> { + if !self.display_preferences.show_patch_diffs() { + let mut files = self + .changes + .keys() + .map(|path| display_path_for(path, &self.cwd)) + .collect::>(); + files.sort_unstable(); + let summary = format!("Edited files: {}", files.join(", ")); + return PrefixedWrappedHistoryCell::new( + Line::from(Span::from(summary).dim()), + "• ".dim(), + " ".dim(), + ) + .display_lines(width); + } create_diff_summary(&self.changes, &self.cwd, width as usize) } } @@ -991,9 +1138,13 @@ impl HistoryCell for PatchHistoryCell { #[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, + display_preferences: DisplayPreferences, } impl HistoryCell for CompletedMcpToolCallWithImageOutput { fn display_lines(&self, _width: u16) -> Vec> { + if !self.display_preferences.show_tool_results() { + return Vec::new(); + } vec!["tool result (image output)".into()] } } @@ -1054,7 +1205,7 @@ fn with_border_internal( let span_count = line.spans.len(); let mut spans: Vec> = Vec::with_capacity(span_count + 4); spans.push(Span::from("│ ").dim()); - spans.extend(line.into_iter()); + spans.extend(line); if used_width < content_width { spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); } @@ -1410,6 +1561,7 @@ pub(crate) struct McpToolCallCell { duration: Option, result: Option>, animations_enabled: bool, + display_preferences: DisplayPreferences, } impl McpToolCallCell { @@ -1417,6 +1569,7 @@ impl McpToolCallCell { call_id: String, invocation: McpInvocation, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> Self { Self { call_id, @@ -1425,6 +1578,7 @@ impl McpToolCallCell { duration: None, result: None, animations_enabled, + display_preferences, } } @@ -1437,8 +1591,11 @@ impl McpToolCallCell { duration: Duration, result: Result, ) -> Option> { - let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) - .map(|cell| Box::new(cell) as Box); + let image_cell = try_new_completed_mcp_tool_call_with_image_output( + &result, + self.display_preferences.clone(), + ) + .map(|cell| Box::new(cell) as Box); self.duration = Some(duration); self.result = Some(result); image_cell @@ -1530,7 +1687,9 @@ impl HistoryCell for McpToolCallCell { // Reserve four columns for the tree prefix (" └ "/" ") and ensure the wrapper still has at least one cell to work with. let detail_wrap_width = (width as usize).saturating_sub(4).max(1); - if let Some(result) = &self.result { + if self.display_preferences.show_tool_results() + && let Some(result) = &self.result + { match result { Ok(codex_protocol::mcp::CallToolResult { content, .. }) => { if !content.is_empty() { @@ -1591,8 +1750,9 @@ pub(crate) fn new_active_mcp_tool_call( call_id: String, invocation: McpInvocation, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> McpToolCallCell { - McpToolCallCell::new(call_id, invocation, animations_enabled) + McpToolCallCell::new(call_id, invocation, animations_enabled, display_preferences) } fn web_search_header(completed: bool) -> &'static str { @@ -1611,6 +1771,7 @@ pub(crate) struct WebSearchCell { start_time: Instant, completed: bool, animations_enabled: bool, + display_preferences: DisplayPreferences, } impl WebSearchCell { @@ -1619,6 +1780,7 @@ impl WebSearchCell { query: String, action: Option, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> Self { Self { call_id, @@ -1627,6 +1789,7 @@ impl WebSearchCell { start_time: Instant::now(), completed: false, animations_enabled, + display_preferences, } } @@ -1646,6 +1809,9 @@ impl WebSearchCell { impl HistoryCell for WebSearchCell { fn display_lines(&self, width: u16) -> Vec> { + if !self.display_preferences.show_tool_results() { + return Vec::new(); + } let bullet = if self.completed { "•".dim() } else { @@ -1666,20 +1832,29 @@ pub(crate) fn new_active_web_search_call( call_id: String, query: String, animations_enabled: bool, + display_preferences: DisplayPreferences, ) -> WebSearchCell { - WebSearchCell::new(call_id, query, /*action*/ None, animations_enabled) + WebSearchCell::new( + call_id, + query, + /*action*/ None, + animations_enabled, + display_preferences, + ) } pub(crate) fn new_web_search_call( call_id: String, query: String, action: WebSearchAction, + display_preferences: DisplayPreferences, ) -> WebSearchCell { let mut cell = WebSearchCell::new( call_id, query, Some(action), /*animations_enabled*/ false, + display_preferences, ); cell.complete(); cell @@ -1699,6 +1874,7 @@ pub(crate) fn new_web_search_call( /// even when the first block is not a valid image. fn try_new_completed_mcp_tool_call_with_image_output( result: &Result, + display_preferences: DisplayPreferences, ) -> Option { let image = result .as_ref() @@ -1707,7 +1883,10 @@ fn try_new_completed_mcp_tool_call_with_image_output( .iter() .find_map(decode_mcp_image)?; - Some(CompletedMcpToolCallWithImageOutput { _image: image }) + Some(CompletedMcpToolCallWithImageOutput { + _image: image, + display_preferences, + }) } /// Decodes an MCP `ImageContent` block into an in-memory image. @@ -1827,7 +2006,7 @@ pub(crate) fn new_mcp_tools_output( let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone()))); let effective_servers = mcp_manager.effective_servers(config, /*auth*/ None); let mut servers: Vec<_> = effective_servers.iter().collect(); - servers.sort_by(|(a, _), (b, _)| a.cmp(b)); + servers.sort_by_key(|(a, _)| *a); for (server, cfg) in servers { let prefix = qualified_mcp_tool_name_prefix(server); @@ -1893,7 +2072,7 @@ pub(crate) fn new_mcp_tools_output( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(a, _)| *a); let display = pairs .into_iter() .map(|(name, _)| format!("{name}=*****")) @@ -1905,7 +2084,7 @@ pub(crate) fn new_mcp_tools_output( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(a, _)| *a); let display = pairs .into_iter() .map(|(name, var)| format!("{name}={var}")) @@ -2060,7 +2239,7 @@ pub(crate) fn new_mcp_tools_output_from_statuses( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(a, _)| *a); let display = pairs .into_iter() .map(|(name, _)| format!("{name}=*****")) @@ -2072,7 +2251,7 @@ pub(crate) fn new_mcp_tools_output_from_statuses( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(a, _)| *a); let display = pairs .into_iter() .map(|(name, var)| format!("{name}={var}")) @@ -2492,10 +2671,12 @@ impl HistoryCell for PlanUpdateCell { pub(crate) fn new_patch_event( changes: HashMap, cwd: &Path, + display_preferences: DisplayPreferences, ) -> PatchHistoryCell { PatchHistoryCell { changes, cwd: cwd.to_path_buf(), + display_preferences, } } @@ -2592,23 +2773,37 @@ pub(crate) fn new_reasoning_summary_block( )) } +pub(crate) fn new_reasoning_raw_block( + full_raw_reasoning_buffer: String, + cwd: &Path, + display_preferences: DisplayPreferences, +) -> Box { + Box::new(ReasoningRawContentCell { + content: full_raw_reasoning_buffer.trim().to_string(), + cwd: cwd.to_path_buf(), + display_preferences, + }) +} + #[derive(Debug)] /// A visual divider between turns, optionally showing how long the assistant "worked for". /// -/// This separator is only emitted for turns that performed concrete work (e.g., running commands, -/// applying patches, making MCP tool calls), so purely conversational turns do not show an empty -/// divider. +/// Completed turns can use this to show a local timestamp, while in-turn work handoffs can omit +/// the timestamp and only show work-related labels. pub struct FinalMessageSeparator { + timestamp_label: Option, elapsed_seconds: Option, runtime_metrics: Option, } impl FinalMessageSeparator { /// Creates a separator; `elapsed_seconds` typically comes from the status indicator timer. pub(crate) fn new( + timestamp_label: Option, elapsed_seconds: Option, runtime_metrics: Option, ) -> Self { Self { + timestamp_label, elapsed_seconds, runtime_metrics, } @@ -2617,6 +2812,9 @@ impl FinalMessageSeparator { impl HistoryCell for FinalMessageSeparator { fn display_lines(&self, width: u16) -> Vec> { let mut label_parts = Vec::new(); + if let Some(timestamp_label) = self.timestamp_label.as_ref() { + label_parts.push(timestamp_label.clone()); + } if let Some(elapsed_seconds) = self .elapsed_seconds .filter(|seconds| *seconds > 60) @@ -2977,8 +3175,11 @@ mod tests { #[test] fn unified_exec_interaction_cell_renders_input() { - let cell = - new_unified_exec_interaction(Some("echo hello".to_string()), "ls\npwd".to_string()); + let cell = new_unified_exec_interaction( + Some("echo hello".to_string()), + "ls\npwd".to_string(), + DisplayPreferences::default(), + ); let lines = render_transcript(&cell); assert_eq!( lines, @@ -2992,11 +3193,32 @@ mod tests { #[test] fn unified_exec_interaction_cell_renders_wait() { - let cell = new_unified_exec_interaction(/*command_display*/ None, String::new()); + let cell = new_unified_exec_interaction( + /*command_display*/ None, + String::new(), + DisplayPreferences::default(), + ); let lines = render_transcript(&cell); assert_eq!(lines, vec!["• Waited for background terminal"]); } + #[test] + fn unified_exec_interaction_cell_hides_when_tool_activity_is_disabled() { + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + /*enabled*/ false, + ); + let cell = new_unified_exec_interaction( + Some("echo hello".to_string()), + "ls\npwd".to_string(), + display_preferences, + ); + + assert!(cell.display_lines(/*width*/ 80).is_empty()); + assert!(cell.transcript_lines(/*width*/ 80).is_empty()); + } + #[test] fn final_message_separator_hides_short_worked_label_and_includes_runtime_metrics() { let summary = RuntimeMetricsSummary { @@ -3029,7 +3251,7 @@ mod tests { turn_ttft_ms: 0, turn_ttfm_ms: 0, }; - let cell = FinalMessageSeparator::new(Some(12), Some(summary)); + let cell = FinalMessageSeparator::new(None, Some(12), Some(summary)); let rendered = render_lines(&cell.display_lines(/*width*/ 600)); assert_eq!(rendered.len(), 1); @@ -3047,13 +3269,26 @@ mod tests { #[test] fn final_message_separator_includes_worked_label_after_one_minute() { - let cell = FinalMessageSeparator::new(Some(61), /*runtime_metrics*/ None); + let cell = FinalMessageSeparator::new(None, Some(61), /*runtime_metrics*/ None); let rendered = render_lines(&cell.display_lines(/*width*/ 200)); assert_eq!(rendered.len(), 1); assert!(rendered[0].contains("Worked for")); } + #[test] + fn final_message_separator_includes_timestamp_label() { + let cell = FinalMessageSeparator::new( + Some("2026-04-08 03:04:05 +08:00".to_string()), + /*elapsed_seconds*/ None, + /*runtime_metrics*/ None, + ); + let rendered = render_lines(&cell.display_lines(/*width*/ 80)); + + assert_eq!(rendered.len(), 1); + assert!(rendered[0].contains("2026-04-08 03:04:05 +08:00")); + } + #[test] fn ps_output_empty_snapshot() { let cell = new_unified_exec_processes_output(Vec::new()); @@ -3191,6 +3426,21 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn ps_output_shows_running_and_queued_workflows() { + let cell = new_background_tasks_output( + Vec::new(), + vec!["build-index · after_turn".to_string()], + vec!["sync-docs · manual".to_string()], + ); + let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n"); + + assert!(rendered.contains("Running workflows")); + assert!(rendered.contains("Queued workflows")); + assert!(rendered.contains("build-index · after_turn")); + assert!(rendered.contains("sync-docs · manual")); + } + #[test] fn error_event_oversized_input_snapshot() { let cell = new_error_event( @@ -3397,7 +3647,11 @@ mod tests { fn unified_exec_interaction_cell_does_not_split_url_like_stdin_token() { let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; - let cell = UnifiedExecInteractionCell::new(Some("true".to_string()), url_like.to_string()); + let cell = UnifiedExecInteractionCell::new( + Some("true".to_string()), + url_like.to_string(), + DisplayPreferences::default(), + ); let rendered = render_lines(&cell.display_lines(/*width*/ 24)); assert_eq!( @@ -3453,6 +3707,7 @@ mod tests { let cell: Box = Box::new(UnifiedExecInteractionCell::new( Some("true".to_string()), url_like.to_string(), + DisplayPreferences::default(), )); let width: u16 = 24; @@ -3494,6 +3749,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)).join("\n"); @@ -3511,6 +3767,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)); @@ -3533,6 +3790,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)); @@ -3550,6 +3808,7 @@ mod tests { query: Some(query), queries: None, }, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.transcript_lines(/*width*/ 64)).join("\n"); @@ -3571,6 +3830,7 @@ mod tests { "call-1".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n"); @@ -3607,6 +3867,7 @@ mod tests { "call-2".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(1420), Ok(result)) @@ -3642,6 +3903,7 @@ mod tests { "call-image".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let extra_cell = cell .complete(Duration::from_millis(25), Ok(result)) @@ -3673,6 +3935,7 @@ mod tests { "call-image-data-url".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let extra_cell = cell .complete(Duration::from_millis(25), Ok(result)) @@ -3682,6 +3945,41 @@ mod tests { assert_eq!(rendered, vec!["tool result (image output)"]); } + #[test] + fn completed_mcp_tool_call_hides_image_result_placeholder_when_disabled() { + let invocation = McpInvocation { + server: "image".into(), + tool: "generate".into(), + arguments: Some(json!({ + "prompt": "tiny image", + })), + }; + + let result = CallToolResult { + content: vec![image_block(SMALL_PNG_BASE64)], + is_error: None, + structured_content: None, + meta: None, + }; + + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + /*enabled*/ false, + ); + let mut cell = new_active_mcp_tool_call( + "call-image-hidden".into(), + invocation, + /*animations_enabled*/ true, + display_preferences, + ); + let extra_cell = cell + .complete(Duration::from_millis(25), Ok(result)) + .expect("expected image cell"); + + assert!(extra_cell.display_lines(/*width*/ 80).is_empty()); + } + #[test] fn completed_mcp_tool_call_skips_invalid_image_blocks() { let invocation = McpInvocation { @@ -3703,6 +4001,7 @@ mod tests { "call-image-2".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); let extra_cell = cell .complete(Duration::from_millis(25), Ok(result)) @@ -3727,6 +4026,7 @@ mod tests { "call-3".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_secs(2), Err("network timeout".into())) @@ -3738,6 +4038,47 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn completed_mcp_tool_call_hides_text_results_when_disabled() { + let invocation = McpInvocation { + server: "search".into(), + tool: "find_docs".into(), + arguments: Some(json!({ + "query": "ratatui styling", + "limit": 3, + })), + }; + + let result = CallToolResult { + content: vec![text_block("Found styling guidance in styles.md")], + is_error: None, + structured_content: None, + meta: None, + }; + + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + /*enabled*/ false, + ); + let mut cell = new_active_mcp_tool_call( + "call-hidden-text".into(), + invocation, + /*animations_enabled*/ true, + display_preferences, + ); + assert!( + cell.complete(Duration::from_millis(200), Ok(result)) + .is_none() + ); + + let rendered = render_lines(&cell.display_lines(/*width*/ 80)); + assert_eq!( + rendered, + vec!["• Called search.find_docs({\"query\":\"ratatui styling\",\"limit\":3})"] + ); + } + #[test] fn completed_mcp_tool_call_multiple_outputs_snapshot() { let invocation = McpInvocation { @@ -3770,6 +4111,7 @@ mod tests { "call-4".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(640), Ok(result)) @@ -3781,6 +4123,56 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn patch_event_hides_diff_summary_when_disabled_and_lists_relative_paths() { + let mut changes = HashMap::new(); + changes.insert( + test_cwd().join("src").join("lib.rs"), + FileChange::Add { + content: "pub fn demo() {}\n".to_string(), + }, + ); + changes.insert( + test_cwd().join("README.md"), + FileChange::Add { + content: "# demo\n".to_string(), + }, + ); + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::PatchDiffs, + /*enabled*/ false, + ); + + let cell = new_patch_event(changes, &test_cwd(), display_preferences); + let rendered = render_lines(&cell.display_lines(/*width*/ 80)); + assert_eq!( + rendered, + vec![format!( + "• Edited files: {}, {}", + PathBuf::from("README.md").display(), + PathBuf::from("src").join("lib.rs").display() + )] + ); + } + + #[test] + fn guardian_denied_patch_request_hides_diff_details_when_disabled() { + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::PatchDiffs, + /*enabled*/ false, + ); + + let cell = + new_guardian_denied_patch_request(vec!["src/lib.rs".to_string()], display_preferences); + let rendered = render_transcript(cell.as_ref()); + assert_eq!( + rendered, + vec!["✗ Request denied for codex to apply a patch touching 1 file (diff hidden)"] + ); + } + #[test] fn completed_mcp_tool_call_wrapped_outputs_snapshot() { let invocation = McpInvocation { @@ -3805,6 +4197,7 @@ mod tests { "call-5".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(1280), Ok(result)) @@ -3841,6 +4234,7 @@ mod tests { "call-6".into(), invocation, /*animations_enabled*/ true, + DisplayPreferences::default(), ); assert!( cell.complete(Duration::from_millis(320), Ok(result)) @@ -4051,6 +4445,34 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn exec_cell_hides_when_tool_activity_is_disabled() { + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + /*enabled*/ false, + ); + let call_id = "c1".to_string(); + let mut cell = ExecCell::new( + ExecCall { + call_id: call_id.clone(), + command: vec!["echo".into(), "ok".into()], + parsed: Vec::new(), + output: None, + source: ExecCommandSource::Agent, + start_time: Some(Instant::now()), + duration: None, + interaction_input: None, + }, + /*animations_enabled*/ true, + ) + .with_display_preferences(display_preferences); + cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1)); + + assert!(cell.display_lines(/*width*/ 80).is_empty()); + assert!(cell.transcript_lines(/*width*/ 80).is_empty()); + } + #[test] fn multiline_command_wraps_with_extra_indent_on_subsequent_lines() { // Create a completed exec cell with a multiline command @@ -4663,6 +5085,38 @@ mod tests { assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]); } + #[test] + fn reasoning_raw_block_is_hidden_when_display_preference_is_disabled() { + let cell = new_reasoning_raw_block( + "secret chain of thought".to_string(), + &test_cwd(), + DisplayPreferences::default(), + ); + + assert!(cell.display_lines(/*width*/ 80).is_empty()); + assert!(cell.transcript_lines(/*width*/ 80).is_empty()); + } + + #[test] + fn reasoning_raw_block_is_visible_when_display_preference_is_enabled() { + let display_preferences = DisplayPreferences::default(); + display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::RawThinking, + /*enabled*/ true, + ); + let cell = new_reasoning_raw_block( + "secret chain of thought".to_string(), + &test_cwd(), + display_preferences, + ); + + let rendered = render_transcript(cell.as_ref()); + assert_eq!( + rendered, + vec!["• Raw Thinking", " secret chain of thought"] + ); + } + #[test] fn deprecation_notice_renders_summary_with_details() { let cell = new_deprecation_notice( diff --git a/codex-rs/tui/src/insight/aggregator.rs b/codex-rs/tui/src/insight/aggregator.rs new file mode 100644 index 000000000..275ee458a --- /dev/null +++ b/codex-rs/tui/src/insight/aggregator.rs @@ -0,0 +1,370 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::Duration; + +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::ThreadId; + +use super::types::AggregateMetrics; +use super::types::CollectedThread; +use super::types::CollectionResult; +use super::types::InsightOverview; +use super::types::NarrativeMode; +use super::types::RootSessionSummary; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AggregatedInsight { + pub(crate) overview: InsightOverview, + pub(crate) roots: Vec, + pub(crate) common_patterns: Vec, + pub(crate) efficiency_suggestions: Vec, + pub(crate) narrative_mode: NarrativeMode, +} + +pub(crate) fn aggregate(collection: CollectionResult) -> AggregatedInsight { + let roots = build_root_summaries(collection.threads); + let mut metrics = AggregateMetrics::default(); + let mut earliest_event_at = None; + let mut latest_event_at = None; + let archived_threads = roots + .iter() + .flat_map(|root| root.threads.iter()) + .filter(|thread| thread.archived) + .count(); + + for root in &roots { + metrics.add_assign(&root.metrics); + earliest_event_at = min_datetime(earliest_event_at, root.earliest_event_at); + latest_event_at = max_datetime(latest_event_at, root.latest_event_at); + } + + let history_span = match (earliest_event_at, latest_event_at) { + (Some(first), Some(last)) if last > first => (last - first).to_std().unwrap_or_default(), + _ => Duration::ZERO, + }; + let overview = InsightOverview { + total_root_sessions: roots.len(), + total_threads: roots.iter().map(|root| root.threads.len()).sum(), + archived_threads, + scanned_files: collection.scanned_files, + skipped_files: collection.skipped_files, + metrics: metrics.clone(), + earliest_event_at, + latest_event_at, + history_span, + }; + + AggregatedInsight { + common_patterns: build_common_patterns(&overview, &roots), + efficiency_suggestions: build_efficiency_suggestions(&overview, &roots), + overview, + roots, + narrative_mode: NarrativeMode::LocalHeuristics, + } +} + +fn build_root_summaries(threads: Vec) -> Vec { + let thread_by_id: HashMap = threads + .into_iter() + .map(|thread| (thread.thread_id, thread)) + .collect(); + + let mut grouped = HashMap::>::new(); + for thread in thread_by_id.values() { + let root_thread_id = resolve_root_thread_id(thread, &thread_by_id); + grouped + .entry(root_thread_id) + .or_default() + .push(thread.clone()); + } + + let mut roots: Vec = grouped + .into_iter() + .filter_map(|(root_thread_id, mut threads)| { + threads.sort_by_key(|thread| (thread.depth.unwrap_or(0), thread.created_at)); + let root = threads + .iter() + .find(|thread| thread.thread_id == root_thread_id) + .cloned() + .or_else(|| threads.first().cloned())?; + + let mut metrics = AggregateMetrics::default(); + let mut earliest_event_at = None; + let mut latest_event_at = None; + for thread in &threads { + metrics.add_assign(&thread.metrics); + earliest_event_at = min_datetime(earliest_event_at, thread.first_event_at); + latest_event_at = max_datetime(latest_event_at, thread.last_event_at); + } + let wall_clock_span = match (earliest_event_at, latest_event_at) { + (Some(first), Some(last)) if last > first => { + (last - first).to_std().unwrap_or_default() + } + _ => Duration::ZERO, + }; + + Some(RootSessionSummary { + root_thread_id, + title: root.title, + cwd: if root.cwd.as_os_str().is_empty() { + PathBuf::new() + } else { + root.cwd + }, + rollout_path: root.rollout_path, + archived: root.archived, + earliest_event_at, + latest_event_at, + wall_clock_span, + metrics, + threads, + }) + }) + .collect(); + + roots.sort_by(|left, right| { + right + .metrics + .total_tokens + .cmp(&left.metrics.total_tokens) + .then_with(|| right.latest_event_at.cmp(&left.latest_event_at)) + }); + roots +} + +fn resolve_root_thread_id( + thread: &CollectedThread, + thread_by_id: &HashMap, +) -> ThreadId { + let mut current = thread; + let mut seen = HashSet::from([current.thread_id]); + while let Some(parent_thread_id) = current.parent_thread_id { + let Some(parent) = thread_by_id.get(&parent_thread_id) else { + return parent_thread_id; + }; + if !seen.insert(parent.thread_id) { + return current.thread_id; + } + current = parent; + } + current.thread_id +} + +fn build_common_patterns(overview: &InsightOverview, roots: &[RootSessionSummary]) -> Vec { + let mut patterns = Vec::new(); + + if let Some(root) = roots.first() { + patterns.push(format!( + "Token hotspot: `{}` is the heaviest root session with {} tokens across {} thread(s).", + root.title, + format_number(root.metrics.total_tokens), + root.threads.len(), + )); + } + + let subagent_roots = roots.iter().filter(|root| root.threads.len() > 1).count(); + patterns.push(format!( + "Subagent usage: {} / {} root session(s) include spawned child threads.", + subagent_roots, + roots.len(), + )); + + patterns.push(format!( + "Timing split: exact tool runtime is {}, estimated user wait is {}, and residual model/UI time is {}.", + format_duration(overview.metrics.exact_tool_runtime()), + format_duration(overview.metrics.estimated_user_wait), + format_duration(overview.metrics.residual_runtime_estimate), + )); + patterns.push(format!( + "Failure surface: {:.1}% failure rate across counted operations (exec + MCP + dynamic tools + patch + API errors).", + overview.metrics.failure_rate() * 100.0, + )); + patterns +} + +fn build_efficiency_suggestions( + overview: &InsightOverview, + roots: &[RootSessionSummary], +) -> Vec { + let mut suggestions = Vec::new(); + + if overview.metrics.exec_commands.failures > 0 + && overview.metrics.exec_commands.failures >= overview.metrics.api_error_count + { + suggestions.push( + "Exec failures dominate the observed error budget; tighten command scope and prefer read-first probes like `rg`, `sed -n`, and targeted tests before wider shell actions." + .to_string(), + ); + } + + if overview.metrics.total_tokens > 500_000 { + suggestions.push( + "Token usage is concentrated in a few long-running sessions; trigger `/compact` earlier on exploratory threads to reduce context drag." + .to_string(), + ); + } + + if overview.metrics.estimated_user_wait > overview.metrics.exact_tool_runtime() { + suggestions.push( + "Estimated user-wait time exceeds exact tool runtime; batch follow-up prompts more aggressively when a task can be specified upfront." + .to_string(), + ); + } + + if roots.iter().all(|root| root.threads.len() == 1) && !roots.is_empty() { + suggestions.push( + "No multi-thread roots were detected in this sample; for parallelizable exploration or verification work, `/agent` or subagent workflows may reduce end-to-end elapsed time." + .to_string(), + ); + } + + if overview.metrics.patches.changed_files > overview.metrics.patches.count.saturating_mul(3) { + suggestions.push( + "Patch churn is spread across many files; grouping edits by module boundary should make review and rollback cheaper." + .to_string(), + ); + } + + if suggestions.is_empty() { + suggestions.push( + "No dominant inefficiency pattern stood out from local heuristics; use the per-root drill-down to inspect the highest-token and highest-failure sessions first." + .to_string(), + ); + } + + suggestions +} + +fn min_datetime( + left: Option>, + right: Option>, +) -> Option> { + match (left, right) { + (Some(left), Some(right)) => Some(left.min(right)), + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + } +} + +fn max_datetime( + left: Option>, + right: Option>, +) -> Option> { + match (left, right) { + (Some(left), Some(right)) => Some(left.max(right)), + (Some(left), None) => Some(left), + (None, Some(right)) => Some(right), + (None, None) => None, + } +} + +pub(crate) fn format_duration(duration: Duration) -> String { + let total_seconds = duration.as_secs(); + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} + +pub(crate) fn format_number(value: i64) -> String { + let negative = value < 0; + let digits = value.abs().to_string(); + let mut formatted = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + formatted.push(','); + } + formatted.push(digit); + } + let formatted: String = formatted.chars().rev().collect(); + if negative { + format!("-{formatted}") + } else { + formatted + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::time::Duration; + + use chrono::TimeZone; + use chrono::Utc; + use pretty_assertions::assert_eq; + + use super::aggregate; + use crate::insight::types::AggregateMetrics; + use crate::insight::types::CollectedThread; + use crate::insight::types::CollectionResult; + use codex_protocol::ThreadId; + + fn thread_id(value: &str) -> ThreadId { + ThreadId::from_string(value).expect("valid thread id") + } + + fn collected_thread( + thread_id: ThreadId, + parent_thread_id: Option, + title: &str, + tokens: i64, + ) -> CollectedThread { + let created_at = Utc.with_ymd_and_hms(2026, 4, 8, 12, 0, 0).unwrap(); + let mut metrics = AggregateMetrics { + total_tokens: tokens, + cumulative_thread_span: Duration::from_secs(20), + ..AggregateMetrics::default() + }; + metrics + .exec_commands + .add_sample(false, Duration::from_secs(3)); + CollectedThread { + thread_id, + parent_thread_id, + depth: parent_thread_id.map(|_| 1), + title: title.to_string(), + cwd: PathBuf::from("/repo"), + rollout_path: PathBuf::from(format!("/tmp/{thread_id}.jsonl")), + archived: false, + source_label: "cli".to_string(), + agent_nickname: None, + agent_role: None, + agent_path: None, + created_at, + updated_at: created_at, + first_event_at: Some(created_at), + last_event_at: Some(created_at + chrono::Duration::seconds(20)), + metrics, + } + } + + #[test] + fn aggregate_rolls_children_into_root_session() { + let root = thread_id("00000000-0000-0000-0000-000000000001"); + let child = thread_id("00000000-0000-0000-0000-000000000002"); + let result = aggregate(CollectionResult { + threads: vec![ + collected_thread(root, None, "root", 120), + collected_thread(child, Some(root), "child", 30), + ], + scanned_files: 2, + skipped_files: 0, + }); + + assert_eq!(result.roots.len(), 1); + assert_eq!(result.roots[0].root_thread_id, root); + assert_eq!(result.roots[0].threads.len(), 2); + assert_eq!(result.roots[0].metrics.total_tokens, 150); + assert_eq!(result.overview.total_root_sessions, 1); + assert_eq!(result.overview.total_threads, 2); + } +} diff --git a/codex-rs/tui/src/insight/collector.rs b/codex-rs/tui/src/insight/collector.rs new file mode 100644 index 000000000..b454fb92a --- /dev/null +++ b/codex-rs/tui/src/insight/collector.rs @@ -0,0 +1,321 @@ +use std::io::ErrorKind; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use chrono::DateTime; +use chrono::Utc; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::protocol::DynamicToolCallResponseEvent; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecCommandStatus; +use codex_protocol::protocol::PatchApplyStatus; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; +use codex_rollout::SESSIONS_SUBDIR; + +use super::types::AggregateMetrics; +use super::types::CollectedThread; +use super::types::CollectionResult; + +pub(crate) async fn collect_sessions(config: &Config) -> anyhow::Result { + let mut rollout_paths = Vec::new(); + collect_rollout_paths( + config.codex_home.join(SESSIONS_SUBDIR), + /*archived*/ false, + &mut rollout_paths, + ) + .await?; + collect_rollout_paths( + config.codex_home.join(ARCHIVED_SESSIONS_SUBDIR), + /*archived*/ true, + &mut rollout_paths, + ) + .await?; + + let scanned_files = rollout_paths.len(); + let mut skipped_files = 0usize; + let mut threads = Vec::new(); + for (path, archived) in rollout_paths { + match analyze_rollout(path.clone(), archived).await { + Ok(Some(thread)) => threads.push(thread), + Ok(None) => skipped_files = skipped_files.saturating_add(1), + Err(err) => { + tracing::warn!("failed to analyze rollout {}: {err}", path.display()); + skipped_files = skipped_files.saturating_add(1); + } + } + } + + Ok(CollectionResult { + threads, + scanned_files, + skipped_files, + }) +} + +async fn collect_rollout_paths( + root: PathBuf, + archived: bool, + paths: &mut Vec<(PathBuf, bool)>, +) -> anyhow::Result<()> { + let mut pending = vec![root]; + while let Some(dir) = pending.pop() { + let mut entries = match tokio::fs::read_dir(&dir).await { + Ok(entries) => entries, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => { + return Err(err).with_context(|| format!("failed to read {}", dir.display())); + } + }; + while let Some(entry) = entries.next_entry().await? { + let entry_type = entry.file_type().await?; + let path = entry.path(); + if entry_type.is_dir() { + pending.push(path); + } else if entry_type.is_file() + && path + .extension() + .is_some_and(|extension| extension == "jsonl") + { + paths.push((path, archived)); + } + } + } + Ok(()) +} + +async fn analyze_rollout(path: PathBuf, archived: bool) -> anyhow::Result> { + let text = tokio::fs::read_to_string(&path) + .await + .with_context(|| format!("failed to read {}", path.display()))?; + if text.trim().is_empty() { + return Ok(None); + } + + let file_updated_at = tokio::fs::metadata(&path) + .await + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(DateTime::::from); + + let mut session_meta: Option = None; + let mut metrics = AggregateMetrics::default(); + let mut first_event_at = None; + let mut last_event_at = None; + let mut last_turn_complete_at = None; + let mut title = String::new(); + + for raw_line in text.lines() { + if raw_line.trim().is_empty() { + continue; + } + + let rollout_line = match serde_json::from_str::(raw_line) { + Ok(rollout_line) => rollout_line, + Err(_) => { + metrics.parse_errors = metrics.parse_errors.saturating_add(1); + continue; + } + }; + + let timestamp = parse_timestamp(rollout_line.timestamp.as_str()); + if first_event_at.is_none() { + first_event_at = timestamp; + } + if timestamp.is_some() { + last_event_at = timestamp; + } + + match rollout_line.item { + RolloutItem::SessionMeta(meta_line) => { + if session_meta.is_none() { + session_meta = Some(meta_line.meta); + } + } + RolloutItem::EventMsg(event) => match event { + EventMsg::UserMessage(user) => { + metrics.user_message_count = metrics.user_message_count.saturating_add(1); + if title.is_empty() { + let stripped = strip_user_message_prefix(user.message.as_str()); + if !stripped.is_empty() { + title = stripped.to_string(); + } + } + if let (Some(turn_complete_at), Some(user_message_at)) = + (last_turn_complete_at.take(), timestamp) + && user_message_at > turn_complete_at + { + metrics.estimated_user_wait = metrics + .estimated_user_wait + .saturating_add((user_message_at - turn_complete_at).to_std()?); + } + } + EventMsg::TurnComplete(_) => { + metrics.completed_turn_count = metrics.completed_turn_count.saturating_add(1); + last_turn_complete_at = timestamp; + } + EventMsg::TokenCount(token_count) => { + if let Some(info) = token_count.info { + metrics.total_tokens = info.total_token_usage.total_tokens.max(0); + metrics.input_tokens = info.total_token_usage.input_tokens.max(0); + metrics.output_tokens = info.total_token_usage.output_tokens.max(0); + metrics.reasoning_output_tokens = + info.total_token_usage.reasoning_output_tokens.max(0); + } + } + EventMsg::ExecCommandEnd(exec) => { + let failed = + matches!(exec.status, ExecCommandStatus::Failed) || exec.exit_code != 0; + metrics.exec_commands.add_sample(failed, exec.duration); + } + EventMsg::McpToolCallEnd(tool_call) => { + metrics + .mcp_tool_calls + .add_sample(!tool_call.is_success(), tool_call.duration); + } + EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { + success, + duration, + .. + }) => { + metrics.dynamic_tool_calls.add_sample(!success, duration); + } + EventMsg::WebSearchEnd(_) => { + metrics.web_search_count = metrics.web_search_count.saturating_add(1); + } + EventMsg::ImageGenerationEnd(_) => { + metrics.image_generation_count = + metrics.image_generation_count.saturating_add(1); + } + EventMsg::ViewImageToolCall(_) => { + metrics.view_image_count = metrics.view_image_count.saturating_add(1); + } + EventMsg::PatchApplyEnd(patch) => { + let failed = matches!(patch.status, PatchApplyStatus::Failed) || !patch.success; + metrics.patches.add_sample(failed, patch.changes.len()); + } + EventMsg::Error(_) | EventMsg::StreamError(_) => { + metrics.api_error_count = metrics.api_error_count.saturating_add(1); + } + _ => {} + }, + RolloutItem::ResponseItem(_) + | RolloutItem::Compacted(_) + | RolloutItem::TurnContext(_) => {} + } + } + + let Some(session_meta) = session_meta else { + return Ok(None); + }; + + let created_at = parse_timestamp(session_meta.timestamp.as_str()) + .or(first_event_at) + .or(file_updated_at) + .unwrap_or_else(Utc::now); + let updated_at = last_event_at.or(file_updated_at).unwrap_or(created_at); + let wall_clock_span = match (first_event_at, last_event_at) { + (Some(first), Some(last)) if last > first => (last - first).to_std()?, + _ => Duration::ZERO, + }; + metrics.cumulative_thread_span = wall_clock_span; + metrics.residual_runtime_estimate = wall_clock_span + .saturating_sub(metrics.exact_tool_runtime()) + .saturating_sub(metrics.estimated_user_wait); + + let (parent_thread_id, depth, source_label, agent_nickname, agent_role, agent_path) = + source_fields(&session_meta.source); + + Ok(Some(CollectedThread { + thread_id: session_meta.id, + parent_thread_id, + depth, + title: if title.is_empty() { + fallback_title(&path) + } else { + title + }, + cwd: session_meta.cwd, + rollout_path: path, + archived, + source_label, + agent_nickname, + agent_role, + agent_path, + created_at, + updated_at, + first_event_at, + last_event_at, + metrics, + })) +} + +fn source_fields( + source: &SessionSource, +) -> ( + Option, + Option, + String, + Option, + Option, + Option, +) { + match source { + SessionSource::Cli => (None, None, "cli".to_string(), None, None, None), + SessionSource::VSCode => (None, None, "vscode".to_string(), None, None, None), + SessionSource::Exec => (None, None, "exec".to_string(), None, None, None), + SessionSource::Mcp => (None, None, "mcp".to_string(), None, None, None), + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname, + agent_role, + }) => ( + Some(*parent_thread_id), + Some(*depth), + format!("subagent/thread_spawn:d{depth}"), + agent_nickname.clone(), + agent_role.clone(), + agent_path.clone().map(|path| path.to_string()), + ), + SessionSource::SubAgent(other) => ( + None, + None, + format!("subagent/{other}"), + source.get_nickname(), + source.get_agent_role(), + source.get_agent_path().map(|path| path.to_string()), + ), + SessionSource::Custom(other) => (None, None, other.clone(), None, None, None), + SessionSource::Unknown => (None, None, "unknown".to_string(), None, None, None), + } +} + +fn parse_timestamp(timestamp: &str) -> Option> { + DateTime::parse_from_rfc3339(timestamp) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +fn strip_user_message_prefix(text: &str) -> &str { + match text.find(USER_MESSAGE_BEGIN) { + Some(index) => text[index + USER_MESSAGE_BEGIN.len()..].trim(), + None => text.trim(), + } +} + +fn fallback_title(path: &Path) -> String { + path.file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("untitled") + .to_string() +} diff --git a/codex-rs/tui/src/insight/mod.rs b/codex-rs/tui/src/insight/mod.rs new file mode 100644 index 000000000..4899e72ec --- /dev/null +++ b/codex-rs/tui/src/insight/mod.rs @@ -0,0 +1,214 @@ +mod aggregator; +mod collector; +mod report; +mod types; + +use std::path::Path; + +use chrono::Utc; +use codex_core::config::Config; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; + +use self::aggregator::aggregate; +use self::collector::collect_sessions; +use self::report::write_report_html; +use self::types::InsightReportData; + +pub(crate) fn spawn_report_generation(config: Config, app_event_tx: AppEventSender) { + tokio::spawn(async move { + match generate_report(config).await { + Ok(output) => { + let message = format!("Insight report generated: {}", output.report_path.display()); + let hint = Some( + "Patterns and suggestions used local heuristics fallback in this build." + .to_string(), + ); + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, hint), + ))); + } + Err(err) => { + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!( + "Failed to generate /insight report: {err}" + )), + ))); + } + } + }); +} + +pub(crate) fn start_message() -> String { + "Generating /insight report from local sessions...".to_string() +} + +async fn generate_report(config: Config) -> anyhow::Result { + let collection = collect_sessions(&config).await?; + let aggregated = aggregate(collection); + let generated_at = Utc::now(); + let report_path = report_path(config.codex_home.as_path(), generated_at); + let data = InsightReportData { + generated_at, + codex_home: config.codex_home.clone(), + report_path, + overview: aggregated.overview, + roots: aggregated.roots, + common_patterns: aggregated.common_patterns, + efficiency_suggestions: aggregated.efficiency_suggestions, + narrative_mode: aggregated.narrative_mode, + }; + write_report_html(&data).await?; + Ok(data) +} + +fn report_path(codex_home: &Path, generated_at: chrono::DateTime) -> std::path::PathBuf { + codex_home.join("reports").join(format!( + "insight-{}.html", + generated_at.format("%Y%m%d-%H%M%S") + )) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + use std::time::Duration; + + use chrono::Utc; + use codex_core::config::ConfigBuilder; + use codex_protocol::ThreadId; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::ExecCommandEndEvent; + use codex_protocol::protocol::ExecCommandSource; + use codex_protocol::protocol::ExecCommandStatus; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMeta; + use codex_protocol::protocol::SessionMetaLine; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::TokenCountEvent; + use codex_protocol::protocol::TokenUsage; + use codex_protocol::protocol::TokenUsageInfo; + use codex_protocol::protocol::TurnCompleteEvent; + use codex_protocol::protocol::UserMessageEvent; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use super::collector::collect_sessions; + use super::generate_report; + + fn write_rollout(codex_home: &PathBuf, relative_dir: &str, thread_id: ThreadId) { + let dir = codex_home.join(relative_dir); + fs::create_dir_all(&dir).expect("create rollout dir"); + let path = + dir.join("rollout-2026-04-08T12-00-00-00000000-0000-0000-0000-000000000001.jsonl"); + let session_meta = RolloutLine { + timestamp: "2026-04-08T12:00:00Z".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + forked_from_id: None, + timestamp: "2026-04-08T12:00:00Z".to_string(), + cwd: PathBuf::from("/repo"), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + agent_nickname: None, + agent_role: None, + agent_path: None, + model_provider: Some("openai".to_string()), + base_instructions: None, + dynamic_tools: None, + memory_mode: None, + }, + git: None, + }), + }; + let user = RolloutLine { + timestamp: "2026-04-08T12:00:05Z".to_string(), + item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "## My request for Codex: inspect session data".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + }; + let token = RolloutLine { + timestamp: "2026-04-08T12:00:15Z".to_string(), + item: RolloutItem::EventMsg(EventMsg::TokenCount(TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: TokenUsage { + input_tokens: 100, + cached_input_tokens: 0, + output_tokens: 20, + reasoning_output_tokens: 10, + total_tokens: 120, + }, + last_token_usage: TokenUsage::default(), + model_context_window: Some(128000), + }), + rate_limits: None, + })), + }; + let exec = RolloutLine { + timestamp: "2026-04-08T12:00:18Z".to_string(), + item: RolloutItem::EventMsg(EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-1".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command: vec!["rg".to_string(), "todo".to_string()], + cwd: PathBuf::from("/repo"), + parsed_cmd: Vec::new(), + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: Duration::from_secs(2), + formatted_output: String::new(), + status: ExecCommandStatus::Completed, + })), + }; + let turn_complete = RolloutLine { + timestamp: "2026-04-08T12:00:20Z".to_string(), + item: RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("done".to_string()), + })), + }; + + let body = [session_meta, user, token, exec, turn_complete] + .into_iter() + .map(|line| serde_json::to_string(&line).expect("serialize")) + .collect::>() + .join("\n"); + fs::write(path, body).expect("write rollout"); + } + + #[tokio::test] + async fn collect_and_generate_report_for_sample_rollout() { + let temp_home = tempdir().expect("temp home"); + let codex_home = temp_home.path().to_path_buf(); + write_rollout(&codex_home, "sessions", ThreadId::new()); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .build() + .await + .expect("config"); + config.sqlite_home = codex_home.clone(); + + let collection = collect_sessions(&config).await.expect("collect sessions"); + assert_eq!(collection.scanned_files, 1); + assert_eq!(collection.threads.len(), 1); + + let report = generate_report(config).await.expect("generate report"); + assert_eq!(report.overview.total_threads, 1); + assert!(report.report_path.exists()); + assert!(report.report_path.starts_with(codex_home.join("reports"))); + assert!(report.generated_at <= Utc::now()); + } +} diff --git a/codex-rs/tui/src/insight/report.rs b/codex-rs/tui/src/insight/report.rs new file mode 100644 index 000000000..8dadcda06 --- /dev/null +++ b/codex-rs/tui/src/insight/report.rs @@ -0,0 +1,539 @@ +use std::path::Path; + +use anyhow::Context; +use chrono::DateTime; +use chrono::Utc; +use pathdiff::diff_paths; + +use super::aggregator::format_duration; +use super::aggregator::format_number; +use super::types::InsightReportData; +use super::types::NarrativeMode; +use super::types::RootSessionSummary; + +pub(crate) async fn write_report_html(data: &InsightReportData) -> anyhow::Result<()> { + let parent_dir = data + .report_path + .parent() + .context("report path has no parent directory")?; + tokio::fs::create_dir_all(parent_dir) + .await + .with_context(|| format!("failed to create {}", parent_dir.display()))?; + let html = render_html(data); + tokio::fs::write(&data.report_path, html) + .await + .with_context(|| format!("failed to write {}", data.report_path.display()))?; + Ok(()) +} + +pub(crate) fn render_html(data: &InsightReportData) -> String { + let generated_at = format_datetime(data.generated_at); + let earliest = data + .overview + .earliest_event_at + .map(format_datetime) + .unwrap_or_else(|| "N/A".to_string()); + let latest = data + .overview + .latest_event_at + .map(format_datetime) + .unwrap_or_else(|| "N/A".to_string()); + let narrative_mode = match data.narrative_mode { + NarrativeMode::LocalHeuristics => "Local heuristics fallback / 本地启发式回退", + }; + + let top_roots_rows = data + .roots + .iter() + .take(10) + .map(|root| { + format!( + "{title}{threads}{tokens}{wall}{failures}", + id = escape_html(root.root_thread_id.to_string().as_str()), + title = escape_html(root.title.as_str()), + threads = root.threads.len(), + tokens = format_number(root.metrics.total_tokens), + wall = format_duration(root.wall_clock_span), + failures = root.metrics.total_failures(), + ) + }) + .collect::>() + .join(""); + + let root_sections = data + .roots + .iter() + .map(|root| render_root_section(root, data.codex_home.as_path())) + .collect::>() + .join(""); + + let patterns = render_list(data.common_patterns.iter().map(std::string::String::as_str)); + let suggestions = render_list( + data.efficiency_suggestions + .iter() + .map(std::string::String::as_str), + ); + + format!( + r##" + + + + + Codex Insight Report + + + +
+
+ /insight + {narrative_mode} +

Codex Insight Report

+

Dashboard first, drill-down later. Mixed CN/EN report. Generated at {generated_at}.

+

History window: {earliest} → {latest}

+ +
+
Root Sessions{root_sessions}
+
Threads{threads}
+
Tokens{tokens}
+
Exact Tool Runtime{tool_runtime}
+
Failure Rate{failure_rate:.1}%
+
History Span{history_span}
+
+
+ +
+

Executive Summary / 概览

+

Total tokens: {tokens}. Counted operations: {counted_operations}. Failures: {failures}.

+

Scanned {scanned_files} rollout file(s); skipped {skipped_files}. Archived thread(s): {archived_threads}.

+
+ +
+

Top Sessions / 高消耗会话

+ + + + + {top_roots_rows} +
Root SessionThreadsTokensWall SpanFailures
+
+ +
+

Token Analysis / Token 分析

+
+ Total: {tokens} + Input: {input_tokens} + Output: {output_tokens} + Reasoning Output: {reasoning_output_tokens} +
+
+ +
+

Time Analysis / 时间分析

+
+ History Span: {history_span} + Cumulative Thread Span: {cumulative_thread_span} + Exact Tool Runtime: {tool_runtime} + Estimated User Wait: {estimated_user_wait} + Residual Model/UI Time: {residual_runtime} +
+

Residual time is a conservative estimate after subtracting exact persisted tool durations and estimated user idle gaps.

+
+ +
+

Failure Analysis / 失败分析

+
+ Exec failures: {exec_failures} + MCP failures: {mcp_failures} + Dynamic tool failures: {dynamic_failures} + Patch failures: {patch_failures} + API errors: {api_errors} +
+
+ +
+

Tool / Patch Analysis / 工具与补丁

+
+ Exec commands: {exec_count} + Tool calls: {tool_calls} + Patches: {patch_count} + Patched files: {patched_files} + User messages: {user_messages} + Completed turns: {completed_turns} +
+
+ +
+

Common Patterns / 共性模式

+ {patterns} +
+ +
+

Efficiency Suggestions / 效率建议

+ {suggestions} +
+ +
+

Root Session Drill-down / 根会话下钻

+ {root_sections} +
+ +
+

Methodology / 指标说明

+
    +
  • Exact: exec command, MCP tool, and dynamic tool durations come from persisted rollout events.
  • +
  • Exact: wall-clock spans come from persisted rollout timestamps.
  • +
  • Estimated: user wait is measured as the gap from a completed turn to the next user message when that gap is observable in history.
  • +
  • Estimated: residual model/UI time is the remaining thread span after subtracting exact tool runtime and estimated user wait.
  • +
  • Failure rate: failures / counted operations, where counted operations are exec + MCP + dynamic tools + patch applications + API error incidents.
  • +
  • Narrative layer: this report used {narrative_mode}, so pattern and suggestion sections are deterministic local heuristics instead of a model-generated write-up.
  • +
+
+
+ +"##, + narrative_mode = escape_html(narrative_mode), + generated_at = escape_html(generated_at.as_str()), + root_sessions = data.overview.total_root_sessions, + threads = data.overview.total_threads, + tokens = format_number(data.overview.metrics.total_tokens), + tool_runtime = format_duration(data.overview.metrics.exact_tool_runtime()), + failure_rate = data.overview.metrics.failure_rate() * 100.0, + history_span = format_duration(data.overview.history_span), + counted_operations = data.overview.metrics.counted_operations(), + failures = data.overview.metrics.total_failures(), + scanned_files = data.overview.scanned_files, + skipped_files = data.overview.skipped_files, + archived_threads = data.overview.archived_threads, + input_tokens = format_number(data.overview.metrics.input_tokens), + output_tokens = format_number(data.overview.metrics.output_tokens), + reasoning_output_tokens = format_number(data.overview.metrics.reasoning_output_tokens), + cumulative_thread_span = format_duration(data.overview.metrics.cumulative_thread_span), + estimated_user_wait = format_duration(data.overview.metrics.estimated_user_wait), + residual_runtime = format_duration(data.overview.metrics.residual_runtime_estimate), + exec_failures = data.overview.metrics.exec_commands.failures, + mcp_failures = data.overview.metrics.mcp_tool_calls.failures, + dynamic_failures = data.overview.metrics.dynamic_tool_calls.failures, + patch_failures = data.overview.metrics.patches.failures, + api_errors = data.overview.metrics.api_error_count, + exec_count = data.overview.metrics.exec_commands.count, + tool_calls = data.overview.metrics.tool_call_count(), + patch_count = data.overview.metrics.patches.count, + patched_files = data.overview.metrics.patches.changed_files, + user_messages = data.overview.metrics.user_message_count, + completed_turns = data.overview.metrics.completed_turn_count, + top_roots_rows = top_roots_rows, + patterns = patterns, + suggestions = suggestions, + root_sections = root_sections, + ) +} + +fn render_root_section(root: &RootSessionSummary, codex_home: &Path) -> String { + let relative_rollout = + diff_paths(&root.rollout_path, codex_home).unwrap_or_else(|| root.rollout_path.clone()); + let cwd = if root.cwd.as_os_str().is_empty() { + "N/A".to_string() + } else { + escape_html(root.cwd.display().to_string().as_str()) + }; + let started_at = root + .earliest_event_at + .map(format_datetime) + .unwrap_or_else(|| "N/A".to_string()); + let latest_at = root + .latest_event_at + .map(format_datetime) + .unwrap_or_else(|| "N/A".to_string()); + let rows = root + .threads + .iter() + .map(|thread| { + let thread_rollout = diff_paths(&thread.rollout_path, codex_home) + .unwrap_or_else(|| thread.rollout_path.clone()); + format!( + "{title}{source}{tokens}{wall}{tools}{patches}{rollout}", + title = escape_html(thread.title.as_str()), + source = escape_html(thread.source_label.as_str()), + tokens = format_number(thread.metrics.total_tokens), + wall = format_duration(thread.wall_clock_span()), + tools = thread.metrics.tool_call_count(), + patches = thread.metrics.patches.count, + rollout = escape_html(thread_rollout.display().to_string().as_str()), + ) + }) + .collect::>() + .join(""); + + format!( + r#"
+

{title}

+

Root thread: {id} · Rollout: {rollout} · CWD: {cwd}

+
+ Threads: {thread_count} + Tokens: {tokens} + Wall Span: {wall_span} + Exact Tool Runtime: {tool_runtime} + Failure Rate: {failure_rate:.1}% +
+

Timeline: {started_at} → {latest_at}

+ + + + + {rows} +
ThreadSourceTokensWall SpanTool CallsPatchesRollout
+
"#, + id = escape_html(root.root_thread_id.to_string().as_str()), + title = escape_html(root.title.as_str()), + rollout = escape_html(relative_rollout.display().to_string().as_str()), + cwd = cwd, + thread_count = root.threads.len(), + tokens = format_number(root.metrics.total_tokens), + wall_span = format_duration(root.wall_clock_span), + tool_runtime = format_duration(root.metrics.exact_tool_runtime()), + failure_rate = root.metrics.failure_rate() * 100.0, + started_at = escape_html(started_at.as_str()), + latest_at = escape_html(latest_at.as_str()), + rows = rows, + ) +} + +fn render_list<'a>(items: impl Iterator) -> String { + let items = items + .map(|item| format!("
  • {}
  • ", escape_html(item))) + .collect::>() + .join(""); + format!("
      {items}
    ") +} + +fn format_datetime(datetime: DateTime) -> String { + datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string() +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::time::Duration; + + use chrono::TimeZone; + use chrono::Utc; + use insta::assert_snapshot; + + use super::render_html; + use crate::insight::types::AggregateMetrics; + use crate::insight::types::InsightOverview; + use crate::insight::types::InsightReportData; + use crate::insight::types::NarrativeMode; + use crate::insight::types::RootSessionSummary; + use codex_protocol::ThreadId; + + fn thread_id(value: &str) -> ThreadId { + ThreadId::from_string(value).expect("valid thread id") + } + + #[test] + fn report_html_snapshot() { + let generated_at = Utc.with_ymd_and_hms(2026, 4, 8, 12, 30, 0).unwrap(); + let metrics = AggregateMetrics { + total_tokens: 12345, + cumulative_thread_span: Duration::from_secs(600), + estimated_user_wait: Duration::from_secs(120), + residual_runtime_estimate: Duration::from_secs(180), + ..AggregateMetrics::default() + }; + let root = RootSessionSummary { + root_thread_id: thread_id("00000000-0000-0000-0000-000000000001"), + title: "Investigate /insight".to_string(), + cwd: PathBuf::from("/repo"), + rollout_path: PathBuf::from("/tmp/codex-home/sessions/root.jsonl"), + archived: false, + earliest_event_at: Some(generated_at), + latest_event_at: Some(generated_at + chrono::Duration::seconds(600)), + wall_clock_span: Duration::from_secs(600), + metrics: metrics.clone(), + threads: Vec::new(), + }; + let html = render_html(&InsightReportData { + generated_at, + codex_home: PathBuf::from("/tmp/codex-home"), + report_path: PathBuf::from("/tmp/codex-home/reports/insight-20260408-123000.html"), + overview: InsightOverview { + total_root_sessions: 1, + total_threads: 1, + archived_threads: 0, + scanned_files: 1, + skipped_files: 0, + metrics, + earliest_event_at: Some(generated_at), + latest_event_at: Some(generated_at + chrono::Duration::seconds(600)), + history_span: Duration::from_secs(600), + }, + roots: vec![root], + common_patterns: vec!["Pattern A".to_string()], + efficiency_suggestions: vec!["Suggestion A".to_string()], + narrative_mode: NarrativeMode::LocalHeuristics, + }); + assert_snapshot!("insight_report_html", html); + } +} diff --git a/codex-rs/tui/src/insight/snapshots/codex_tui__insight__report__tests__insight_report_html.snap b/codex-rs/tui/src/insight/snapshots/codex_tui__insight__report__tests__insight_report_html.snap new file mode 100644 index 000000000..520394aa6 --- /dev/null +++ b/codex-rs/tui/src/insight/snapshots/codex_tui__insight__report__tests__insight_report_html.snap @@ -0,0 +1,289 @@ +--- +source: tui/src/insight/report.rs +expression: html +--- + + + + + + Codex Insight Report + + + +
    +
    + /insight + Local heuristics fallback / 本地启发式回退 +

    Codex Insight Report

    +

    Dashboard first, drill-down later. Mixed CN/EN report. Generated at 2026-04-08 12:30:00 UTC.

    +

    History window: 2026-04-08 12:30:00 UTC → 2026-04-08 12:40:00 UTC

    + +
    +
    Root Sessions1
    +
    Threads1
    +
    Tokens12,345
    +
    Exact Tool Runtime0s
    +
    Failure Rate0.0%
    +
    History Span10m 0s
    +
    +
    + +
    +

    Executive Summary / 概览

    +

    Total tokens: 12,345. Counted operations: 0. Failures: 0.

    +

    Scanned 1 rollout file(s); skipped 0. Archived thread(s): 0.

    +
    + +
    +

    Top Sessions / 高消耗会话

    + + + + + +
    Root SessionThreadsTokensWall SpanFailures
    Investigate /insight012,34510m 0s0
    +
    + +
    +

    Token Analysis / Token 分析

    +
    + Total: 12,345 + Input: 0 + Output: 0 + Reasoning Output: 0 +
    +
    + +
    +

    Time Analysis / 时间分析

    +
    + History Span: 10m 0s + Cumulative Thread Span: 10m 0s + Exact Tool Runtime: 0s + Estimated User Wait: 2m 0s + Residual Model/UI Time: 3m 0s +
    +

    Residual time is a conservative estimate after subtracting exact persisted tool durations and estimated user idle gaps.

    +
    + +
    +

    Failure Analysis / 失败分析

    +
    + Exec failures: 0 + MCP failures: 0 + Dynamic tool failures: 0 + Patch failures: 0 + API errors: 0 +
    +
    + +
    +

    Tool / Patch Analysis / 工具与补丁

    +
    + Exec commands: 0 + Tool calls: 0 + Patches: 0 + Patched files: 0 + User messages: 0 + Completed turns: 0 +
    +
    + +
    +

    Common Patterns / 共性模式

    +
    • Pattern A
    +
    + +
    +

    Efficiency Suggestions / 效率建议

    +
    • Suggestion A
    +
    + +
    +

    Root Session Drill-down / 根会话下钻

    +
    +

    Investigate /insight

    +

    Root thread: 00000000-0000-0000-0000-000000000001 · Rollout: sessions/root.jsonl · CWD: /repo

    +
    + Threads: 0 + Tokens: 12,345 + Wall Span: 10m 0s + Exact Tool Runtime: 0s + Failure Rate: 0.0% +
    +

    Timeline: 2026-04-08 12:30:00 UTC → 2026-04-08 12:40:00 UTC

    + + + + + +
    ThreadSourceTokensWall SpanTool CallsPatchesRollout
    +
    +
    + +
    +

    Methodology / 指标说明

    +
      +
    • Exact: exec command, MCP tool, and dynamic tool durations come from persisted rollout events.
    • +
    • Exact: wall-clock spans come from persisted rollout timestamps.
    • +
    • Estimated: user wait is measured as the gap from a completed turn to the next user message when that gap is observable in history.
    • +
    • Estimated: residual model/UI time is the remaining thread span after subtracting exact tool runtime and estimated user wait.
    • +
    • Failure rate: failures / counted operations, where counted operations are exec + MCP + dynamic tools + patch applications + API error incidents.
    • +
    • Narrative layer: this report used Local heuristics fallback / 本地启发式回退, so pattern and suggestion sections are deterministic local heuristics instead of a model-generated write-up.
    • +
    +
    +
    + + diff --git a/codex-rs/tui/src/insight/types.rs b/codex-rs/tui/src/insight/types.rs new file mode 100644 index 000000000..d16cc95c0 --- /dev/null +++ b/codex-rs/tui/src/insight/types.rs @@ -0,0 +1,257 @@ +use std::path::PathBuf; +use std::time::Duration; + +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::ThreadId; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct OperationStats { + pub(crate) count: u32, + pub(crate) failures: u32, + pub(crate) duration: Duration, +} + +impl OperationStats { + pub(crate) fn add_sample(&mut self, failed: bool, duration: Duration) { + self.count = self.count.saturating_add(1); + if failed { + self.failures = self.failures.saturating_add(1); + } + self.duration = self.duration.saturating_add(duration); + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct PatchStats { + pub(crate) count: u32, + pub(crate) failures: u32, + pub(crate) changed_files: u32, +} + +impl PatchStats { + pub(crate) fn add_sample(&mut self, failed: bool, changed_files: usize) { + self.count = self.count.saturating_add(1); + if failed { + self.failures = self.failures.saturating_add(1); + } + self.changed_files = self + .changed_files + .saturating_add(u32::try_from(changed_files).unwrap_or(u32::MAX)); + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct AggregateMetrics { + pub(crate) total_tokens: i64, + pub(crate) input_tokens: i64, + pub(crate) output_tokens: i64, + pub(crate) reasoning_output_tokens: i64, + pub(crate) exec_commands: OperationStats, + pub(crate) mcp_tool_calls: OperationStats, + pub(crate) dynamic_tool_calls: OperationStats, + pub(crate) web_search_count: u32, + pub(crate) image_generation_count: u32, + pub(crate) view_image_count: u32, + pub(crate) patches: PatchStats, + pub(crate) api_error_count: u32, + pub(crate) parse_errors: usize, + pub(crate) user_message_count: u32, + pub(crate) completed_turn_count: u32, + pub(crate) estimated_user_wait: Duration, + pub(crate) cumulative_thread_span: Duration, + pub(crate) residual_runtime_estimate: Duration, +} + +impl AggregateMetrics { + pub(crate) fn add_assign(&mut self, other: &Self) { + self.total_tokens += other.total_tokens; + self.input_tokens += other.input_tokens; + self.output_tokens += other.output_tokens; + self.reasoning_output_tokens += other.reasoning_output_tokens; + self.exec_commands.count = self + .exec_commands + .count + .saturating_add(other.exec_commands.count); + self.exec_commands.failures = self + .exec_commands + .failures + .saturating_add(other.exec_commands.failures); + self.exec_commands.duration = self + .exec_commands + .duration + .saturating_add(other.exec_commands.duration); + self.mcp_tool_calls.count = self + .mcp_tool_calls + .count + .saturating_add(other.mcp_tool_calls.count); + self.mcp_tool_calls.failures = self + .mcp_tool_calls + .failures + .saturating_add(other.mcp_tool_calls.failures); + self.mcp_tool_calls.duration = self + .mcp_tool_calls + .duration + .saturating_add(other.mcp_tool_calls.duration); + self.dynamic_tool_calls.count = self + .dynamic_tool_calls + .count + .saturating_add(other.dynamic_tool_calls.count); + self.dynamic_tool_calls.failures = self + .dynamic_tool_calls + .failures + .saturating_add(other.dynamic_tool_calls.failures); + self.dynamic_tool_calls.duration = self + .dynamic_tool_calls + .duration + .saturating_add(other.dynamic_tool_calls.duration); + self.web_search_count = self.web_search_count.saturating_add(other.web_search_count); + self.image_generation_count = self + .image_generation_count + .saturating_add(other.image_generation_count); + self.view_image_count = self.view_image_count.saturating_add(other.view_image_count); + self.patches.count = self.patches.count.saturating_add(other.patches.count); + self.patches.failures = self.patches.failures.saturating_add(other.patches.failures); + self.patches.changed_files = self + .patches + .changed_files + .saturating_add(other.patches.changed_files); + self.api_error_count = self.api_error_count.saturating_add(other.api_error_count); + self.parse_errors = self.parse_errors.saturating_add(other.parse_errors); + self.user_message_count = self + .user_message_count + .saturating_add(other.user_message_count); + self.completed_turn_count = self + .completed_turn_count + .saturating_add(other.completed_turn_count); + self.estimated_user_wait = self + .estimated_user_wait + .saturating_add(other.estimated_user_wait); + self.cumulative_thread_span = self + .cumulative_thread_span + .saturating_add(other.cumulative_thread_span); + self.residual_runtime_estimate = self + .residual_runtime_estimate + .saturating_add(other.residual_runtime_estimate); + } + + pub(crate) fn tool_call_count(&self) -> u32 { + self.mcp_tool_calls + .count + .saturating_add(self.dynamic_tool_calls.count) + .saturating_add(self.web_search_count) + .saturating_add(self.image_generation_count) + .saturating_add(self.view_image_count) + } + + pub(crate) fn exact_tool_runtime(&self) -> Duration { + self.exec_commands + .duration + .saturating_add(self.mcp_tool_calls.duration) + .saturating_add(self.dynamic_tool_calls.duration) + } + + pub(crate) fn total_failures(&self) -> u32 { + self.exec_commands + .failures + .saturating_add(self.mcp_tool_calls.failures) + .saturating_add(self.dynamic_tool_calls.failures) + .saturating_add(self.patches.failures) + .saturating_add(self.api_error_count) + } + + pub(crate) fn counted_operations(&self) -> u32 { + self.exec_commands + .count + .saturating_add(self.mcp_tool_calls.count) + .saturating_add(self.dynamic_tool_calls.count) + .saturating_add(self.patches.count) + .saturating_add(self.api_error_count) + } + + pub(crate) fn failure_rate(&self) -> f64 { + let total = self.counted_operations(); + if total == 0 { + 0.0 + } else { + self.total_failures() as f64 / total as f64 + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CollectedThread { + pub(crate) thread_id: ThreadId, + pub(crate) parent_thread_id: Option, + pub(crate) depth: Option, + pub(crate) title: String, + pub(crate) cwd: PathBuf, + pub(crate) rollout_path: PathBuf, + pub(crate) archived: bool, + pub(crate) source_label: String, + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, + pub(crate) agent_path: Option, + pub(crate) created_at: DateTime, + pub(crate) updated_at: DateTime, + pub(crate) first_event_at: Option>, + pub(crate) last_event_at: Option>, + pub(crate) metrics: AggregateMetrics, +} + +impl CollectedThread { + pub(crate) fn wall_clock_span(&self) -> Duration { + self.metrics.cumulative_thread_span + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct CollectionResult { + pub(crate) threads: Vec, + pub(crate) scanned_files: usize, + pub(crate) skipped_files: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RootSessionSummary { + pub(crate) root_thread_id: ThreadId, + pub(crate) title: String, + pub(crate) cwd: PathBuf, + pub(crate) rollout_path: PathBuf, + pub(crate) archived: bool, + pub(crate) earliest_event_at: Option>, + pub(crate) latest_event_at: Option>, + pub(crate) wall_clock_span: Duration, + pub(crate) metrics: AggregateMetrics, + pub(crate) threads: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InsightOverview { + pub(crate) total_root_sessions: usize, + pub(crate) total_threads: usize, + pub(crate) archived_threads: usize, + pub(crate) scanned_files: usize, + pub(crate) skipped_files: usize, + pub(crate) metrics: AggregateMetrics, + pub(crate) earliest_event_at: Option>, + pub(crate) latest_event_at: Option>, + pub(crate) history_span: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum NarrativeMode { + LocalHeuristics, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct InsightReportData { + pub(crate) generated_at: DateTime, + pub(crate) codex_home: PathBuf, + pub(crate) report_path: PathBuf, + pub(crate) overview: InsightOverview, + pub(crate) roots: Vec, + pub(crate) common_patterns: Vec, + pub(crate) efficiency_suggestions: Vec, + pub(crate) narrative_mode: NarrativeMode, +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 991c91c55..cbb454525 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -106,6 +106,8 @@ pub mod custom_terminal; mod cwd_prompt; mod debug_config; mod diff_render; +mod display_preferences; +mod display_preferences_menu; mod exec_cell; mod exec_command; mod external_editor; @@ -114,6 +116,7 @@ mod frames; mod get_git_diff; mod history_cell; pub mod insert_history; +mod insight; mod key_hint; mod line_truncation; pub mod live_wrap; @@ -129,6 +132,7 @@ mod notifications; pub mod onboarding; mod oss_selection; mod pager_overlay; +mod profile_router; pub mod public_widgets; mod render; mod resume_picker; @@ -545,29 +549,49 @@ async fn lookup_latest_session_target_with_app_server( cwd_filter: Option<&Path>, include_non_interactive: bool, ) -> color_eyre::Result> { - let response = app_server - .thread_list(latest_session_lookup_params( - app_server.is_remote(), - config, - cwd_filter, - include_non_interactive, - )) - .await?; - Ok(response - .data - .into_iter() - .find_map(session_target_from_app_server_thread)) + let mut cursor = None; + let mut fallback = None; + + loop { + let response = app_server + .thread_list(latest_session_lookup_params( + cursor.clone(), + app_server.is_remote(), + config, + include_non_interactive, + )) + .await?; + + for thread in response.data { + let matches_cwd = + cwd_filter.is_some_and(|cwd| app_server_thread_matches_cwd(&thread, cwd)); + let Some(target) = session_target_from_app_server_thread(thread) else { + continue; + }; + if fallback.is_none() { + fallback = Some(target.clone()); + } + if matches_cwd { + return Ok(Some(target)); + } + } + + if response.next_cursor.is_none() { + return Ok(fallback); + } + cursor = response.next_cursor; + } } fn latest_session_lookup_params( + cursor: Option, is_remote: bool, config: &Config, - cwd_filter: Option<&Path>, include_non_interactive: bool, ) -> ThreadListParams { ThreadListParams { - cursor: None, - limit: Some(1), + cursor, + limit: Some(100), sort_key: Some(AppServerThreadSortKey::UpdatedAt), model_providers: if is_remote { None @@ -577,11 +601,21 @@ fn latest_session_lookup_params( source_kinds: (!include_non_interactive) .then_some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]), archived: Some(false), - cwd: cwd_filter.map(|cwd| cwd.to_string_lossy().to_string()), + cwd: None, search_term: None, } } +fn app_server_thread_matches_cwd(thread: &AppServerThread, cwd_filter: &Path) -> bool { + if let (Ok(thread_cwd), Ok(filter_cwd)) = ( + path_utils::normalize_for_path_comparison(&thread.cwd), + path_utils::normalize_for_path_comparison(cwd_filter), + ) { + return thread_cwd == filter_cwd; + } + thread.cwd == cwd_filter +} + fn config_cwd_for_app_server_target( cwd: Option<&Path>, app_server_target: &AppServerTarget, @@ -970,8 +1004,6 @@ async fn run_ratatui_app( let remote_mode = matches!(&app_server_target, AppServerTarget::Remote { .. }); color_eyre::install()?; - tooltips::announcement::prewarm(); - // Forward panic reports through tracing so they appear in the UI status // line, but do not swallow the default/color-eyre panic handler. // Chain to the previous hook so users still get a rich panic report @@ -981,6 +1013,9 @@ async fn run_ratatui_app( tracing::error!("panic: {info}"); prev_hook(info); })); + + tooltips::announcement::prewarm(); + let mut terminal = tui::init()?; terminal.clear()?; @@ -1002,6 +1037,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: Some(action), + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1075,6 +1111,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1121,6 +1158,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::Fatal(format!( "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." )), @@ -1195,7 +1233,11 @@ async fn run_ratatui_app( match resume_picker::run_fork_picker_with_app_server( &mut tui, &config, - cli.fork_show_all, + if cli.fork_show_all { + resume_picker::SessionPickerOrder::GlobalTime + } else { + resume_picker::SessionPickerOrder::LocalGroupFirst + }, app_server, ) .await? @@ -1208,6 +1250,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1255,7 +1298,12 @@ async fn run_ratatui_app( match resume_picker::run_resume_picker_with_app_server( &mut tui, &config, - cli.resume_show_all, + if cli.resume_show_all { + resume_picker::SessionPickerOrder::GlobalTime + } else { + resume_picker::SessionPickerOrder::LocalGroupFirst + }, + resume_picker::SessionPickerProviderScope::CurrentProfile, cli.resume_include_non_interactive, app_server, ) @@ -1269,6 +1317,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1315,6 +1364,7 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, + respawn_with_yolo: false, exit_reason: ExitReason::UserRequested, }); } @@ -1822,21 +1872,18 @@ mod tests { } #[tokio::test] - async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions() + async fn latest_session_lookup_params_keep_local_provider_filter_for_embedded_sessions() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let config = build_config(&temp_dir).await?; - let cwd = temp_dir.path().join("project"); let params = latest_session_lookup_params( - /*is_remote*/ false, - &config, - Some(cwd.as_path()), + /*cursor*/ None, /*is_remote*/ false, &config, /*include_non_interactive*/ false, ); assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); - assert_eq!(params.cwd, Some(cwd.to_string_lossy().to_string())); + assert_eq!(params.cwd, None); Ok(()) } @@ -1847,7 +1894,7 @@ mod tests { let config = build_config(&temp_dir).await?; let params = latest_session_lookup_params( - /*is_remote*/ true, &config, /*cwd_filter*/ None, + /*cursor*/ None, /*is_remote*/ true, &config, /*include_non_interactive*/ false, ); @@ -1857,21 +1904,21 @@ mod tests { } #[tokio::test] - async fn latest_session_lookup_params_keep_explicit_cwd_filter_for_remote_sessions() + async fn latest_session_lookup_params_omit_explicit_cwd_filter_for_remote_sessions() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let config = build_config(&temp_dir).await?; - let cwd = Path::new("repo/on/server"); let params = latest_session_lookup_params( + /*cursor*/ Some(String::from("cursor-1")), /*is_remote*/ true, &config, - Some(cwd), /*include_non_interactive*/ false, ); + assert_eq!(params.cursor, Some(String::from("cursor-1"))); assert_eq!(params.model_providers, None); - assert_eq!(params.cwd.as_deref(), Some("repo/on/server")); + assert_eq!(params.cwd, None); Ok(()) } diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 293c80fcf..4c3ab659f 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -493,7 +493,7 @@ fn wait_complete_lines( status: status.clone(), }) .collect::>(); - entries.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.sort_by_key(|left| left.thread_id.to_string()); entries } else { let mut entries = agent_statuses.to_vec(); @@ -511,7 +511,7 @@ fn wait_complete_lines( status: status.clone(), }) .collect::>(); - extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + extras.sort_by_key(|left| left.thread_id.to_string()); entries.extend(extras); entries }; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index bca5f1f36..86ae8fa3a 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -477,10 +477,11 @@ impl TranscriptOverlay { .enumerate() .flat_map(|(i, c)| { let mut v: Vec> = Vec::new(); + let highlighted = highlight_cell == Some(i); let mut cell_renderable = if c.as_any().is::() { Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), - style: if highlight_cell == Some(i) { + style: if highlighted { user_message_style().reversed() } else { user_message_style() @@ -489,7 +490,11 @@ impl TranscriptOverlay { } else { Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), - style: Style::default(), + style: if highlighted { + Style::default().reversed() + } else { + Style::default() + }, })) as Box }; if !c.is_stream_continuation() && i > 0 { @@ -717,6 +722,11 @@ impl TranscriptOverlay { pub(crate) fn committed_cell_count(&self) -> usize { self.cells.len() } + + #[cfg(test)] + pub(crate) fn highlighted_cell(&self) -> Option { + self.highlight_cell + } } pub(crate) struct StaticOverlay { @@ -875,6 +885,7 @@ mod tests { lines: vec![Line::from("hello")], })]); overlay.set_highlight_cell(Some(0)); + assert_eq!(overlay.highlighted_cell(), Some(0)); // Render into a wide buffer so the footer hints aren't truncated. let area = Rect::new(0, 0, 120, 10); @@ -986,7 +997,11 @@ mod tests { content: "hello\nworld\n".to_string(), }, ); - let approval_cell: Arc = Arc::new(new_patch_event(approval_changes, &cwd)); + let approval_cell: Arc = Arc::new(new_patch_event( + approval_changes, + &cwd, + crate::display_preferences::DisplayPreferences::default(), + )); cells.push(approval_cell); let mut apply_changes = HashMap::new(); @@ -996,7 +1011,11 @@ mod tests { content: "hello\nworld\n".to_string(), }, ); - let apply_begin_cell: Arc = Arc::new(new_patch_event(apply_changes, &cwd)); + let apply_begin_cell: Arc = Arc::new(new_patch_event( + apply_changes, + &cwd, + crate::display_preferences::DisplayPreferences::default(), + )); cells.push(apply_begin_cell); let apply_end_cell: Arc = history_cell::new_approval_decision_cell( @@ -1014,6 +1033,7 @@ mod tests { ExecCommandSource::Agent, /*interaction_input*/ None, /*animations_enabled*/ true, + crate::display_preferences::DisplayPreferences::default(), ); exec_cell.complete_call( "exec-1", diff --git a/codex-rs/tui/src/profile_router.rs b/codex-rs/tui/src/profile_router.rs new file mode 100644 index 000000000..b37854ae7 --- /dev/null +++ b/codex-rs/tui/src/profile_router.rs @@ -0,0 +1,271 @@ +use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; +#[cfg(test)] +use codex_protocol::protocol::CodexErrorInfo; +use serde::Deserialize; +use serde::Serialize; +use std::fs; +use std::io; +use std::path::PathBuf; + +pub(crate) const PROFILE_ROUTER_STATE_RELATIVE_PATH: &str = "accounts/profile-router.json"; +const PROFILE_ROUTER_STATE_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProfileFallbackAction { + RetrySameProfileFirst, + SwitchProfileImmediately, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProfileRouteEntry { + pub(crate) profile_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProfileRouterState { + pub(crate) version: u32, + pub(crate) active_profile_id: Option, + pub(crate) routes: Vec, +} + +impl Default for ProfileRouterState { + fn default() -> Self { + Self { + version: PROFILE_ROUTER_STATE_VERSION, + active_profile_id: None, + routes: Vec::new(), + } + } +} + +impl ProfileRouterState { + pub(crate) fn contains_profile(&self, profile_id: &str) -> bool { + self.routes + .iter() + .any(|route| route.profile_id == profile_id) + } + + pub(crate) fn set_active_profile(&mut self, profile_id: &str) -> bool { + if !self.contains_profile(profile_id) { + return false; + } + let next = Some(profile_id.to_string()); + + if self.active_profile_id == next { + false + } else { + self.active_profile_id = next; + true + } + } + + pub(crate) fn set_runtime_active_profile(&mut self, profile_id: Option<&str>) -> bool { + let next = profile_id + .filter(|profile_id| self.contains_profile(profile_id)) + .map(ToOwned::to_owned); + if self.active_profile_id == next { + false + } else { + self.active_profile_id = next; + true + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct DefaultProfileRouter; + +impl DefaultProfileRouter { + pub(crate) fn fallback_profile( + &self, + state: &ProfileRouterState, + active_profile_id: Option<&str>, + ) -> Option { + state + .routes + .iter() + .find(|route| Some(route.profile_id.as_str()) != active_profile_id) + .map(|route| route.profile_id.clone()) + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProfileRouterStore { + codex_home: PathBuf, +} + +impl ProfileRouterStore { + pub(crate) fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + fn path(&self) -> PathBuf { + self.codex_home.join(PROFILE_ROUTER_STATE_RELATIVE_PATH) + } + + pub(crate) fn load(&self) -> io::Result { + let path = self.path(); + match fs::read_to_string(path) { + Ok(contents) => serde_json::from_str(&contents).map_err(io::Error::other), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(ProfileRouterState::default()), + Err(err) => Err(err), + } + } + + fn save(&self, state: &ProfileRouterState) -> io::Result<()> { + let path = self.path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string_pretty(state).map_err(io::Error::other)?; + fs::write(path, contents) + } + + pub(crate) fn update(&self, updater: F) -> io::Result + where + F: FnOnce(&mut ProfileRouterState), + { + let mut state = self.load()?; + updater(&mut state); + self.save(&state)?; + Ok(state) + } +} + +#[cfg(test)] +pub(crate) fn core_profile_fallback_action(info: &CodexErrorInfo) -> Option { + match info { + CodexErrorInfo::UsageLimitExceeded | CodexErrorInfo::Unauthorized => { + Some(ProfileFallbackAction::SwitchProfileImmediately) + } + CodexErrorInfo::ServerOverloaded | CodexErrorInfo::InternalServerError => { + Some(ProfileFallbackAction::RetrySameProfileFirst) + } + CodexErrorInfo::HttpConnectionFailed { http_status_code } + | CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } + | CodexErrorInfo::ResponseStreamDisconnected { http_status_code } + | CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { + match http_status_code { + Some(401 | 403 | 429) => Some(ProfileFallbackAction::SwitchProfileImmediately), + Some(500..=599) | None => Some(ProfileFallbackAction::RetrySameProfileFirst), + Some(_) => None, + } + } + CodexErrorInfo::ContextWindowExceeded + | CodexErrorInfo::BadRequest + | CodexErrorInfo::SandboxError + | CodexErrorInfo::ActiveTurnNotSteerable { .. } + | CodexErrorInfo::ThreadRollbackFailed + | CodexErrorInfo::Other => None, + } +} + +pub(crate) fn app_server_profile_fallback_action( + info: &AppServerCodexErrorInfo, +) -> Option { + match info { + AppServerCodexErrorInfo::UsageLimitExceeded | AppServerCodexErrorInfo::Unauthorized => { + Some(ProfileFallbackAction::SwitchProfileImmediately) + } + AppServerCodexErrorInfo::ServerOverloaded + | AppServerCodexErrorInfo::InternalServerError => { + Some(ProfileFallbackAction::RetrySameProfileFirst) + } + AppServerCodexErrorInfo::HttpConnectionFailed { http_status_code } + | AppServerCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } + | AppServerCodexErrorInfo::ResponseStreamDisconnected { http_status_code } + | AppServerCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { + match http_status_code { + Some(401 | 403 | 429) => Some(ProfileFallbackAction::SwitchProfileImmediately), + Some(500..=599) | None => Some(ProfileFallbackAction::RetrySameProfileFirst), + Some(_) => None, + } + } + AppServerCodexErrorInfo::ContextWindowExceeded + | AppServerCodexErrorInfo::BadRequest + | AppServerCodexErrorInfo::ThreadRollbackFailed + | AppServerCodexErrorInfo::SandboxError + | AppServerCodexErrorInfo::ActiveTurnNotSteerable { .. } + | AppServerCodexErrorInfo::Other => None, + } +} + +#[cfg(test)] +mod tests { + use super::DefaultProfileRouter; + use super::ProfileFallbackAction; + use super::ProfileRouteEntry; + use super::ProfileRouterState; + use super::ProfileRouterStore; + use super::app_server_profile_fallback_action; + use super::core_profile_fallback_action; + use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; + use codex_protocol::protocol::CodexErrorInfo; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn fallback_profile_skips_current_profile() { + let state = ProfileRouterState { + version: 1, + active_profile_id: Some("primary".to_string()), + routes: vec![ + ProfileRouteEntry { + profile_id: "primary".to_string(), + }, + ProfileRouteEntry { + profile_id: "secondary".to_string(), + }, + ], + }; + + let fallback = DefaultProfileRouter + .fallback_profile(&state, /*active_profile_id*/ Some("primary")); + + assert_eq!(fallback, Some("secondary".to_string())); + } + + #[test] + fn profile_router_store_defaults_when_file_is_missing() { + let tempdir = TempDir::new().unwrap(); + let store = ProfileRouterStore::new(tempdir.path().to_path_buf()); + + let state = store.load().unwrap(); + + assert_eq!(state, ProfileRouterState::default()); + } + + #[test] + fn unexpected_status_503_is_fallback_eligible_for_core_errors() { + let action = core_profile_fallback_action(&CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(503), + }); + + assert_eq!(action, Some(ProfileFallbackAction::RetrySameProfileFirst)); + } + + #[test] + fn unexpected_status_503_is_fallback_eligible_for_app_server_errors() { + let action = app_server_profile_fallback_action( + &AppServerCodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(503), + }, + ); + + assert_eq!(action, Some(ProfileFallbackAction::RetrySameProfileFirst)); + } + + #[test] + fn runtime_active_profile_clears_when_selected_profile_is_not_routed() { + let mut state = ProfileRouterState { + version: 1, + active_profile_id: Some("secondary".to_string()), + routes: vec![ProfileRouteEntry { + profile_id: "secondary".to_string(), + }], + }; + + assert!(state.set_runtime_active_profile(Some("missing"))); + assert_eq!(state.active_profile_id, None); + } +} diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index a415a2552..736489488 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -108,12 +108,18 @@ struct PageLoadRequest { sort_key: ThreadSortKey, } -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] enum ProviderFilter { Any, MatchDefault(String), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SessionPickerProviderScope { + AllProviders, + CurrentProfile, +} + type PageLoader = Arc; enum BackgroundEvent { @@ -124,11 +130,17 @@ enum BackgroundEvent { }, } -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq, Eq)] enum PageCursor { #[allow(dead_code)] Rollout(Cursor), - AppServer(String), + AppServer(AppServerPageCursor), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum AppServerPageCursor { + Local(Option), + Global(Option), } struct PickerPage { @@ -151,39 +163,62 @@ struct PickerPage { /// new sessions appear during pagination. /// /// Filtering happens in two layers: -/// 1. Provider and source filtering at the backend (only interactive CLI sessions -/// for the current model provider). -/// 2. Working-directory filtering at the picker (unless `--all` is passed). +/// 1. Provider and source filtering at the backend. +/// 2. Query filtering at the picker, while presentation order determines whether +/// current-working-directory sessions are grouped ahead of the rest of global +/// history. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SessionPickerOrder { + GlobalTime, + LocalGroupFirst, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PickerFilters { + provider_filter: ProviderFilter, + filter_cwd: Option, +} + #[allow(dead_code)] pub async fn run_resume_picker( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, ) -> Result { - run_session_picker(tui, config, show_all, SessionPickerAction::Resume).await + run_session_picker(tui, config, order, SessionPickerAction::Resume).await } pub async fn run_resume_picker_with_app_server( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, + provider_scope: SessionPickerProviderScope, include_non_interactive: bool, app_server: AppServerSession, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let is_remote = app_server.is_remote(); - let cwd_filter = if show_all { - None - } else { - app_server.remote_cwd_override().map(Path::to_path_buf) + let filters = PickerFilters { + provider_filter: provider_filter_for_scope( + is_remote, + &config.model_provider_id, + provider_scope, + ), + filter_cwd: picker_filter_cwd(config, is_remote), }; run_session_picker_with_loader( tui, config, - show_all, + order, SessionPickerAction::Resume, - is_remote, - spawn_app_server_page_loader(app_server, cwd_filter, include_non_interactive, bg_tx), + filters.clone(), + spawn_app_server_page_loader( + app_server, + order, + filters.filter_cwd.clone(), + include_non_interactive, + bg_tx, + ), bg_rx, ) .await @@ -192,24 +227,31 @@ pub async fn run_resume_picker_with_app_server( pub async fn run_fork_picker_with_app_server( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, app_server: AppServerSession, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let is_remote = app_server.is_remote(); - let cwd_filter = if show_all { - None - } else { - app_server.remote_cwd_override().map(Path::to_path_buf) + let filters = PickerFilters { + provider_filter: provider_filter_for_scope( + is_remote, + &config.model_provider_id, + SessionPickerProviderScope::CurrentProfile, + ), + filter_cwd: picker_filter_cwd(config, is_remote), }; run_session_picker_with_loader( tui, config, - show_all, + order, SessionPickerAction::Fork, - is_remote, + filters.clone(), spawn_app_server_page_loader( - app_server, cwd_filter, /*include_non_interactive*/ false, bg_tx, + app_server, + order, + filters.filter_cwd.clone(), + /*include_non_interactive*/ false, + bg_tx, ), bg_rx, ) @@ -220,16 +262,23 @@ pub async fn run_fork_picker_with_app_server( async fn run_session_picker( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, action: SessionPickerAction, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); run_session_picker_with_loader( tui, config, - show_all, + order, action, - /*is_remote*/ false, + PickerFilters { + provider_filter: provider_filter_for_scope( + /*is_remote*/ false, + &config.model_provider_id, + SessionPickerProviderScope::CurrentProfile, + ), + filter_cwd: picker_filter_cwd(config, /*is_remote*/ false), + }, spawn_rollout_page_loader(config, bg_tx), bg_rx, ) @@ -239,35 +288,22 @@ async fn run_session_picker( async fn run_session_picker_with_loader( tui: &mut Tui, config: &Config, - show_all: bool, + order: SessionPickerOrder, action: SessionPickerAction, - is_remote: bool, + filters: PickerFilters, page_loader: PageLoader, bg_rx: mpsc::UnboundedReceiver, ) -> Result { let alt = AltScreenGuard::enter(tui); - let provider_filter = if is_remote { - ProviderFilter::Any - } else { - ProviderFilter::MatchDefault(config.model_provider_id.to_string()) - }; let codex_home = config.codex_home.as_path(); - let filter_cwd = if show_all || is_remote { - // Remote sessions live in the server's filesystem namespace, so the client - // process cwd is not a meaningful row filter. If the user provided an - // explicit remote --cd, filtering is handled server-side in thread/list. - None - } else { - std::env::current_dir().ok() - }; let mut state = PickerState::new( codex_home.to_path_buf(), alt.tui.frame_requester(), page_loader, - provider_filter, - show_all, - filter_cwd, + filters.provider_filter, + order, + filters.filter_cwd, action, ); state.start_initial_load(); @@ -353,7 +389,8 @@ fn spawn_rollout_page_loader( fn spawn_app_server_page_loader( app_server: AppServerSession, - cwd_filter: Option, + order: SessionPickerOrder, + filter_cwd: Option, include_non_interactive: bool, bg_tx: mpsc::UnboundedSender, ) -> PageLoader { @@ -369,8 +406,9 @@ fn spawn_app_server_page_loader( }; let page = load_app_server_page( &mut app_server, + order, + filter_cwd.as_deref(), cursor, - cwd_filter.as_deref(), request.provider_filter, request.sort_key, include_non_interactive, @@ -434,7 +472,7 @@ struct PickerState { page_loader: PageLoader, view_rows: Option, provider_filter: ProviderFilter, - show_all: bool, + order: SessionPickerOrder, filter_cwd: Option, action: SessionPickerAction, sort_key: ThreadSortKey, @@ -480,12 +518,14 @@ impl LoadingState { async fn load_app_server_page( app_server: &mut AppServerSession, - cursor: Option, - cwd_filter: Option<&Path>, + order: SessionPickerOrder, + filter_cwd: Option<&Path>, + cursor: Option, provider_filter: ProviderFilter, sort_key: ThreadSortKey, include_non_interactive: bool, ) -> std::io::Result { + let (cursor, cwd_filter, scope) = app_server_page_request(order, filter_cwd, cursor.as_ref()); let response = app_server .thread_list(thread_list_params( cursor, @@ -504,12 +544,73 @@ async fn load_app_server_page( .into_iter() .filter_map(row_from_app_server_thread) .collect(), - next_cursor: response.next_cursor.map(PageCursor::AppServer), + next_cursor: next_app_server_page_cursor(scope, response.next_cursor), num_scanned_files, reached_scan_cap: false, }) } +fn picker_filter_cwd(config: &Config, is_remote: bool) -> Option { + if is_remote { + // Remote sessions live in the server's filesystem namespace, so the client + // process cwd is not a meaningful local-priority hint. + None + } else { + Some(config.cwd.to_path_buf()) + } +} + +fn provider_filter_for_scope( + is_remote: bool, + default_provider: &str, + provider_scope: SessionPickerProviderScope, +) -> ProviderFilter { + if is_remote || provider_scope == SessionPickerProviderScope::AllProviders { + ProviderFilter::Any + } else { + ProviderFilter::MatchDefault(default_provider.to_string()) + } +} + +fn app_server_page_request<'a>( + order: SessionPickerOrder, + filter_cwd: Option<&'a Path>, + cursor: Option<&AppServerPageCursor>, +) -> (Option, Option<&'a Path>, AppServerPageScope) { + match cursor { + Some(AppServerPageCursor::Local(cursor)) => { + (cursor.clone(), filter_cwd, AppServerPageScope::Local) + } + Some(AppServerPageCursor::Global(cursor)) => { + (cursor.clone(), None, AppServerPageScope::Global) + } + None if order == SessionPickerOrder::LocalGroupFirst && filter_cwd.is_some() => { + (None, filter_cwd, AppServerPageScope::Local) + } + None => (None, None, AppServerPageScope::Global), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AppServerPageScope { + Local, + Global, +} + +fn next_app_server_page_cursor( + scope: AppServerPageScope, + next_cursor: Option, +) -> Option { + match scope { + AppServerPageScope::Local => Some(PageCursor::AppServer(match next_cursor { + Some(cursor) => AppServerPageCursor::Local(Some(cursor)), + None => AppServerPageCursor::Global(None), + })), + AppServerPageScope::Global => next_cursor + .map(|cursor| PageCursor::AppServer(AppServerPageCursor::Global(Some(cursor)))), + } +} + impl SearchState { fn active_token(&self) -> Option { match self { @@ -572,7 +673,7 @@ impl PickerState { requester: FrameRequester, page_loader: PageLoader, provider_filter: ProviderFilter, - show_all: bool, + order: SessionPickerOrder, filter_cwd: Option, action: SessionPickerAction, ) -> Self { @@ -597,7 +698,7 @@ impl PickerState { page_loader, view_rows: None, provider_filter, - show_all, + order, filter_cwd, action, sort_key: ThreadSortKey::UpdatedAt, @@ -674,8 +775,8 @@ impl PickerState { self.request_frame(); } } - KeyCode::PageDown => { - if !self.filtered_rows.is_empty() { + KeyCode::PageDown + if !self.filtered_rows.is_empty() => { let step = self.view_rows.unwrap_or(10).max(1); let max_index = self.filtered_rows.len().saturating_sub(1); self.selected = (self.selected + step).min(max_index); @@ -683,7 +784,6 @@ impl PickerState { self.maybe_load_more_for_scroll(); self.request_frame(); } - } KeyCode::Tab => { self.toggle_sort_key(); self.request_frame(); @@ -693,18 +793,17 @@ impl PickerState { new_query.pop(); self.set_query(new_query); } - KeyCode::Char(c) => { + KeyCode::Char(c) // basic text input for search if !key .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) && !key.modifiers.contains(crossterm::event::KeyModifiers::ALT) - { + => { let mut new_query = self.query.clone(); new_query.push(c); self.set_query(new_query); } - } _ => {} } Ok(None) @@ -847,15 +946,23 @@ impl PickerState { } fn apply_filter(&mut self) { - let base_iter = self - .all_rows - .iter() - .filter(|row| self.row_matches_filter(row)); if self.query.is_empty() { - self.filtered_rows = base_iter.cloned().collect(); + self.filtered_rows = self.all_rows.clone(); } else { let q = self.query.to_lowercase(); - self.filtered_rows = base_iter.filter(|r| r.matches_query(&q)).cloned().collect(); + self.filtered_rows = self + .all_rows + .iter() + .filter(|row| row.matches_query(&q)) + .cloned() + .collect(); + } + if self.order == SessionPickerOrder::LocalGroupFirst { + let filter_cwd = self.filter_cwd.clone(); + self.filtered_rows.sort_by_key(|row| { + let is_local = Self::row_matches_local_cwd(row, filter_cwd.as_deref()); + !is_local + }); } if self.selected >= self.filtered_rows.len() { self.selected = self.filtered_rows.len().saturating_sub(1); @@ -867,12 +974,9 @@ impl PickerState { self.request_frame(); } - fn row_matches_filter(&self, row: &Row) -> bool { - if self.show_all { - return true; - } - let Some(filter_cwd) = self.filter_cwd.as_ref() else { - return true; + fn row_matches_local_cwd(row: &Row, filter_cwd: Option<&Path>) -> bool { + let Some(filter_cwd) = filter_cwd else { + return false; }; let Some(row_cwd) = row.cwd.as_ref() else { return false; @@ -1130,7 +1234,9 @@ fn thread_list_params( ThreadSortKey::UpdatedAt => AppServerThreadSortKey::UpdatedAt, }), model_providers: match provider_filter { - ProviderFilter::Any => None, + // App-server interprets `modelProviders = null` as "current provider only"; + // send an explicit empty list to request unfiltered local history. + ProviderFilter::Any => Some(vec![]), ProviderFilter::MatchDefault(default_provider) => Some(vec![default_provider]), }, source_kinds: (!include_non_interactive) @@ -1186,7 +1292,7 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { // Search line frame.render_widget_ref(search_line(state), search); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true); // Column headers and list render_column_headers(frame, columns, &metrics, state.sort_key); @@ -1850,22 +1956,22 @@ mod tests { } #[test] - fn remote_thread_list_params_omit_provider_filter() { + fn any_provider_thread_list_params_request_unfiltered_history() { let params = thread_list_params( Some(String::from("cursor-1")), - Some(Path::new("repo/on/server")), + /*cwd_filter*/ None, ProviderFilter::Any, ThreadSortKey::UpdatedAt, /*include_non_interactive*/ false, ); assert_eq!(params.cursor, Some(String::from("cursor-1"))); - assert_eq!(params.model_providers, None); + assert_eq!(params.model_providers, Some(vec![])); assert_eq!( params.source_kinds, Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]) ); - assert_eq!(params.cwd.as_deref(), Some("repo/on/server")); + assert_eq!(params.cwd, None); } #[test] @@ -1879,22 +1985,108 @@ mod tests { ); assert_eq!(params.cursor, Some(String::from("cursor-1"))); - assert_eq!(params.model_providers, None); + assert_eq!(params.model_providers, Some(vec![])); assert_eq!(params.source_kinds, None); } #[test] - fn remote_picker_does_not_filter_rows_by_local_cwd() { + fn provider_filter_scope_can_include_all_local_sessions() { + assert_eq!( + provider_filter_for_scope( + /*is_remote*/ false, + "daz2", + SessionPickerProviderScope::AllProviders + ), + ProviderFilter::Any + ); + } + + #[test] + fn provider_filter_scope_keeps_current_profile_filter_for_local_sessions() { + assert_eq!( + provider_filter_for_scope( + /*is_remote*/ false, + "daz2", + SessionPickerProviderScope::CurrentProfile + ), + ProviderFilter::MatchDefault(String::from("daz2")) + ); + } + + #[test] + fn local_cwd_rows_are_prioritized_without_hiding_global_rows() { let loader: PageLoader = Arc::new(|_| {}); - let state = PickerState::new( + let mut state = PickerState::new( PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, - ProviderFilter::Any, - /*show_all*/ false, - /*filter_cwd*/ None, + ProviderFilter::MatchDefault(String::from("openai")), + SessionPickerOrder::LocalGroupFirst, + Some(PathBuf::from("/repo/local")), SessionPickerAction::Resume, ); + state.all_rows = vec![ + Row { + path: Some(PathBuf::from("/tmp/global.jsonl")), + preview: String::from("global"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/repo/elsewhere")), + git_branch: None, + }, + Row { + path: Some(PathBuf::from("/tmp/local.jsonl")), + preview: String::from("local"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/repo/local")), + git_branch: None, + }, + ]; + + state.apply_filter(); + + let previews: Vec<&str> = state + .filtered_rows + .iter() + .map(|row| row.preview.as_str()) + .collect(); + assert_eq!(previews, vec!["local", "global"]); + } + + #[test] + fn app_server_page_request_starts_with_local_scope_when_local_priority_has_cwd() { + let (cursor, cwd_filter, scope) = app_server_page_request( + SessionPickerOrder::LocalGroupFirst, + Some(Path::new("/repo/local")), + None, + ); + + assert_eq!(cursor, None); + assert_eq!(cwd_filter, Some(Path::new("/repo/local"))); + assert_eq!(scope, AppServerPageScope::Local); + } + + #[test] + fn next_app_server_page_cursor_transitions_from_local_to_global() { + assert_eq!( + next_app_server_page_cursor(AppServerPageScope::Local, None), + Some(PageCursor::AppServer(AppServerPageCursor::Global(None))) + ); + assert_eq!( + next_app_server_page_cursor(AppServerPageScope::Local, Some(String::from("cursor-2"))), + Some(PageCursor::AppServer(AppServerPageCursor::Local(Some( + String::from("cursor-2") + )))) + ); + } + + #[test] + fn row_matches_local_cwd_returns_false_without_filter_cwd() { let row = Row { path: None, preview: String::from("remote session"), @@ -1906,7 +2098,9 @@ mod tests { git_branch: None, }; - assert!(state.row_matches_filter(&row)); + assert!(!PickerState::row_matches_local_cwd( + &row, /*filter_cwd*/ None + )); } #[test] @@ -1922,7 +2116,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -1967,7 +2161,7 @@ mod tests { state.scroll_top = 0; state.update_view_rows(/*rows*/ 3); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true); let width: u16 = 80; let height: u16 = 6; @@ -2000,7 +2194,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2236,7 +2430,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2273,7 +2467,7 @@ mod tests { state.update_thread_names().await; - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + let metrics = calculate_column_metrics(&state.filtered_rows, /*include_cwd*/ true); let width: u16 = 80; let height: u16 = 5; @@ -2317,7 +2511,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2354,7 +2548,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2423,7 +2617,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2504,7 +2698,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2534,7 +2728,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2582,7 +2776,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2622,7 +2816,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2692,7 +2886,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); @@ -2740,7 +2934,7 @@ mod tests { FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, + SessionPickerOrder::GlobalTime, /*filter_cwd*/ None, SessionPickerAction::Resume, ); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb..58dee0da0 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -27,6 +27,8 @@ pub enum SlashCommand { New, Resume, Fork, + Thread, + Profile, Init, Compact, Plan, @@ -37,6 +39,7 @@ pub enum SlashCommand { Copy, Mention, Status, + Insight, DebugConfig, Title, Statusline, @@ -44,6 +47,8 @@ pub enum SlashCommand { Mcp, Apps, Plugins, + Workflow, + Btw, Logout, Quit, Exit, @@ -56,6 +61,7 @@ pub enum SlashCommand { Personality, Realtime, Settings, + Clawbot, TestApproval, #[strum(serialize = "subagents")] MultiAgents, @@ -79,6 +85,8 @@ impl SlashCommand { SlashCommand::Resume => "resume a saved chat", SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", + SlashCommand::Thread => "open thread actions for the current conversation", + SlashCommand::Profile => "show and switch routed API profiles", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -86,19 +94,21 @@ impl SlashCommand { SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Insight => "analyze local sessions and generate an HTML insight report", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", SlashCommand::Theme => "choose a syntax highlighting theme", - SlashCommand::Ps => "list background terminals", - SlashCommand::Stop => "stop all background terminals", + SlashCommand::Ps => "list background tasks", + SlashCommand::Stop => "stop all background tasks", SlashCommand::MemoryDrop => "DO NOT USE", SlashCommand::MemoryUpdate => "DO NOT USE", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Realtime => "toggle realtime voice mode (experimental)", - SlashCommand::Settings => "configure realtime microphone/speaker", + SlashCommand::Settings => "configure UI visibility and realtime devices", + SlashCommand::Clawbot => "manage workspace-local Feishu bridge and bindings", SlashCommand::Plan => "switch to Plan mode", SlashCommand::Collab => "change collaboration mode (experimental)", SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", @@ -112,6 +122,8 @@ impl SlashCommand { SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Apps => "manage apps", SlashCommand::Plugins => "browse plugins", + SlashCommand::Workflow => "inspect and trigger workspace workflows", + SlashCommand::Btw => "run a hidden read-only temporary discussion", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -132,6 +144,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Fast + | SlashCommand::Btw | SlashCommand::SandboxReadRoot ) } @@ -155,6 +168,7 @@ impl SlashCommand { | SlashCommand::Experimental | SlashCommand::Review | SlashCommand::Plan + | SlashCommand::Btw | SlashCommand::Clear | SlashCommand::Logout | SlashCommand::MemoryDrop @@ -165,12 +179,16 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Status + | SlashCommand::Insight | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Plugins + | SlashCommand::Workflow + | SlashCommand::Thread + | SlashCommand::Profile | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, @@ -178,6 +196,7 @@ impl SlashCommand { SlashCommand::TestApproval => true, SlashCommand::Realtime => true, SlashCommand::Settings => true, + SlashCommand::Clawbot => true, SlashCommand::Collab => true, SlashCommand::Agent | SlashCommand::MultiAgents => true, SlashCommand::Statusline => false, diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap new file mode 100644 index 000000000..6dec9134c --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__workflow_controls_popup.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app.rs +expression: popup +--- + Workflow + Manage workflow files, jobs, and triggers directly. + + Type to search workflows +› Background Tasks Insert a background task snapshot into the transcript. /ps + shows the same live workflow state. + director - edit yaml Open manual.yaml in your editor. + director - job - summarize Workflow job + director - job - notify Workflow job + director - trigger - review_backlog Manual · Enabled + director - trigger - triage Manual · Enabled + director - trigger - followup After Turn · Enabled + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap new file mode 100644 index 000000000..cb5ba1bc6 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__display_preferences_menu__tests__display_preferences_menu.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/display_preferences_menu.rs +expression: render_lines(&view) +--- + + UI + These settings only affect local TUI renderi + +› 1. Hide Startup Tooltips Currently visible. + Hide first-run and + local startup + tooltip hints in + this TUI. + 2. Show Raw Thinking Currently hidden. + Reveal raw + reasoning text in + this TUI only. + 3. Hide Tool Activity Currently visible. + Hide tool calls + and result details + in transcript + cells. + 4. Hide Patch Diffs Currently visible. + Collapse patch/ + edit diff + summaries in + transcript cells. + + Model context and persisted rollout history + are unchanged. + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/text_formatting.rs b/codex-rs/tui/src/text_formatting.rs index ae93890bb..a89f392eb 100644 --- a/codex-rs/tui/src/text_formatting.rs +++ b/codex-rs/tui/src/text_formatting.rs @@ -284,7 +284,7 @@ pub(crate) fn center_truncate_path(path: &str, max_width: usize) -> String { } }; - for (left_count, right_count) in prioritized.into_iter().chain(fallback.into_iter()) { + for (left_count, right_count) in prioritized.into_iter().chain(fallback) { let mut segments: Vec> = raw_segments[..left_count] .iter() .map(|seg| Segment { diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index cacb94b8e..7b57b93cb 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -123,11 +123,13 @@ pub(crate) mod announcement { use crate::version::CODEX_CLI_VERSION; use chrono::NaiveDate; use chrono::Utc; + use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use regex_lite::Regex; use serde::Deserialize; use std::sync::OnceLock; use std::thread; use std::time::Duration; + use tracing::warn; static ANNOUNCEMENT_TIP: OnceLock> = OnceLock::new(); @@ -177,16 +179,40 @@ pub(crate) mod announcement { fn blocking_init_announcement_tip() -> Option { // Avoid system proxy detection to prevent macOS system-configuration panics (#8912). - let client = reqwest::blocking::Client::builder() - .no_proxy() - .build() - .ok()?; - let response = client + ensure_rustls_crypto_provider(); + + let client = match reqwest::blocking::Client::builder().no_proxy().build() { + Ok(client) => client, + Err(error) => { + warn!(error = %error, "failed to build announcement tooltip HTTP client"); + return None; + } + }; + let response = match client .get(ANNOUNCEMENT_TIP_URL) .timeout(Duration::from_millis(2000)) .send() - .ok()?; - response.error_for_status().ok()?.text().ok() + { + Ok(response) => response, + Err(error) => { + warn!(error = %error, url = ANNOUNCEMENT_TIP_URL, "failed to fetch announcement tooltip"); + return None; + } + }; + let response = match response.error_for_status() { + Ok(response) => response, + Err(error) => { + warn!(error = %error, url = ANNOUNCEMENT_TIP_URL, "announcement tooltip request returned error status"); + return None; + } + }; + match response.text() { + Ok(text) => Some(text), + Err(error) => { + warn!(error = %error, url = ANNOUNCEMENT_TIP_URL, "failed to read announcement tooltip response body"); + None + } + } } pub(crate) fn parse_announcement_tip_toml(text: &str) -> Option { diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 4db63f7f6..fbd2fc024 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -1,3 +1,31 @@ # Slash commands For an overview of Codex CLI slash commands, see [this documentation](https://developers.openai.com/codex/cli/slash-commands). + +For TUI workflow management with `/workflow`, see [Workflows](workflows.md). + +## `/insight` + +`/insight` scans local Codex session rollouts and writes an offline HTML analysis report to `~/.codex/reports/`. + +What it includes: + +- active + archived local sessions +- main threads + sub-agent threads +- roll-up from child threads into the parent/root session, with per-thread drill-down kept in the report +- token usage, wall-clock span, exec/tool/patch counts, and failure counts +- exact metrics where the rollout history persists them directly +- estimated timing breakdowns where historical rollout data only supports approximation + +Report behavior: + +- output is a single self-contained HTML file +- report is readable offline with no external assets +- dashboard sections come first, followed by deeper per-session drill-down +- common patterns and efficiency suggestions fall back to local heuristics when an AI summary layer is unavailable + +Notes on timing precision: + +- exec command, MCP tool, and dynamic tool durations are exact when present in the rollout +- wall-clock session spans are derived from persisted rollout timestamps +- model time and user-wait time are reported as estimates for historical sessions, because older rollout data does not persist a complete end-to-end timing decomposition for every turn diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 000000000..7cf15806b --- /dev/null +++ b/docs/workflows.md @@ -0,0 +1,151 @@ +# Workflows + +Codex TUI can load workflow definitions from `.codex/workflows/*.yaml` and manage them directly from the `/workflow` menu. + +## Files + +- Workflow files live under `.codex/workflows/`. +- If no workflow file exists yet, `/workflow` offers `Create workflow.yaml`. +- Creating the template writes a starter file and opens it in the configured external editor. + +## Root Menu + +Running `/workflow` opens a flattened menu. Each workflow contributes entries in this shape: + +- ` - edit yaml` +- ` - job - ` +- ` - trigger - ` + +The root menu also shows: + +- `Background Tasks` + +`Background Tasks` shows the same running and queued workflow state as `/ps`. + +## Workflow File Entry + +` - edit yaml` opens the real workflow file in the external editor. + +This is the fastest path for: + +- creating or removing jobs +- creating or removing triggers +- repairing invalid YAML +- editing fields that are not exposed as structured menu actions + +## Job Entry + +` - job - ` opens the job management page. + +The job page can: + +- `Run Now` +- enable or disable the job flag in YAML +- edit common structured fields such as `context`, `response`, `needs`, and `steps` +- open the workflow YAML directly + +Behavior notes: + +- `Run Now` still works even if the job has `enabled: false`. +- Job `enabled: false` only affects workflow-controlled selection logic. It does not block an explicit manual `Run Now` from the menu. + +## Trigger Entry + +` - trigger - ` opens the trigger management page. + +The trigger page can: + +- `Run Now` +- `Enable Trigger` or `Disable Trigger` +- change `Type` +- edit `Trigger ID` +- edit `Target Jobs` +- edit the trigger-specific parameter +- open the workflow YAML directly + +Behavior notes: + +- Trigger `enabled: false` disables the trigger itself. +- A disabled trigger cannot be started from `Run Now` until it is enabled again. +- `Run Now` is available for any enabled trigger type, not only `manual`. +- If a trigger resolves only to disabled or otherwise unrunnable jobs, the run fails visibly instead of silently doing nothing. +- `After Turn` runs are dispatched as background workflow tasks after the turn finishes, so the main thread stays responsive and the transcript shows workflow start/completion cells separately. +- `response: user` follow-ups can recursively re-trigger `after_turn`. The chain naturally stops when the workflow returns an empty reply, because no follow-up turn is queued. +- Workflow steps default to a 30s timeout. Override this per step with `timeout`, for example `timeout: 5m`. +- Timeout failures participate in workflow step retry behavior, including one automatic timeout retry. + +## Trigger Types + +The `Type` picker supports: + +- `Manual` +- `Before Turn` +- `After Turn` +- `File Watch` +- `Idle` +- `Interval` +- `Cron` + +Changing the type updates the structured trigger fields in YAML: + +- `File Watch` watches the current workspace recursively and fires when a regular file or a directory changes +- `Idle` uses `after` +- `Interval` uses `every` +- `Cron` uses `cron` +- `Manual`, `Before Turn`, `After Turn`, and `File Watch` do not require an extra schedule parameter + +When the current type has a parameter, the trigger page exposes a matching action: + +- `Edit Idle Delay` +- `Edit Interval` +- `Edit Cron Schedule` + +Behavior notes: + +- `File Watch` uses the same global trigger queue as the other trigger types. +- If the same `file_watch` trigger is already running or already queued, new matching file events are skipped (`overlap=skip`) instead of enqueueing duplicates. + +## Typical Flow + +1. Run `/workflow`. +2. Pick a flattened root entry such as `director - trigger - review_backlog`. +3. Use structured actions for small changes like enable/disable, `Run Now`, type changes, or parameter edits. +4. Use `edit yaml` when the change is broader than the structured menu supports. + +## Example + +Given this workflow: + +```yaml +name: director + +triggers: + - id: pulse + type: interval + every: 30m + enabled: true + jobs: [notify] + +jobs: + notify: + enabled: false + context: ephemeral + response: assistant + steps: + - prompt: | + Send a concise update. + timeout: 2m +``` + +The root menu includes: + +- `director - edit yaml` +- `director - job - notify` +- `director - trigger - pulse` + +From `director - trigger - pulse`, you can: + +- run it immediately +- disable the trigger +- switch `interval` to `idle` +- change the parameter from `every: 30m` to `after: 30m` diff --git a/sdk/python-runtime-enhanced/README.md b/sdk/python-runtime-enhanced/README.md new file mode 100644 index 000000000..a08425cfd --- /dev/null +++ b/sdk/python-runtime-enhanced/README.md @@ -0,0 +1,33 @@ +# Codex Enhanced + +`codex-enhanced` packages the Codex CLI as a platform-specific Python wheel. +It is intended for users who want to install the enhanced CLI with `pip` and +run it directly without managing a separate native binary release. + +Website: `https://codex-enhanced.com` + +## Highlights + +- Loop automation with clear context modes: + - `embed`: submit the loop prompt into the main thread as a normal user turn + - `ephemeral`: fork compacted context for one run, then discard it + - `persistent`: keep a private retained context and refresh it with recent + main-thread messages +- Feishu clawbot integration for bound sessions, automatic inbound handling, and + plain-text outbound replies +- Fast `respawn` support so the CLI can restart and resume the current session + +## Install + +```bash +pip install codex-enhanced +``` + +## Run + +```bash +codex-enhanced +``` + +Each wheel includes the native `codex` binary for its target platform. This +package is wheel-only and is not intended to publish a source distribution. diff --git a/sdk/python-runtime-enhanced/hatch_build.py b/sdk/python-runtime-enhanced/hatch_build.py new file mode 100644 index 000000000..6f34d58ea --- /dev/null +++ b/sdk/python-runtime-enhanced/hatch_build.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from packaging.tags import sys_tags + + +class RuntimeBuildHook(BuildHookInterface): + def initialize(self, version: str, build_data: dict[str, object]) -> None: + del version + if self.target_name == "sdist": + raise RuntimeError( + "codex-enhanced is wheel-only; build and publish platform wheels only." + ) + + platform_tag = next(sys_tags()).platform + build_data["pure_python"] = False + build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/sdk/python-runtime-enhanced/pyproject.toml b/sdk/python-runtime-enhanced/pyproject.toml new file mode 100644 index 000000000..b631d6e01 --- /dev/null +++ b/sdk/python-runtime-enhanced/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "codex-enhanced" +version = "0.1.25" +description = "Enhanced Codex CLI with loop automation, Feishu clawbot support, and fast session respawn" +readme = "README.md" +requires-python = ">=3" +license = { text = "Apache-2.0" } +authors = [{ name = "Piping" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", +] + +[project.urls] +Homepage = "https://codex-enhanced.com" +Repository = "https://github.com/Piping/codex-enhanced" +Issues = "https://github.com/Piping/codex-enhanced/issues" + +[project.scripts] +codex-enhanced = "codex_enhanced.__main__:main" + +[tool.hatch.build] +exclude = [ + ".venv/**", + ".pytest_cache/**", + "dist/**", + "build/**", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/codex_enhanced"] +include = ["src/codex_enhanced/bin/**"] + +[tool.hatch.build.targets.wheel.hooks.custom] + +[tool.hatch.build.targets.sdist] + +[tool.hatch.build.targets.sdist.hooks.custom] diff --git a/sdk/python-runtime-enhanced/src/codex_enhanced/__init__.py b/sdk/python-runtime-enhanced/src/codex_enhanced/__init__.py new file mode 100644 index 000000000..a640f6b15 --- /dev/null +++ b/sdk/python-runtime-enhanced/src/codex_enhanced/__init__.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import os +from pathlib import Path + +PACKAGE_NAME = "codex-enhanced" + + +def bundled_codex_path() -> Path: + exe = "codex.exe" if os.name == "nt" else "codex" + path = Path(__file__).resolve().parent / "bin" / exe + if not path.is_file(): + raise FileNotFoundError( + f"{PACKAGE_NAME} is installed but missing its packaged codex binary at {path}" + ) + return path + + +__all__ = ["PACKAGE_NAME", "bundled_codex_path"] diff --git a/sdk/python-runtime-enhanced/src/codex_enhanced/__main__.py b/sdk/python-runtime-enhanced/src/codex_enhanced/__main__.py new file mode 100644 index 000000000..0f2300f00 --- /dev/null +++ b/sdk/python-runtime-enhanced/src/codex_enhanced/__main__.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import os +import subprocess +import sys + +from . import bundled_codex_path + + +def main() -> int: + codex_path = bundled_codex_path() + argv = [str(codex_path), *sys.argv[1:]] + if os.name != "nt": + os.execv(argv[0], argv) + raise AssertionError("os.execv returned unexpectedly") + + return subprocess.call(argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 6685fd099..3507bdf15 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -26,8 +26,20 @@ def sdk_root() -> Path: return repo_root() / "sdk" / "python" -def python_runtime_root() -> Path: - return repo_root() / "sdk" / "python-runtime" +def python_runtime_root(runtime_package: str = "default") -> Path: + if runtime_package == "default": + return repo_root() / "sdk" / "python-runtime" + if runtime_package == "enhanced": + return repo_root() / "sdk" / "python-runtime-enhanced" + raise RuntimeError(f"Unsupported runtime package: {runtime_package}") + + +def runtime_python_package_dir(runtime_package: str = "default") -> str: + if runtime_package == "default": + return "codex_cli_bin" + if runtime_package == "enhanced": + return "codex_enhanced" + raise RuntimeError(f"Unsupported runtime package: {runtime_package}") def schema_bundle_path() -> Path: @@ -53,8 +65,14 @@ def runtime_binary_name() -> str: return "codex.exe" if _is_windows() else "codex" -def staged_runtime_bin_path(root: Path) -> Path: - return root / "src" / "codex_cli_bin" / "bin" / runtime_binary_name() +def staged_runtime_bin_path(root: Path, runtime_package: str = "default") -> Path: + return ( + root + / "src" + / runtime_python_package_dir(runtime_package) + / "bin" + / runtime_binary_name() + ) def run(cmd: list[str], cwd: Path) -> None: @@ -141,16 +159,19 @@ def stage_python_sdk_package( def stage_python_runtime_package( - staging_dir: Path, runtime_version: str, binary_path: Path + staging_dir: Path, + runtime_version: str, + binary_path: Path, + runtime_package: str = "default", ) -> Path: - _copy_package_tree(python_runtime_root(), staging_dir) + _copy_package_tree(python_runtime_root(runtime_package), staging_dir) pyproject_path = staging_dir / "pyproject.toml" pyproject_path.write_text( _rewrite_project_version(pyproject_path.read_text(), runtime_version) ) - out_bin = staged_runtime_bin_path(staging_dir) + out_bin = staged_runtime_bin_path(staging_dir, runtime_package) out_bin.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(binary_path, out_bin) if not _is_windows(): @@ -559,7 +580,7 @@ class PublicFieldSpec: class CliOps: generate_types: Callable[[], None] stage_python_sdk_package: Callable[[Path, str, str], Path] - stage_python_runtime_package: Callable[[Path, str, Path], Path] + stage_python_runtime_package: Callable[[Path, str, Path, str], Path] current_sdk_version: Callable[[], str] @@ -954,6 +975,12 @@ def build_parser() -> argparse.ArgumentParser: required=True, help="Version to write into the staged runtime package", ) + stage_runtime_parser.add_argument( + "--runtime-package", + choices=["default", "enhanced"], + default="default", + help="Runtime package template to stage", + ) return parser @@ -985,6 +1012,7 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None: args.staging_dir, args.runtime_version, args.runtime_binary.resolve(), + args.runtime_package, ) diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index b19dc745a..690fff033 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -263,6 +263,29 @@ def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text() +def test_stage_enhanced_runtime_release_copies_binary_and_sets_version( + tmp_path: Path, +) -> None: + script = _load_update_script_module() + fake_binary = tmp_path / script.runtime_binary_name() + fake_binary.write_text("fake codex\n") + + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + fake_binary, + "enhanced", + ) + + assert staged == tmp_path / "runtime-stage" + assert ( + script.staged_runtime_bin_path(staged, "enhanced").read_text() == "fake codex\n" + ) + pyproject = (staged / "pyproject.toml").read_text() + assert 'name = "codex-enhanced"' in pyproject + assert 'version = "1.2.3"' in pyproject + + def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> None: script = _load_update_script_module() staging_dir = tmp_path / "runtime-stage" @@ -329,7 +352,10 @@ def fake_stage_sdk_package( return tmp_path / "sdk-stage" def fake_stage_runtime_package( - _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + _staging_dir: Path, + _runtime_version: str, + _runtime_binary: Path, + _runtime_package: str, ) -> Path: raise AssertionError("runtime staging should not run for stage-sdk") @@ -372,7 +398,10 @@ def fake_stage_sdk_package( raise AssertionError("sdk staging should not run for stage-runtime") def fake_stage_runtime_package( - _staging_dir: Path, _runtime_version: str, _runtime_binary: Path + _staging_dir: Path, + _runtime_version: str, + _runtime_binary: Path, + _runtime_package: str, ) -> Path: calls.append("stage_runtime") return tmp_path / "runtime-stage" @@ -392,6 +421,55 @@ def fake_current_sdk_version() -> str: assert calls == ["stage_runtime"] +def test_stage_enhanced_runtime_passes_package_choice(tmp_path: Path) -> None: + script = _load_update_script_module() + fake_binary = tmp_path / script.runtime_binary_name() + fake_binary.write_text("fake codex\n") + calls: list[tuple[str, str]] = [] + args = script.parse_args( + [ + "stage-runtime", + str(tmp_path / "runtime-stage"), + str(fake_binary), + "--runtime-version", + "1.2.3", + "--runtime-package", + "enhanced", + ] + ) + + def fake_generate_types() -> None: + raise AssertionError("type generation should not run for stage-runtime") + + def fake_stage_sdk_package( + _staging_dir: Path, _sdk_version: str, _runtime_version: str + ) -> Path: + raise AssertionError("sdk staging should not run for stage-runtime") + + def fake_stage_runtime_package( + _staging_dir: Path, + _runtime_version: str, + _runtime_binary: Path, + runtime_package: str, + ) -> Path: + calls.append(("stage_runtime", runtime_package)) + return tmp_path / "runtime-stage" + + def fake_current_sdk_version() -> str: + return "0.2.0" + + ops = script.CliOps( + generate_types=fake_generate_types, + stage_python_sdk_package=fake_stage_sdk_package, + stage_python_runtime_package=fake_stage_runtime_package, + current_sdk_version=fake_current_sdk_version, + ) + + script.run_command(args, ops) + + assert calls == [("stage_runtime", "enhanced")] + + def test_default_runtime_is_resolved_from_installed_runtime_package( tmp_path: Path, ) -> None: