From 0fadf701b049f9bc369a5f7c81930883265d52a8 Mon Sep 17 00:00:00 2001 From: piping Date: Thu, 26 Mar 2026 17:58:17 +0800 Subject: [PATCH 01/25] chore: add enhanced workflows and fork docs --- .github/workflows/enhanced-release.yml | 189 ++++++++++++++ .github/workflows/rust-release.yml | 6 +- README.md | 160 ++++++++---- codex-rs/.cargo/config.toml | 8 + codex-rs/README.md | 1 + .../docs/codex_component_evolution.drawio | 61 +++++ .../docs/codex_runtime_stack_swimlanes.drawio | 154 ++++++++++++ codex-rs/docs/codex_tool_design_principles.md | 238 ++++++++++++++++++ docs/contributing.md | 2 +- docs/fork-extension-mvp.md | 70 ++++++ docs/install.md | 7 + justfile | 20 ++ 12 files changed, 865 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/enhanced-release.yml create mode 100644 codex-rs/docs/codex_component_evolution.drawio create mode 100644 codex-rs/docs/codex_runtime_stack_swimlanes.drawio create mode 100644 codex-rs/docs/codex_tool_design_principles.md create mode 100644 docs/fork-extension-mvp.md diff --git a/.github/workflows/enhanced-release.yml b/.github/workflows/enhanced-release.yml new file mode 100644 index 000000000..91b768641 --- /dev/null +++ b/.github/workflows/enhanced-release.yml @@ -0,0 +1,189 @@ +name: enhanced-release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + release_tag: + description: Existing tag to build and publish, for example v0.1.1 + required: true + 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 }} + 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" + + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ steps.meta.outputs.checkout_ref }} + + - uses: dtolnay/rust-toolchain@stable + + - name: Validate tag matches Cargo version + shell: bash + run: | + set -euo pipefail + cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml | sed -E 's/version *= *"([^"]+)".*/\1/')" + tag_ver="${{ steps.meta.outputs.release_version }}" + [[ "${tag_ver}" == "${cargo_ver}" ]] \ + || { echo "Tag version ${tag_ver} does not match Cargo.toml ${cargo_ver}."; exit 1; } + + build: + needs: prepare + name: build-${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + defaults: + run: + working-directory: codex-rs + env: + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_RELEASE_LTO: fat + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + target: aarch64-apple-darwin + archive_ext: tar.gz + archive_kind: tar + - runner: macos-15-intel + target: x86_64-apple-darwin + archive_ext: tar.gz + archive_kind: tar + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + archive_ext: tar.gz + archive_kind: tar + - runner: windows-2022 + target: x86_64-pc-windows-msvc + archive_ext: zip + archive_kind: zip + steps: + - name: Checkout release ref + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.checkout_ref }} + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - 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 + + - name: Build release binaries + shell: bash + run: | + set -euo pipefail + cargo build --locked --release --target "${{ matrix.target }}" --bin codex --bin codex-responses-api-proxy + + - name: Package unix artifacts + if: ${{ matrix.archive_kind == 'tar' }} + shell: bash + run: | + set -euo pipefail + stage_dir="dist/${{ matrix.target }}" + archive_name="codex-enhanced-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.${{ matrix.archive_ext }}" + + mkdir -p "${stage_dir}" + cp "target/${{ matrix.target }}/release/codex" "${stage_dir}/codex" + cp "target/${{ matrix.target }}/release/codex-responses-api-proxy" "${stage_dir}/codex-responses-api-proxy" + + tar -C "${stage_dir}" -czf "${archive_name}" codex codex-responses-api-proxy + + - name: Package windows artifacts + if: ${{ matrix.archive_kind == 'zip' }} + shell: pwsh + run: | + $stageDir = "dist/${{ matrix.target }}" + $archiveName = "codex-enhanced-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.${{ matrix.archive_ext }}" + + New-Item -ItemType Directory -Force -Path $stageDir | Out-Null + Copy-Item "target/${{ matrix.target }}/release/codex.exe" "$stageDir/codex.exe" + Copy-Item "target/${{ matrix.target }}/release/codex-responses-api-proxy.exe" "$stageDir/codex-responses-api-proxy.exe" + + Compress-Archive -Path "$stageDir/codex.exe", "$stageDir/codex-responses-api-proxy.exe" -DestinationPath $archiveName -Force + + - name: Upload packaged artifact + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.target }} + path: codex-rs/codex-enhanced-${{ needs.prepare.outputs.release_version }}-${{ matrix.target }}.${{ matrix.archive_ext }} + if-no-files-found: error + + release: + needs: + - prepare + - build + 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 packaged artifacts + uses: actions/download-artifact@v4 + with: + pattern: release-* + path: release-artifacts + merge-multiple: true + + - name: Write release notes from tag commit message + shell: bash + run: | + set -euo pipefail + commit="$(git rev-parse "${{ needs.prepare.outputs.checkout_ref }}^{commit}")" + git log -1 --format=%B "${commit}" > release-notes.md + echo >> release-notes.md + + - name: Publish GitHub Release assets + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.prepare.outputs.release_tag }} + name: Codex Enhanced ${{ needs.prepare.outputs.release_version }} + body_path: release-notes.md + files: release-artifacts/* + fail_on_unmatched_files: true + prerelease: ${{ contains(needs.prepare.outputs.release_version, '-') }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1ec9bd28b..8d8eac4ee 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -57,9 +57,7 @@ jobs: run: working-directory: codex-rs env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} + CARGO_PROFILE_RELEASE_LTO: fat strategy: fail-fast: false @@ -386,7 +384,7 @@ jobs: needs: tag-check uses: ./.github/workflows/rust-release-windows.yml with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} + release-lto: fat secrets: inherit argument-comment-lint-release-assets: diff --git a/README.md b/README.md index 1e44875f2..1487e285c 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,128 @@ -

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.

- ---- - -## Quickstart - -### Installing and running Codex CLI - -Install globally with your preferred package manager: - -```shell -# Install using npm -npm install -g @openai/codex +# Codex Enhanced + +Codex Enhanced is a standalone public distribution of Codex focused on +multi-account ChatGPT operations, fullscreen TUI workflow improvements, and a +smaller long-term fork maintenance surface. + +This repository is maintained as its own GitHub project instead of a GitHub +fork. It tracks upstream Codex where practical, while keeping product-specific +behavior behind a dedicated extension layer so future changes can converge on +plugins instead of repeated invasive rebases. + +## Why This Version Exists + +Upstream Codex already provides a strong local coding agent. The main problem +this project solves is operational: + +- switching between multiple ChatGPT accounts should not require manual file + juggling +- rate-limit and usage-limit handling should be able to fail over to another + account automatically +- fullscreen TUI workflows should expose session and account operations in one + operator-facing control surface +- fork-specific behavior should move toward a stable extension boundary instead + of expanding the patch set in core runtime code + +## What Is Included + +- Managed account storage under `~/.codex/accounts` +- `codex login --auth ` for capturing multiple ChatGPT logins into named + account slots +- Account pool metadata with stable IDs, aliases, cooldown state, and inferred + usage windows +- Threshold-based account routing and one-shot retry on explicit + limit/rejection failures for normal user turns in the fullscreen TUI path +- A `Ctrl-P` control panel with: + - global session picker + - account selection + - alias rename submenu + - current-session fork entry point +- A dedicated `codex-rs/ext` crate for fork-owned extension state and host + compatibility groundwork + +## Current Scope + +The current milestone is a practical MVP for daily use, not the final extension +architecture. + +Implemented now: + +- managed ChatGPT account registry and auth snapshot layout +- account activation and alias management in the TUI +- control-panel-driven session and account operations +- inferred cooldown recording from explicit limit errors +- login-time account registration + +Planned next: + +- broader automatic account routing coverage beyond the current fullscreen TUI + path +- observable switch reasons and richer operator status views +- hook/interceptor expansion +- capability-negotiated WASM plugins built on top of `codex-ext` + +## Repository Layout + +- [codex-rs/ext](./codex-rs/ext) + Fork-owned extension crate for account pool state, auth snapshots, and future + plugin host compatibility. +- [codex-rs/tui](./codex-rs/tui) + Fullscreen local TUI implementation. +- [codex-rs/tui_app_server](./codex-rs/tui_app_server) + App-server-backed TUI implementation that mirrors relevant UX changes. +- [docs/fork-extension-mvp.md](./docs/fork-extension-mvp.md) + Fork proposal, MVP design, and phased roadmap. + +## Build And Run + +Build the Rust CLI locally: + +```bash +cd codex-rs +cargo build -p codex-cli +./target/debug/codex ``` -```shell -# Install using Homebrew -brew install --cask codex -``` +Install it into your shell path: -Then simply run `codex` to get started. +```bash +cd codex-rs +cargo build --release -p codex-cli +sudo ln -sf "$(pwd)/target/release/codex" /usr/local/bin/codex +codex --help +``` -
-You can also go to the latest GitHub Release and download the appropriate binary for your platform. +## Managed Account Quick Start -Each GitHub Release contains many executables, but in practice, you likely want one of these: +Register multiple ChatGPT logins into the managed account pool: -- 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` +```bash +codex login --auth primary +codex login --auth backup +codex login status +``` -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. +Start Codex, then use: -
+- `Ctrl-P -> Sessions` to open the global session picker +- `Ctrl-P -> Accounts` to switch the active managed account +- `Ctrl-P -> Accounts -> Rename` to rename account aliases -### Using Codex with your ChatGPT plan +Managed account state is stored under: -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). +```text +~/.codex/accounts/ +├── account-pool.json +└── / + └── auth.json +``` -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). +## Upstream Relationship -## Docs +This project is based on OpenAI Codex and keeps upstream history so changes can +be rebased and audited cleanly. The maintenance goal is to keep the fork-owned +delta small, explicit, and increasingly isolated behind `codex-ext`. -- [**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). +This repository remains licensed under the [Apache-2.0 License](LICENSE). diff --git a/codex-rs/.cargo/config.toml b/codex-rs/.cargo/config.toml index 5d5eb8fd6..37cd38c6d 100644 --- a/codex-rs/.cargo/config.toml +++ b/codex-rs/.cargo/config.toml @@ -1,3 +1,11 @@ +[build] +jobs = 8 + +[target.'cfg(target_os = "macos")'] +# Stable Cargo can't select a linker by profile, so use ld64.lld for macOS +# target builds, which includes local `cargo test` runs. +rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/opt/lld/bin/ld64.lld"] + [target.'cfg(all(windows, target_env = "msvc"))'] rustflags = ["-C", "link-arg=/STACK:8388608"] diff --git a/codex-rs/README.md b/codex-rs/README.md index ad8a0506f..64c2d5115 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -17,6 +17,7 @@ You can also install via Homebrew (`brew install --cask codex`) or download a pl - First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management). - Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md). +- Looking for Rust-workspace design notes? See [`docs/codex_mcp_interface.md`](./docs/codex_mcp_interface.md), [`docs/codex_tool_design_principles.md`](./docs/codex_tool_design_principles.md), and the component evolution diagram in [`docs/codex_component_evolution.drawio`](./docs/codex_component_evolution.drawio). ## What's new in the Rust CLI diff --git a/codex-rs/docs/codex_component_evolution.drawio b/codex-rs/docs/codex_component_evolution.drawio new file mode 100644 index 000000000..efc2ad8cb --- /dev/null +++ b/codex-rs/docs/codex_component_evolution.drawio @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/codex-rs/docs/codex_runtime_stack_swimlanes.drawio b/codex-rs/docs/codex_runtime_stack_swimlanes.drawio new file mode 100644 index 000000000..e968db112 --- /dev/null +++ b/codex-rs/docs/codex_runtime_stack_swimlanes.drawio @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/codex-rs/docs/codex_tool_design_principles.md b/codex-rs/docs/codex_tool_design_principles.md new file mode 100644 index 000000000..71adf4bcc --- /dev/null +++ b/codex-rs/docs/codex_tool_design_principles.md @@ -0,0 +1,238 @@ +# Codex Tool Design Principles [proposal] + +Status: proposal + +This document proposes a default design stance for Codex tools, based on direct user feedback gathered through structured prompts. It is intended to guide future tool design, tool API evolution, and approval UX decisions across Codex surfaces. + +## Goals + +Codex tools should optimize for the following, in order: + +1. Reliable task completion. +2. Strong traceability and execution evidence. +3. Low-friction operation with bounded safety controls. +4. Extensibility without excessive schema or UX complexity. + +The proposal explicitly does not optimize for maximum autonomy at any cost, nor for fully manual operation. The preferred stance is a balanced default with clear risk boundaries. + +## Core principles + +### 1. Prioritize task completion over tool purity + +Tools exist to help Codex complete user work, not to preserve a minimal or elegant abstraction at the expense of success rate. A tool system that is slightly redundant but materially more reliable is preferable to a perfectly uniform system that fails in real workflows. + +### 2. Use a mixed abstraction stack + +Codex should expose both: + +- low-level primitives such as command execution, patch application, browsing, and structured questioning +- a limited set of higher-level capabilities where the workflow is stable and repeatedly valuable + +This avoids forcing every task through brittle high-level tools while still leaving room for opinionated workflow helpers. + +### 3. Prefer medium-granularity tool boundaries + +Tool sets should avoid both extremes: + +- too few tools, where each tool becomes overloaded and hard to steer +- too many tools, where discovery, selection, and maintenance become noisy + +A medium-granularity tool catalog is preferred, with clear responsibilities and predictable calling conventions. + +### 4. Keep schemas compact, but enforce strong conventions + +Tool APIs should generally prefer semi-structured inputs and outputs: + +- keep core fields explicit and typed +- allow flexible text where rigid structure adds little value +- avoid repeating long schema descriptions that inflate token usage + +Short descriptions plus strong naming conventions are preferred over verbose per-call schema payloads. + +### 5. Default to balanced autonomy + +The preferred default is: + +- routine, low-risk work proceeds automatically +- higher-risk work requires approval +- obviously dangerous actions are hard-blocked rather than delegated to model judgment + +This keeps the system usable without normalizing destructive or unverifiable behavior. + +## Approval and safety model + +### 6. Favor preauthorization over repetitive prompts + +When approval is required, the preferred model is scoped preauthorization rather than repeated per-command interruptions. Command-prefix-based authorization is a strong default because it maps well to practical workflows such as tests, builds, and diagnostics. + +Approval UX should be short and purpose-driven. The system should explain what it wants to do and why, without burying the user in unnecessary detail. + +### 7. Hard-block a small set of dangerous actions + +Some actions should not be left to ordinary approval flow. A narrow class of obviously dangerous operations should be blocked by policy, regardless of model confidence. + +### 8. Safety should be risk-layered, not uniformly restrictive + +The preferred tradeoff is not "safety first" in every case. It is capability-first within a risk-layered system: + +- low-risk operations should have low friction +- high-risk operations should have stronger controls +- unverifiable claims of execution should never be acceptable + +## Observability and trust + +### 9. Every tool call should be traceable + +Traceability is not optional. At minimum, the system should retain a per-call record of: + +- tool name +- high-level purpose +- key inputs +- timestamp +- result status +- failure reason when applicable + +### 10. Execution should carry lightweight proof + +When a tool performs an action, the default evidence bar should be lightweight but real. For command execution, the preferred baseline is: + +- exit code +- key output + +Where possible, tools should also point to external verification artifacts such as file changes, test results, or request logs. + +### 11. Progress updates should stay concise + +The preferred progress model is "key steps visible." Users generally want: + +- a short command or action summary +- a short result summary + +Progress should not devolve into log spam, but it also should not disappear until the end. + +## Tool behavior + +### 12. Failures should trigger limited self-healing + +When a tool fails, the preferred behavior is: + +1. Attempt a small, safe amount of self-recovery. +2. If recovery fails, report the failure with context. + +The system should avoid both extremes of infinite silent retries and immediate escalation for every routine hiccup. + +### 13. Retry semantics should be tool-declared + +Retryability should not be guessed blindly. Tools should explicitly declare whether they are safe to retry and under what conditions. The platform may still make the final retry decision, but the tool must provide the signal. + +### 14. Cancellation should support compensation where possible + +For long-running or side-effecting tools, cancellation should aim for more than best-effort termination. The preferred model is cancellation plus compensation or cleanup when feasible. + +### 15. Partial success should be a first-class result + +Tools should be able to return an explicit partial-success state rather than collapsing mixed outcomes into either "success" or "failure." This is especially important for multi-step operations and tool batches. + +## State, recovery, and compatibility + +### 16. Prefer lightweight short-term state + +Tools should primarily rely on current task context and lightweight short-term state. Long-term memory can exist, but it should be explicit and bounded rather than silently shaping every interaction. + +### 17. Resume should combine checkpoints and logs + +Long-running work should be recoverable through a hybrid of: + +- explicit checkpoints where useful +- event or call logs for replay and auditing + +### 18. Preserve strong API compatibility + +Tool APIs should evolve carefully. Backward compatibility is a strong preference, with migration paths when changes are unavoidable. + +## Tool-specific guidance + +### 19. `web` should make search a first-class capability + +The most important gap in web tooling is native search. Browsing alone is not enough. The preferred default search result shape is: + +- title +- short summary +- source + +Browser-style interaction can expand later, with session and cookie handling as an especially valuable follow-on capability. + +### 20. `question` should be used for constraints and decisions, not as a crutch + +`question` is best suited for: + +- collecting user constraints and preferences up front +- handling meaningful branch decisions mid-task + +It should not be used as a substitute for basic local context gathering. Repetitive questioning before inspecting the workspace erodes trust quickly. + +The most valuable near-term improvements to `question` are: + +- multi-select answers +- ranked multi-select +- default values +- basic validation for required fields and simple formats + +Conditional branching may be useful later, but is not required for an initial improved version. + +### 21. Subagents should stay explicitly user-authorized + +Delegation is valuable, but it should remain visible and intentional. The preferred model is a main agent orchestrator that uses subagents only after explicit user authorization. + +## Tool API guidance + +The following fields are strong candidates for a common tool contract: + +- `name` +- `description` +- `trigger_conditions` +- `dependencies` +- `timeout` +- `retry` +- `idempotent` +- `cancelable` +- `requires_approval` + +Tool cards or UI surfaces should, at minimum, expose: + +- name +- description +- trigger conditions +- dependencies + +Implementation details such as caching can remain mostly hidden unless they materially affect behavior. + +## Non-goals + +This proposal does not attempt to: + +- define a single universal schema for every tool +- require all tools to expose the same UI surface +- replace product-specific policy or sandbox constraints +- prescribe a complete browser automation model + +## Evaluation criteria + +A future tool or API change is aligned with this proposal if it tends to improve one or more of the following without materially regressing the others: + +- completion reliability +- user trust +- evidence and traceability +- approval ergonomics +- token efficiency +- extensibility + +## Next steps + +Potential follow-up work: + +1. Define a minimal shared tool metadata contract. +2. Redesign `web` around first-class search plus clearer result objects. +3. Expand `question` to support ranked multi-select and defaults. +4. Standardize per-call execution evidence and partial-success reporting. +5. Review approval flows for repeated same-class command prompts. diff --git a/docs/contributing.md b/docs/contributing.md index 19b31073e..73bb4d636 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -54,7 +54,7 @@ When a change updates model catalogs or model metadata (`/models` payloads, pres - Fill in the PR template (or include similar information) - **What? Why? How?** - Include a link to a bug report or enhancement request in the issue tracker -- Run **all** checks locally. Use the root `just` helpers so you stay consistent with the rest of the workspace: `just fmt`, `just fix -p ` for the crate you touched, and the relevant tests (e.g., `cargo test -p codex-tui` or `just test` if you need a full sweep). CI failures that could have been caught locally slow down the process. +- Run **all** checks locally. Use the root `just` helpers so you stay consistent with the rest of the workspace: `just fmt`, `just fix -p ` for the crate you touched, and the relevant tests (for the default lightweight pass, `just verify-fast`; for `/loop`-focused TUI work, `just verify-tui-loop`; for a targeted lightweight pass, `just verify-fast-crate -p `; for a full sweep, `just test`). CI failures that could have been caught locally slow down the process. - Make sure your branch is up-to-date with `main` and that you have resolved merge conflicts. - Mark the PR as **Ready for review** only when you believe it is in a merge-able state. diff --git a/docs/fork-extension-mvp.md b/docs/fork-extension-mvp.md new file mode 100644 index 000000000..6bda86e18 --- /dev/null +++ b/docs/fork-extension-mvp.md @@ -0,0 +1,70 @@ +# Fork Extension MVP + +## Proposal + +Maintain a forked `codex` binary while moving fork-specific behavior behind a +host-owned extension layer. The host stays small and explicit; policy and +product logic move into extension-facing data models and, later, WASM plugins. + +The first fork-owned target is multi-account ChatGPT routing with a fullscreen +control panel entry point. The fork should prefer additive crates and thin +integration shims over invasive edits to `codex-core`, `codex-tui`, or +`codex-tui-app-server`. + +## Design + +### Principles + +- Keep fork logic in a dedicated workspace crate: `codex-ext`. +- Treat host/plugin compatibility as capability negotiation, not lockstep API. +- Keep plugins behind host-controlled APIs so existing approval and sandbox + policies remain authoritative. +- Use the TUI as a consumer of extension state, not the source of truth. +- Route account switching above the provider layer, but below UI-specific code. + +### MVP Scope + +1. Add `codex-ext` with: + - host capability negotiation types + - persisted account-pool state under `CODEX_HOME/accounts/account-pool.json` + - persisted per-account auth snapshots under `CODEX_HOME/accounts//auth.json` + - a default threshold-based account router model + - login-time account snapshotting and alias-aware account registration +2. Add a `Ctrl-P` control panel entry point in both TUI implementations. +3. Show account-pool state in a read-only popup so the fork has a visible, + testable operator surface before auth switching is wired into requests. + +### Deferred + +- WASM runtime and ABI host +- plugin-owned TUI layouts +- undo-last-user-message implementation + +## Phased Todos + +### Phase 1 + +- Land `codex-ext` data model crate. +- Expose `Ctrl-P` control panel in both TUIs. +- Render account pool status from disk. + +### Phase 2 + +- Introduce fork-owned account registry alongside current single-auth storage. +- Add active-account selector and account alias management. +- Record inferred limit/cooldown signals from model errors. + +### Phase 3 + +- Route normal user turns through the default account router. +- Retry one normal user turn on explicit limit/rejection errors. +- Add `codex login --auth ` so browser/device-code login snapshots the + previous root auth and stores the new account under `accounts//`. +- Add observable switch reasons in the control panel. + +### Phase 4 + +- Add a WASM host with capability negotiation. +- Re-express the default account router as a built-in plugin module. +- Add hook/interceptor points for `AppStart`, `SessionStart`, + `BeforeTurnStart`, `BeforeToolCall`, and `AfterToolCall`. diff --git a/docs/install.md b/docs/install.md index b7d4f0711..67fab5895 100644 --- a/docs/install.md +++ b/docs/install.md @@ -39,6 +39,13 @@ cargo run --bin codex -- "explain this codebase to me" just fmt just fix -p +# For the default lightweight verification pass (tui, ext, cli): +just verify-fast +# For `/loop`-focused TUI edits, use the narrower loop path: +just verify-tui-loop +# Or target a specific crate/target explicitly: +just verify-fast-crate -p + # Run the relevant tests (project-specific is fastest), for example: cargo test -p codex-tui # If you have cargo-nextest installed, `just test` runs the test suite via nextest: diff --git a/justfile b/justfile index 5c9fa5e6a..31566cedf 100644 --- a/justfile +++ b/justfile @@ -51,6 +51,26 @@ install: test: cargo nextest run --no-fail-fast +# Run the default lightweight verification pass for tui, ext, and cli. +verify-fast: + cargo test -p codex-tui + cargo clippy --tests -p codex-tui + cargo test -p codex-ext + cargo clippy --tests -p codex-ext + cargo test -p codex-cli + cargo clippy --tests -p codex-cli + +# Run a lightweight verification pass for explicitly selected crates/targets. +verify-fast-crate *args: + cargo test "$@" + cargo clippy --tests "$@" + +# Run the narrow `/loop` edit-run verification path for codex-tui. +verify-tui-loop: + cargo check -p codex-tui --tests + cargo test -p codex-tui loop_timer_command + cargo test -p codex-tui loop_timers + # Build and run Codex from source using Bazel. # Note we have to use the combination of `[no-cd]` and `--run_under="cd $PWD &&"` # to ensure that Bazel runs the command in the current working directory. From 7e91e44ebe808c0bbf050aa3c5416d608c9f8055 Mon Sep 17 00:00:00 2001 From: piping Date: Thu, 26 Mar 2026 17:58:44 +0800 Subject: [PATCH 02/25] refactor: extract fork support crates --- codex-rs/Cargo.toml | 37 +- codex-rs/accounts/Cargo.toml | 23 + codex-rs/accounts/src/account_pool.rs | 478 ++++++++++++++++++ codex-rs/accounts/src/account_signal.rs | 136 +++++ codex-rs/accounts/src/lib.rs | 30 ++ codex-rs/accounts/src/managed_account_auth.rs | 271 ++++++++++ codex-rs/accounts/src/router.rs | 244 +++++++++ codex-rs/btw/Cargo.toml | 18 + codex-rs/btw/src/lib.rs | 187 +++++++ codex-rs/cli/Cargo.toml | 3 + codex-rs/ext/Cargo.toml | 26 + codex-rs/ext/README.md | 16 + codex-rs/ext/src/host_api.rs | 133 +++++ codex-rs/ext/src/lib.rs | 45 ++ codex-rs/ext/src/workspace_spawn.rs | 132 +++++ codex-rs/loop/Cargo.toml | 25 + codex-rs/loop/src/command.rs | 327 ++++++++++++ codex-rs/loop/src/execution.rs | 214 ++++++++ codex-rs/loop/src/lib.rs | 32 ++ codex-rs/loop/src/model.rs | 130 +++++ codex-rs/threadmessages/Cargo.toml | 18 + codex-rs/threadmessages/src/lib.rs | 123 +++++ codex-rs/tui/Cargo.toml | 8 +- codex-rs/tui_app_server/Cargo.toml | 8 +- 24 files changed, 2658 insertions(+), 6 deletions(-) create mode 100644 codex-rs/accounts/Cargo.toml create mode 100644 codex-rs/accounts/src/account_pool.rs create mode 100644 codex-rs/accounts/src/account_signal.rs create mode 100644 codex-rs/accounts/src/lib.rs create mode 100644 codex-rs/accounts/src/managed_account_auth.rs create mode 100644 codex-rs/accounts/src/router.rs create mode 100644 codex-rs/btw/Cargo.toml create mode 100644 codex-rs/btw/src/lib.rs create mode 100644 codex-rs/ext/Cargo.toml create mode 100644 codex-rs/ext/README.md create mode 100644 codex-rs/ext/src/host_api.rs create mode 100644 codex-rs/ext/src/lib.rs create mode 100644 codex-rs/ext/src/workspace_spawn.rs create mode 100644 codex-rs/loop/Cargo.toml create mode 100644 codex-rs/loop/src/command.rs create mode 100644 codex-rs/loop/src/execution.rs create mode 100644 codex-rs/loop/src/lib.rs create mode 100644 codex-rs/loop/src/model.rs create mode 100644 codex-rs/threadmessages/Cargo.toml create mode 100644 codex-rs/threadmessages/src/lib.rs diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f19fb650f..da4260914 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "analytics", + "accounts", "backend-client", "ansi-escape", "async-utils", @@ -30,6 +31,7 @@ members = [ "instructions", "secrets", "exec", + "ext", "exec-server", "execpolicy", "execpolicy-legacy", @@ -84,11 +86,14 @@ members = [ "package-manager", "plugin", "artifacts", + "btw", + "loop", + "threadmessages", ] resolver = "2" [workspace.package] -version = "0.0.0" +version = "0.1.7" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 @@ -99,10 +104,12 @@ license = "Apache-2.0" [workspace.dependencies] # Internal app_test_support = { path = "app-server/tests/common" } +codex-accounts = { path = "accounts" } codex-ansi-escape = { path = "ansi-escape" } codex-analytics = { path = "analytics" } codex-api = { path = "codex-api" } codex-artifacts = { path = "artifacts" } +codex-btw = { path = "btw" } codex-code-mode = { path = "code-mode" } codex-package-manager = { path = "package-manager" } codex-app-server = { path = "app-server" } @@ -122,6 +129,7 @@ codex-config = { path = "config" } codex-core = { path = "core" } codex-core-skills = { path = "core-skills" } codex-exec = { path = "exec" } +codex-ext = { path = "ext" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } @@ -135,6 +143,7 @@ codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } +codex-loop = { path = "loop" } codex-mcp-server = { path = "mcp-server" } codex-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } @@ -154,6 +163,7 @@ codex-state = { path = "state" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-test-macros = { path = "test-macros" } codex-terminal-detection = { path = "terminal-detection" } +codex-threadmessages = { path = "threadmessages" } codex-tui = { path = "tui" } codex-tui-app-server = { path = "tui_app_server" } codex-v8-poc = { path = "v8-poc" } @@ -402,15 +412,34 @@ ignored = [ "codex-v8-poc", ] +[profile.dev] +incremental = false +lto = false + +[profile.dev.package."*"] +incremental = false + +[profile.dev.build-override] +incremental = false + +[profile.test] +incremental = false + +[profile.test.package."*"] +incremental = false + +[profile.test.build-override] +incremental = false + [profile.release] -lto = "fat" +lto = false split-debuginfo = "off" # Because we bundle some of these executables with the TypeScript CLI, we # remove everything to make the binary as small as possible. strip = "symbols" -# See https://github.com/openai/codex/issues/1411 for details. -codegen-units = 1 +# Match local parallelism better to reduce release build time. +codegen-units = 12 [profile.ci-test] debug = 1 # Reduce debug symbol size diff --git a/codex-rs/accounts/Cargo.toml b/codex-rs/accounts/Cargo.toml new file mode 100644 index 000000000..4faf3c8a5 --- /dev/null +++ b/codex-rs/accounts/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "codex-accounts" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_accounts" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-core = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +base64 = { workspace = true } +codex-app-server-protocol = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/accounts/src/account_pool.rs b/codex-rs/accounts/src/account_pool.rs new file mode 100644 index 000000000..8de834b0d --- /dev/null +++ b/codex-rs/accounts/src/account_pool.rs @@ -0,0 +1,478 @@ +use serde::Deserialize; +use serde::Serialize; +use std::fs; +use std::io; +use std::path::PathBuf; + +use crate::account_signal::AccountLimitSignal; +use crate::account_signal::AccountRateLimitSnapshot; + +pub const ACCOUNT_POOL_STATE_RELATIVE_PATH: &str = "accounts/account-pool.json"; +const ACCOUNT_POOL_STATE_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountUsageWindowKind { + FiveHour, + Weekly, + Custom, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageEstimateSource { + Manual, + ResponseErrorInference, + LocalHeuristic, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountUsageWindow { + pub kind: AccountUsageWindowKind, + pub label: String, + pub estimated_used_units: u32, + pub estimated_limit_units: Option, + pub reset_at: Option, + pub source: UsageEstimateSource, +} + +impl AccountUsageWindow { + pub fn pressure_permille(&self) -> Option { + let limit = self.estimated_limit_units?; + if limit == 0 { + return None; + } + + let used = self.estimated_used_units.min(limit); + let permille = (u64::from(used) * 1000) / u64::from(limit); + u16::try_from(permille).ok() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRecord { + pub id: String, + pub alias: String, + pub masked_email: Option, + pub plan_label: Option, + pub priority: u32, + pub enabled: bool, + pub cooldown_until: Option, + pub last_limit_error_at: Option, + pub last_selected_at: Option, + pub usage_windows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountManagementProfile { + pub id: String, + pub alias: Option, + pub masked_email: Option, + pub plan_label: Option, + pub priority: Option, +} + +impl AccountRecord { + pub fn display_name(&self) -> &str { + if self.alias.trim().is_empty() { + &self.id + } else { + &self.alias + } + } + + pub fn is_available_at(&self, now_ts: i64) -> bool { + if !self.enabled { + return false; + } + + match self.cooldown_until { + Some(cooldown_until) => cooldown_until <= now_ts, + None => true, + } + } + + pub fn highest_pressure_permille(&self) -> Option { + self.usage_windows + .iter() + .filter_map(AccountUsageWindow::pressure_permille) + .max() + } + + pub fn usage_summary(&self) -> Option { + let windows: Vec = self + .usage_windows + .iter() + .map(|window| { + let prefix = match window.kind { + AccountUsageWindowKind::FiveHour => "5h", + AccountUsageWindowKind::Weekly => "week", + AccountUsageWindowKind::Custom => window.label.as_str(), + }; + match window.estimated_limit_units { + Some(limit) => format!("{prefix} {}/{}", window.estimated_used_units, limit), + None => format!("{prefix} {}", window.estimated_used_units), + } + }) + .collect(); + if windows.is_empty() { + None + } else { + Some(windows.join(" · ")) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountPoolState { + pub version: u32, + pub active_account_id: Option, + pub accounts: Vec, +} + +impl Default for AccountPoolState { + fn default() -> Self { + Self { + version: ACCOUNT_POOL_STATE_VERSION, + active_account_id: None, + accounts: Vec::new(), + } + } +} + +impl AccountPoolState { + pub fn upsert_account(&mut self, profile: AccountManagementProfile) -> bool { + if let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == profile.id) + { + let mut changed = false; + let next_alias = profile.alias.unwrap_or_else(|| account.alias.clone()); + if account.alias != next_alias { + account.alias = next_alias; + changed = true; + } + if account.masked_email != profile.masked_email { + account.masked_email = profile.masked_email; + changed = true; + } + if account.plan_label != profile.plan_label { + account.plan_label = profile.plan_label; + changed = true; + } + if let Some(priority) = profile.priority + && account.priority != priority + { + account.priority = priority; + changed = true; + } + return changed; + } + + self.accounts.push(AccountRecord { + id: profile.id.clone(), + alias: profile.alias.unwrap_or(profile.id.clone()), + masked_email: profile.masked_email, + plan_label: profile.plan_label, + priority: profile.priority.unwrap_or_else(|| { + u32::try_from(self.accounts.len()).unwrap_or(u32::MAX.saturating_sub(1)) + }), + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: None, + usage_windows: Vec::new(), + }); + if self.active_account_id.is_none() { + self.active_account_id = Some(profile.id); + } + true + } + + pub fn set_active_account(&mut self, account_id: &str, now_ts: i64) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + account.last_selected_at = Some(now_ts); + let should_change = self.active_account_id.as_deref() != Some(account_id); + self.active_account_id = Some(account_id.to_string()); + should_change || account.last_selected_at == Some(now_ts) + } + + pub fn rename_account_alias(&mut self, account_id: &str, alias: String) -> bool { + let normalized = alias.trim(); + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + let next_alias = if normalized.is_empty() { + account.id.clone() + } else { + normalized.to_string() + }; + if account.alias == next_alias { + false + } else { + account.alias = next_alias; + true + } + } + + pub fn remove_account(&mut self, account_id: &str) -> bool { + let original_len = self.accounts.len(); + self.accounts.retain(|account| account.id != account_id); + if self.accounts.len() == original_len { + return false; + } + + if self.active_account_id.as_deref() == Some(account_id) { + self.active_account_id = self.accounts.first().map(|account| account.id.clone()); + } + + true + } + + pub fn apply_rate_limit_snapshot( + &mut self, + account_id: &str, + snapshot: &AccountRateLimitSnapshot, + ) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + let previous = account.usage_windows.clone(); + account.usage_windows = rate_limit_windows(snapshot); + previous != account.usage_windows + } + + pub fn apply_limit_signal(&mut self, account_id: &str, signal: &AccountLimitSignal) -> bool { + let Some(account) = self + .accounts + .iter_mut() + .find(|account| account.id == account_id) + else { + return false; + }; + + let mut changed = false; + if account.last_limit_error_at != Some(signal.recorded_at) { + account.last_limit_error_at = Some(signal.recorded_at); + changed = true; + } + if account.cooldown_until != signal.cooldown_until { + account.cooldown_until = signal.cooldown_until; + changed = true; + } + changed + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountPoolStore { + codex_home: PathBuf, +} + +impl AccountPoolStore { + pub fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + pub fn path(&self) -> PathBuf { + self.codex_home.join(ACCOUNT_POOL_STATE_RELATIVE_PATH) + } + + pub 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(AccountPoolState::default()), + Err(err) => Err(err), + } + } + + pub fn save(&self, state: &AccountPoolState) -> 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 fn update(&self, updater: F) -> io::Result + where + F: FnOnce(&mut AccountPoolState), + { + let mut state = self.load()?; + updater(&mut state); + self.save(&state)?; + Ok(state) + } +} + +fn rate_limit_windows(snapshot: &AccountRateLimitSnapshot) -> Vec { + let mut windows = Vec::new(); + for (kind, label, window) in [ + ( + AccountUsageWindowKind::FiveHour, + "5h", + snapshot.primary.as_ref(), + ), + ( + AccountUsageWindowKind::Weekly, + "week", + snapshot.secondary.as_ref(), + ), + ] { + let Some(window) = window else { + continue; + }; + windows.push(AccountUsageWindow { + kind, + label: label.to_string(), + estimated_used_units: window.used_percent.round() as u32, + estimated_limit_units: Some(100), + reset_at: window.resets_at, + source: UsageEstimateSource::ResponseErrorInference, + }); + } + windows +} + +#[cfg(test)] +mod tests { + use super::AccountManagementProfile; + use super::AccountPoolState; + use super::AccountPoolStore; + use super::AccountRateLimitSnapshot; + use super::AccountRateLimitWindow; + use super::AccountRecord; + use super::AccountUsageWindow; + use super::AccountUsageWindowKind; + use super::UsageEstimateSource; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn missing_account_pool_file_returns_default_state() { + let tempdir = tempdir().expect("tempdir"); + let store = AccountPoolStore::new(tempdir.path().to_path_buf()); + + assert_eq!(store.load().expect("load"), AccountPoolState::default()); + } + + #[test] + fn save_round_trips_account_pool_state() { + let tempdir = tempdir().expect("tempdir"); + let store = AccountPoolStore::new(tempdir.path().to_path_buf()); + let state = AccountPoolState { + version: 1, + active_account_id: Some("account-1".to_string()), + accounts: vec![AccountRecord { + id: "account-1".to_string(), + alias: "Primary".to_string(), + masked_email: Some("pri***@example.com".to_string()), + plan_label: Some("pro".to_string()), + priority: 0, + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: Some(12), + usage_windows: vec![AccountUsageWindow { + kind: AccountUsageWindowKind::FiveHour, + label: "5h".to_string(), + estimated_used_units: 10, + estimated_limit_units: Some(20), + reset_at: Some(100), + source: UsageEstimateSource::Manual, + }], + }], + }; + + store.save(&state).expect("save"); + + assert_eq!(store.load().expect("load"), state); + } + + #[test] + fn upsert_account_preserves_existing_alias_when_profile_has_none() { + let mut state = AccountPoolState { + version: 1, + active_account_id: Some("account-1".to_string()), + accounts: vec![AccountRecord { + id: "account-1".to_string(), + alias: "Primary".to_string(), + masked_email: None, + plan_label: None, + priority: 0, + enabled: true, + cooldown_until: None, + last_limit_error_at: None, + last_selected_at: None, + usage_windows: Vec::new(), + }], + }; + + assert!(state.upsert_account(AccountManagementProfile { + id: "account-1".to_string(), + alias: None, + masked_email: Some("pri***@example.com".to_string()), + plan_label: Some("pro".to_string()), + priority: None, + })); + assert_eq!(state.accounts[0].alias, "Primary"); + } + + #[test] + fn apply_rate_limit_snapshot_rewrites_usage_windows() { + let mut state = AccountPoolState::default(); + state.upsert_account(AccountManagementProfile { + id: "account-1".to_string(), + alias: Some("Primary".to_string()), + masked_email: None, + plan_label: None, + priority: Some(0), + }); + + let changed = state.apply_rate_limit_snapshot( + "account-1", + &AccountRateLimitSnapshot { + limit_name: Some("codex".to_string()), + primary: Some(AccountRateLimitWindow { + used_percent: 45.0, + window_minutes: Some(300), + resets_at: Some(123), + }), + secondary: None, + }, + ); + + assert!(changed); + assert_eq!( + state.accounts[0].usage_windows, + vec![AccountUsageWindow { + kind: AccountUsageWindowKind::FiveHour, + label: "5h".to_string(), + estimated_used_units: 45, + estimated_limit_units: Some(100), + reset_at: Some(123), + source: UsageEstimateSource::ResponseErrorInference, + }] + ); + } +} diff --git a/codex-rs/accounts/src/account_signal.rs b/codex-rs/accounts/src/account_signal.rs new file mode 100644 index 000000000..a62a09a3e --- /dev/null +++ b/codex-rs/accounts/src/account_signal.rs @@ -0,0 +1,136 @@ +use serde::Deserialize; +use serde::Serialize; + +const DEFAULT_GENERIC_LIMIT_COOLDOWN_SECS: i64 = 15 * 60; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRateLimitWindow { + pub used_percent: f64, + pub window_minutes: Option, + pub resets_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRateLimitSnapshot { + pub limit_name: Option, + pub primary: Option, + pub secondary: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LimitSignalKind { + UsageLimit, + RateLimit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountLimitSignal { + pub kind: LimitSignalKind, + pub recorded_at: i64, + pub cooldown_until: Option, +} + +pub fn infer_limit_signal( + kind: LimitSignalKind, + recorded_at: i64, + snapshot: Option<&AccountRateLimitSnapshot>, +) -> AccountLimitSignal { + let cooldown_until = snapshot + .and_then(|snapshot| blocking_resets_at(snapshot, recorded_at)) + .or_else(|| match kind { + LimitSignalKind::UsageLimit => None, + LimitSignalKind::RateLimit => Some(recorded_at + DEFAULT_GENERIC_LIMIT_COOLDOWN_SECS), + }); + + AccountLimitSignal { + kind, + recorded_at, + cooldown_until, + } +} + +fn blocking_resets_at(snapshot: &AccountRateLimitSnapshot, recorded_at: i64) -> Option { + let mut saturated = Vec::new(); + let mut any_future = Vec::new(); + + for window in [snapshot.primary.as_ref(), snapshot.secondary.as_ref()] + .into_iter() + .flatten() + { + let Some(resets_at) = window + .resets_at + .filter(|resets_at| *resets_at > recorded_at) + else { + continue; + }; + + any_future.push(resets_at); + if window.used_percent >= 99.5 { + saturated.push(resets_at); + } + } + + saturated + .into_iter() + .min() + .or_else(|| any_future.into_iter().min()) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::AccountLimitSignal; + use super::AccountRateLimitSnapshot; + use super::AccountRateLimitWindow; + use super::LimitSignalKind; + use super::infer_limit_signal; + + #[test] + fn usage_limit_prefers_saturated_window_reset() { + let signal = infer_limit_signal( + LimitSignalKind::UsageLimit, + 100, + Some(&AccountRateLimitSnapshot { + limit_name: Some("codex".to_string()), + primary: Some(AccountRateLimitWindow { + used_percent: 92.0, + window_minutes: Some(300), + resets_at: Some(500), + }), + secondary: Some(AccountRateLimitWindow { + used_percent: 100.0, + window_minutes: Some(10080), + resets_at: Some(900), + }), + }), + ); + + assert_eq!( + signal, + AccountLimitSignal { + kind: LimitSignalKind::UsageLimit, + recorded_at: 100, + cooldown_until: Some(900), + } + ); + } + + #[test] + fn rate_limit_falls_back_to_short_cooldown_without_snapshot() { + let signal = infer_limit_signal(LimitSignalKind::RateLimit, 100, None); + + assert_eq!( + signal, + AccountLimitSignal { + kind: LimitSignalKind::RateLimit, + recorded_at: 100, + cooldown_until: Some(1000), + } + ); + } +} diff --git a/codex-rs/accounts/src/lib.rs b/codex-rs/accounts/src/lib.rs new file mode 100644 index 000000000..44315d112 --- /dev/null +++ b/codex-rs/accounts/src/lib.rs @@ -0,0 +1,30 @@ +mod account_pool; +mod account_signal; +mod managed_account_auth; +mod router; + +pub use account_pool::ACCOUNT_POOL_STATE_RELATIVE_PATH; +pub use account_pool::AccountManagementProfile; +pub use account_pool::AccountPoolState; +pub use account_pool::AccountPoolStore; +pub use account_pool::AccountRecord; +pub use account_pool::AccountUsageWindow; +pub use account_pool::AccountUsageWindowKind; +pub use account_pool::UsageEstimateSource; +pub use account_signal::AccountLimitSignal; +pub use account_signal::AccountRateLimitSnapshot; +pub use account_signal::AccountRateLimitWindow; +pub use account_signal::LimitSignalKind; +pub use account_signal::infer_limit_signal; +pub use managed_account_auth::MANAGED_ACCOUNTS_RELATIVE_DIR; +pub use managed_account_auth::ManagedAccountAuthStore; +pub use managed_account_auth::ManagedAccountSnapshot; +pub use managed_account_auth::activate_managed_account; +pub use managed_account_auth::load_current_managed_account_snapshot; +pub use managed_account_auth::persist_current_managed_account_snapshot; +pub use managed_account_auth::persist_managed_account_auth_snapshot; +pub use router::AccountRouterDecision; +pub use router::AccountRouterDecisionReason; +pub use router::DefaultAccountRouter; +pub use router::RouteTurnRequest; +pub use router::RoutingTrigger; diff --git a/codex-rs/accounts/src/managed_account_auth.rs b/codex-rs/accounts/src/managed_account_auth.rs new file mode 100644 index 000000000..8ca78fb8a --- /dev/null +++ b/codex-rs/accounts/src/managed_account_auth.rs @@ -0,0 +1,271 @@ +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::AuthDotJson; +use codex_core::auth::load_auth_dot_json; +use codex_core::auth::save_auth; +use std::fs; +use std::fs::OpenOptions; +use std::io; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; + +use crate::account_pool::AccountManagementProfile; + +pub const MANAGED_ACCOUNTS_RELATIVE_DIR: &str = "accounts"; +const ACCOUNT_AUTH_FILE_NAME: &str = "auth.json"; + +#[derive(Debug, Clone, PartialEq)] +pub struct ManagedAccountSnapshot { + pub profile: AccountManagementProfile, + pub auth: AuthDotJson, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManagedAccountAuthStore { + codex_home: PathBuf, +} + +impl ManagedAccountAuthStore { + pub fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + pub fn directory(&self) -> PathBuf { + self.codex_home.join(MANAGED_ACCOUNTS_RELATIVE_DIR) + } + + pub fn account_dir(&self, account_id: &str) -> PathBuf { + self.directory().join(account_id) + } + + pub fn account_auth_path(&self, account_id: &str) -> PathBuf { + self.account_dir(account_id).join(ACCOUNT_AUTH_FILE_NAME) + } + + pub fn load_account_auth(&self, account_id: &str) -> io::Result { + let path = self.account_auth_path(account_id); + let contents = fs::read_to_string(path)?; + serde_json::from_str(&contents).map_err(io::Error::other) + } + + pub fn save_account_auth(&self, account_id: &str, auth: &AuthDotJson) -> io::Result<()> { + let path = self.account_auth_path(account_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let contents = serde_json::to_string_pretty(auth).map_err(io::Error::other)?; + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(contents.as_bytes())?; + file.flush() + } + + pub fn delete_account_auth(&self, account_id: &str) -> io::Result<()> { + let account_dir = self.account_dir(account_id); + match fs::remove_dir_all(account_dir) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } + } +} + +pub fn activate_managed_account( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + account_id: &str, +) -> io::Result<()> { + let store = ManagedAccountAuthStore::new(codex_home.to_path_buf()); + let auth = store.load_account_auth(account_id)?; + save_auth(codex_home, &auth, auth_credentials_store_mode) +} + +pub fn persist_current_managed_account_snapshot( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> io::Result> { + let Some(snapshot) = + load_current_managed_account_snapshot(codex_home, auth_credentials_store_mode)? + else { + return Ok(None); + }; + let store = ManagedAccountAuthStore::new(codex_home.to_path_buf()); + store.save_account_auth(&snapshot.profile.id, &snapshot.auth)?; + Ok(Some(snapshot)) +} + +pub fn persist_managed_account_auth_snapshot( + codex_home: &Path, + auth: &AuthDotJson, + alias_override: Option<&str>, +) -> io::Result> { + let Some(snapshot) = managed_account_snapshot_from_auth(auth, alias_override) else { + return Ok(None); + }; + let store = ManagedAccountAuthStore::new(codex_home.to_path_buf()); + store.save_account_auth(&snapshot.profile.id, &snapshot.auth)?; + Ok(Some(snapshot)) +} + +pub fn load_current_managed_account_snapshot( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> io::Result> { + let Some(auth_json) = load_auth_dot_json(codex_home, auth_credentials_store_mode)? else { + return Ok(None); + }; + Ok(managed_account_snapshot_from_auth( + &auth_json, /*alias_override*/ None, + )) +} + +fn managed_account_snapshot_from_auth( + auth: &AuthDotJson, + alias_override: Option<&str>, +) -> Option { + let tokens = auth.tokens.as_ref()?; + let id = tokens + .account_id + .clone() + .or_else(|| tokens.id_token.chatgpt_account_id.clone())?; + let profile = AccountManagementProfile { + id, + alias: alias_override + .map(str::trim) + .filter(|alias| !alias.is_empty()) + .map(ToString::to_string), + masked_email: tokens.id_token.email.as_deref().map(mask_email), + plan_label: tokens + .id_token + .get_chatgpt_plan_type() + .map(|plan_type| plan_type.to_ascii_lowercase()), + priority: None, + }; + Some(ManagedAccountSnapshot { + profile, + auth: auth.clone(), + }) +} + +fn mask_email(email: &str) -> String { + let mut parts = email.split('@'); + let local = parts.next().unwrap_or_default(); + let domain = parts.next().unwrap_or_default(); + if local.is_empty() || domain.is_empty() { + return email.to_string(); + } + + let prefix: String = local.chars().take(3).collect(); + format!("{prefix}***@{domain}") +} + +#[cfg(test)] +mod tests { + use super::ManagedAccountAuthStore; + use super::activate_managed_account; + use super::persist_current_managed_account_snapshot; + use super::persist_managed_account_auth_snapshot; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use codex_app_server_protocol::AuthMode; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::load_auth_dot_json; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use serde_json::json; + + fn chatgpt_auth(account_id: &str, email: &str) -> AuthDotJson { + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&fake_jwt( + email, account_id, "pro", + )) + .expect("id token"), + access_token: fake_jwt(email, account_id, "pro"), + refresh_token: "refresh-token".to_string(), + account_id: Some(account_id.to_string()), + }), + last_refresh: None, + } + } + + fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String { + let header = json!({"alg":"none","typ":"JWT"}); + let payload = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |value: serde_json::Value| -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&value).expect("serialize")) + }; + format!("{}.{}.sig", encode(header), encode(payload)) + } + + #[test] + fn persist_managed_account_round_trips() { + let tempdir = tempdir().expect("tempdir"); + let store = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()); + let auth = chatgpt_auth("account-1", "person@example.com"); + + persist_managed_account_auth_snapshot(tempdir.path(), &auth, Some("primary")) + .expect("persist snapshot"); + + let loaded = store.load_account_auth("account-1").expect("load auth"); + assert_eq!(loaded, auth); + } + + #[test] + fn persist_current_managed_account_snapshot_saves_active_auth() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("account-1", "person@example.com"); + save_auth(tempdir.path(), &auth, AuthCredentialsStoreMode::Plaintext).expect("save auth"); + + let snapshot = persist_current_managed_account_snapshot( + tempdir.path(), + AuthCredentialsStoreMode::Plaintext, + ) + .expect("persist current") + .expect("snapshot"); + + assert_eq!(snapshot.profile.id, "account-1"); + } + + #[test] + fn activate_managed_account_restores_saved_auth() { + let tempdir = tempdir().expect("tempdir"); + let store = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()); + let auth = chatgpt_auth("account-1", "person@example.com"); + store + .save_account_auth("account-1", &auth) + .expect("save account auth"); + + activate_managed_account( + tempdir.path(), + AuthCredentialsStoreMode::Plaintext, + "account-1", + ) + .expect("activate"); + + let active = load_auth_dot_json(tempdir.path(), AuthCredentialsStoreMode::Plaintext) + .expect("load auth") + .expect("active auth"); + assert_eq!(active, auth); + } +} diff --git a/codex-rs/accounts/src/router.rs b/codex-rs/accounts/src/router.rs new file mode 100644 index 000000000..cf6b87a8b --- /dev/null +++ b/codex-rs/accounts/src/router.rs @@ -0,0 +1,244 @@ +use crate::account_pool::AccountPoolState; +use crate::account_pool::AccountRecord; +use serde::Deserialize; +use serde::Serialize; + +const DEFAULT_USAGE_THRESHOLD_PERMILLE: u16 = 850; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoutingTrigger { + NormalTurn, + RetryAfterHardError, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountRouterDecisionReason { + KeepActiveAccount, + RetryWithFallbackAccount, + ActiveAccountCoolingDown, + ActiveAccountOverThreshold, + PreferredFallbackSelected, + NoHealthyAccount, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteTurnRequest { + pub now_ts: i64, + pub trigger: RoutingTrigger, + pub active_account_id: Option, + pub preferred_account_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountRouterDecision { + pub account_id: Option, + pub reason: AccountRouterDecisionReason, + pub retry_immediately: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefaultAccountRouter { + usage_threshold_permille: u16, +} + +impl Default for DefaultAccountRouter { + fn default() -> Self { + Self { + usage_threshold_permille: DEFAULT_USAGE_THRESHOLD_PERMILLE, + } + } +} + +impl DefaultAccountRouter { + pub fn new(usage_threshold_permille: u16) -> Self { + Self { + usage_threshold_permille, + } + } + + pub fn select_account( + &self, + state: &AccountPoolState, + request: &RouteTurnRequest, + ) -> AccountRouterDecision { + let active = request + .active_account_id + .as_deref() + .or(state.active_account_id.as_deref()) + .and_then(|account_id| { + state + .accounts + .iter() + .find(|account| account.id == account_id) + }); + + if request.trigger == RoutingTrigger::NormalTurn + && let Some(active) = active + && self.is_healthy(active, request.now_ts) + { + return AccountRouterDecision { + account_id: Some(active.id.clone()), + reason: AccountRouterDecisionReason::KeepActiveAccount, + retry_immediately: false, + }; + } + + if request.trigger == RoutingTrigger::RetryAfterHardError { + if let Some(fallback) = self.pick_fallback(state, request, active) { + return AccountRouterDecision { + account_id: Some(fallback.id.clone()), + reason: AccountRouterDecisionReason::RetryWithFallbackAccount, + retry_immediately: true, + }; + } + + return AccountRouterDecision { + account_id: None, + reason: AccountRouterDecisionReason::NoHealthyAccount, + retry_immediately: false, + }; + } + + if let Some(active) = active { + let reason = if active.is_available_at(request.now_ts) { + AccountRouterDecisionReason::ActiveAccountOverThreshold + } else { + AccountRouterDecisionReason::ActiveAccountCoolingDown + }; + + if let Some(fallback) = self.pick_fallback(state, request, Some(active)) { + return AccountRouterDecision { + account_id: Some(fallback.id.clone()), + reason, + retry_immediately: false, + }; + } + } + + if let Some(preferred) = request + .preferred_account_id + .as_deref() + .and_then(|account_id| { + state + .accounts + .iter() + .find(|account| account.id == account_id) + }) + .filter(|account| self.is_healthy(account, request.now_ts)) + { + return AccountRouterDecision { + account_id: Some(preferred.id.clone()), + reason: AccountRouterDecisionReason::PreferredFallbackSelected, + retry_immediately: false, + }; + } + + AccountRouterDecision { + account_id: None, + reason: AccountRouterDecisionReason::NoHealthyAccount, + retry_immediately: false, + } + } + + fn is_healthy(&self, account: &AccountRecord, now_ts: i64) -> bool { + account.is_available_at(now_ts) + && account + .highest_pressure_permille() + .is_none_or(|pressure| pressure < self.usage_threshold_permille) + } + + fn pick_fallback<'a>( + &self, + state: &'a AccountPoolState, + request: &RouteTurnRequest, + active: Option<&AccountRecord>, + ) -> Option<&'a AccountRecord> { + let active_id = active.map(|account| account.id.as_str()); + let mut candidates: Vec<&AccountRecord> = state + .accounts + .iter() + .filter(|account| Some(account.id.as_str()) != active_id) + .filter(|account| self.is_healthy(account, request.now_ts)) + .collect(); + + candidates.sort_by_key(|account| (account.priority, account.last_selected_at.unwrap_or(0))); + candidates.into_iter().next() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::account_pool::AccountPoolState; + use crate::account_pool::AccountRecord; + use crate::account_pool::AccountUsageWindow; + use crate::account_pool::AccountUsageWindowKind; + use crate::account_pool::UsageEstimateSource; + + use super::AccountRouterDecision; + use super::AccountRouterDecisionReason; + use super::DefaultAccountRouter; + use super::RouteTurnRequest; + use super::RoutingTrigger; + + fn account( + id: &str, + priority: u32, + used: u32, + limit: u32, + cooldown_until: Option, + ) -> AccountRecord { + AccountRecord { + id: id.to_string(), + alias: id.to_string(), + masked_email: None, + plan_label: None, + priority, + enabled: true, + cooldown_until, + last_limit_error_at: None, + last_selected_at: None, + usage_windows: vec![AccountUsageWindow { + kind: AccountUsageWindowKind::FiveHour, + label: "5h".to_string(), + estimated_used_units: used, + estimated_limit_units: Some(limit), + reset_at: None, + source: UsageEstimateSource::LocalHeuristic, + }], + } + } + + #[test] + fn normal_turn_keeps_healthy_active_account() { + let router = DefaultAccountRouter::default(); + let state = AccountPoolState { + version: 1, + active_account_id: Some("primary".to_string()), + accounts: vec![ + account("primary", 0, 10, 40, None), + account("backup", 1, 1, 40, None), + ], + }; + let request = RouteTurnRequest { + now_ts: 100, + trigger: RoutingTrigger::NormalTurn, + active_account_id: Some("primary".to_string()), + preferred_account_id: None, + }; + + assert_eq!( + router.select_account(&state, &request), + AccountRouterDecision { + account_id: Some("primary".to_string()), + reason: AccountRouterDecisionReason::KeepActiveAccount, + retry_immediately: false, + } + ); + } +} diff --git a/codex-rs/btw/Cargo.toml b/codex-rs/btw/Cargo.toml new file mode 100644 index 000000000..30f830509 --- /dev/null +++ b/codex-rs/btw/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-btw" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_btw" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/btw/src/lib.rs b/codex-rs/btw/src/lib.rs new file mode 100644 index 000000000..5d99f299d --- /dev/null +++ b/codex-rs/btw/src/lib.rs @@ -0,0 +1,187 @@ +use serde::Deserialize; +use serde::Serialize; + +const DEFAULT_CONTEXT_BUDGET_TOKENS: usize = 2_000; +const PREVIEW_CHAR_LIMIT: usize = 1_200; +const SUMMARY_MAX_LINES: usize = 4; +const SUMMARY_MAX_CHARS: usize = 500; + +pub 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." +); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwSurface { + SlashOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwVisibility { + Hidden, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwPersistence { + EphemeralOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwToolPolicy { + InheritMinusSideEffects, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BtwResultAction { + InsertSummary, + InsertFull, + Discard, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BtwPolicy { + pub context_budget_tokens: usize, + pub tool_policy: BtwToolPolicy, + pub result_actions: Vec, +} + +impl Default for BtwPolicy { + fn default() -> Self { + Self { + context_budget_tokens: DEFAULT_CONTEXT_BUDGET_TOKENS, + tool_policy: BtwToolPolicy::InheritMinusSideEffects, + result_actions: vec![ + BtwResultAction::InsertSummary, + BtwResultAction::InsertFull, + BtwResultAction::Discard, + ], + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BtwCapability { + pub enabled: bool, + pub surface: BtwSurface, + pub visibility: BtwVisibility, + pub persistence: BtwPersistence, + pub policy: BtwPolicy, +} + +impl Default for BtwCapability { + fn default() -> Self { + Self { + enabled: true, + surface: BtwSurface::SlashOnly, + visibility: BtwVisibility::Hidden, + persistence: BtwPersistence::EphemeralOnly, + policy: BtwPolicy::default(), + } + } +} + +pub fn merge_developer_instructions(existing: Option) -> String { + match existing { + Some(existing) if !existing.trim().is_empty() => { + format!("{existing}\n\n{BTW_DEVELOPER_INSTRUCTIONS}") + } + _ => BTW_DEVELOPER_INSTRUCTIONS.to_string(), + } +} + +pub 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…") +} + +pub 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")) + } +} + +pub fn full_insert_text(message: &str) -> String { + format!("BTW discussion:\n{message}") +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::BtwCapability; + use super::BtwResultAction; + use super::BtwToolPolicy; + use super::preview_text; + use super::summarize_message; + + #[test] + fn btw_capability_defaults_match_hidden_ephemeral_discussion_flow() { + let capability = BtwCapability::default(); + + assert!(capability.enabled); + assert_eq!(capability.policy.context_budget_tokens, 2_000); + assert_eq!( + capability.policy.tool_policy, + BtwToolPolicy::InheritMinusSideEffects + ); + assert_eq!( + capability.policy.result_actions, + vec![ + BtwResultAction::InsertSummary, + BtwResultAction::InsertFull, + BtwResultAction::Discard, + ] + ); + } + + #[test] + fn summarize_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 message = "a".repeat(1_250); + let preview = preview_text(&message); + + assert!(preview.contains("preview truncated")); + } +} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 7e703efe1..a93d28352 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -19,6 +19,7 @@ workspace = true anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } +codex-accounts = { workspace = true } codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } @@ -30,6 +31,7 @@ codex-config = { workspace = true } codex-core = { workspace = true } codex-exec = { workspace = true } codex-execpolicy = { workspace = true } +codex-ext = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } codex-mcp-server = { workspace = true } @@ -66,6 +68,7 @@ codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows- [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } +base64 = { workspace = true } codex-utils-cargo-bin = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/ext/Cargo.toml b/codex-rs/ext/Cargo.toml new file mode 100644 index 000000000..74204d0a8 --- /dev/null +++ b/codex-rs/ext/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "codex-ext" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_ext" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-accounts = { workspace = true } +codex-btw = { workspace = true } +codex-core = { workspace = true } +codex-threadmessages = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +base64 = { workspace = true } +codex-app-server-protocol = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/ext/README.md b/codex-rs/ext/README.md new file mode 100644 index 000000000..38d72f9e9 --- /dev/null +++ b/codex-rs/ext/README.md @@ -0,0 +1,16 @@ +# codex-ext + +`codex-ext` is the fork-owned extension layer for long-lived customization. + +Current MVP scope: + +- stable capability-negotiation shapes for future plugin runtimes +- jump-target and workspace-spawn models for fork-owned host extensions +- hidden ephemeral-discussion capability models for fork-owned `/btw`-style flows +- persisted multi-account pool state +- default account router model for threshold-based fallback +- data structures intentionally decoupled from current TUI/core internals + +This crate does not yet load WASM modules. The immediate goal is to keep the +fork-specific policy surface isolated so future upgrades only touch a narrow set +of host integration points. diff --git a/codex-rs/ext/src/host_api.rs b/codex-rs/ext/src/host_api.rs new file mode 100644 index 000000000..7ce555451 --- /dev/null +++ b/codex-rs/ext/src/host_api.rs @@ -0,0 +1,133 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostCapability { + pub name: String, + pub version: u32, +} + +impl HostCapability { + pub fn new(name: impl Into, version: u32) -> Self { + Self { + name: name.into(), + version, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityRequirement { + pub name: String, + pub minimum_version: u32, +} + +impl CapabilityRequirement { + pub fn new(name: impl Into, minimum_version: u32) -> Self { + Self { + name: name.into(), + minimum_version, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostCapabilities { + pub capabilities: Vec, +} + +impl Default for HostCapabilities { + fn default() -> Self { + Self::codex_mvp() + } +} + +impl HostCapabilities { + pub fn codex_mvp() -> Self { + Self { + capabilities: vec![ + HostCapability::new("app-start", /*version*/ 1), + HostCapability::new("session-start", /*version*/ 1), + HostCapability::new("before-turn-start", /*version*/ 1), + HostCapability::new("before-tool-call", /*version*/ 1), + HostCapability::new("after-tool-call", /*version*/ 1), + HostCapability::new("account-routing", /*version*/ 1), + HostCapability::new("control-panel", /*version*/ 1), + HostCapability::new("ephemeral-discussion", /*version*/ 1), + HostCapability::new("jump-to-message", /*version*/ 1), + HostCapability::new("spawn-with-workspace", /*version*/ 1), + ], + } + } + + pub fn supports(&self, requirement: &CapabilityRequirement) -> bool { + self.capabilities.iter().any(|capability| { + capability.name == requirement.name && capability.version >= requirement.minimum_version + }) + } + + pub fn negotiate(&self, requirements: &[CapabilityRequirement]) -> PluginNegotiation { + let mut accepted = Vec::new(); + let mut missing = Vec::new(); + + for requirement in requirements { + if self.supports(requirement) { + accepted.push(requirement.clone()); + } else { + missing.push(requirement.clone()); + } + } + + PluginNegotiation { accepted, missing } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginNegotiation { + pub accepted: Vec, + pub missing: Vec, +} + +impl PluginNegotiation { + pub fn is_compatible(&self) -> bool { + self.missing.is_empty() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::CapabilityRequirement; + use super::HostCapabilities; + + #[test] + fn codex_mvp_capabilities_support_account_routing() { + let capabilities = HostCapabilities::codex_mvp(); + let negotiation = capabilities.negotiate(&[ + CapabilityRequirement::new("account-routing", 1), + CapabilityRequirement::new("control-panel", 1), + CapabilityRequirement::new("ephemeral-discussion", 1), + CapabilityRequirement::new("jump-to-message", 1), + CapabilityRequirement::new("spawn-with-workspace", 1), + ]); + + assert!(negotiation.is_compatible()); + assert_eq!(negotiation.accepted.len(), 5); + assert_eq!(negotiation.missing.len(), 0); + } + + #[test] + fn negotiation_reports_missing_versions() { + let capabilities = HostCapabilities::codex_mvp(); + let negotiation = + capabilities.negotiate(&[CapabilityRequirement::new("before-turn-start", 2)]); + + assert!(!negotiation.is_compatible()); + assert_eq!(negotiation.accepted.len(), 0); + assert_eq!( + negotiation.missing, + vec![CapabilityRequirement::new("before-turn-start", 2)] + ); + } +} diff --git a/codex-rs/ext/src/lib.rs b/codex-rs/ext/src/lib.rs new file mode 100644 index 000000000..2912cbdbb --- /dev/null +++ b/codex-rs/ext/src/lib.rs @@ -0,0 +1,45 @@ +pub mod host_api; +pub mod workspace_spawn; + +pub use codex_accounts::ACCOUNT_POOL_STATE_RELATIVE_PATH; +pub use codex_accounts::AccountLimitSignal; +pub use codex_accounts::AccountManagementProfile; +pub use codex_accounts::AccountPoolState; +pub use codex_accounts::AccountPoolStore; +pub use codex_accounts::AccountRateLimitSnapshot; +pub use codex_accounts::AccountRateLimitWindow; +pub use codex_accounts::AccountRecord; +pub use codex_accounts::AccountRouterDecision; +pub use codex_accounts::AccountRouterDecisionReason; +pub use codex_accounts::AccountUsageWindow; +pub use codex_accounts::AccountUsageWindowKind; +pub use codex_accounts::DefaultAccountRouter; +pub use codex_accounts::LimitSignalKind; +pub use codex_accounts::MANAGED_ACCOUNTS_RELATIVE_DIR; +pub use codex_accounts::ManagedAccountAuthStore; +pub use codex_accounts::ManagedAccountSnapshot; +pub use codex_accounts::RouteTurnRequest; +pub use codex_accounts::RoutingTrigger; +pub use codex_accounts::UsageEstimateSource; +pub use codex_accounts::activate_managed_account; +pub use codex_accounts::infer_limit_signal; +pub use codex_accounts::load_current_managed_account_snapshot; +pub use codex_accounts::persist_current_managed_account_snapshot; +pub use codex_accounts::persist_managed_account_auth_snapshot; +pub use codex_btw::BtwCapability; +pub use codex_btw::BtwPersistence; +pub use codex_btw::BtwPolicy; +pub use codex_btw::BtwResultAction; +pub use codex_btw::BtwSurface; +pub use codex_btw::BtwToolPolicy; +pub use codex_threadmessages::JumpCatalog; +pub use codex_threadmessages::JumpTarget; +pub use codex_threadmessages::JumpTargetKind; +pub use host_api::CapabilityRequirement; +pub use host_api::HostCapabilities; +pub use host_api::HostCapability; +pub use host_api::PluginNegotiation; +pub use workspace_spawn::WorkspaceSpawnError; +pub use workspace_spawn::WorkspaceSpawnRequest; +pub use workspace_spawn::WorkspaceSpawnResolution; +pub use workspace_spawn::resolve_workspace_spawn; diff --git a/codex-rs/ext/src/workspace_spawn.rs b/codex-rs/ext/src/workspace_spawn.rs new file mode 100644 index 000000000..7a717350c --- /dev/null +++ b/codex-rs/ext/src/workspace_spawn.rs @@ -0,0 +1,132 @@ +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceSpawnRequest { + pub cwd: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSpawnResolution { + pub cwd: PathBuf, + pub inherited: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceSpawnError { + EmptyPath, + MissingDirectory(PathBuf), + NotADirectory(PathBuf), +} + +impl fmt::Display for WorkspaceSpawnError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyPath => write!(f, "workspace path cannot be empty"), + Self::MissingDirectory(path) => { + write!(f, "workspace {} does not exist", path.display()) + } + Self::NotADirectory(path) => { + write!(f, "workspace {} is not a directory", path.display()) + } + } + } +} + +impl std::error::Error for WorkspaceSpawnError {} + +pub fn resolve_workspace_spawn( + parent_cwd: &Path, + request: &WorkspaceSpawnRequest, +) -> Result { + let Some(requested_cwd) = request.cwd.as_deref() else { + return Ok(WorkspaceSpawnResolution { + cwd: parent_cwd.to_path_buf(), + inherited: true, + }); + }; + + let requested_cwd = requested_cwd.trim(); + if requested_cwd.is_empty() { + return Err(WorkspaceSpawnError::EmptyPath); + } + + let resolved = PathBuf::from(requested_cwd); + let resolved = if resolved.is_absolute() { + resolved + } else { + parent_cwd.join(resolved) + }; + + if !resolved.exists() { + return Err(WorkspaceSpawnError::MissingDirectory(resolved)); + } + if !resolved.is_dir() { + return Err(WorkspaceSpawnError::NotADirectory(resolved)); + } + + Ok(WorkspaceSpawnResolution { + cwd: resolved, + inherited: false, + }) +} + +#[cfg(test)] +mod tests { + use super::WorkspaceSpawnError; + use super::WorkspaceSpawnRequest; + use super::resolve_workspace_spawn; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn resolve_workspace_spawn_inherits_parent_cwd_by_default() { + let tempdir = tempdir().expect("tempdir"); + + let resolution = + resolve_workspace_spawn(tempdir.path(), &WorkspaceSpawnRequest { cwd: None }) + .expect("resolution"); + + assert_eq!(resolution.cwd, tempdir.path()); + assert!(resolution.inherited); + } + + #[test] + fn resolve_workspace_spawn_resolves_relative_path() { + let tempdir = tempdir().expect("tempdir"); + let child = tempdir.path().join("worker-a"); + std::fs::create_dir(&child).expect("create dir"); + + let resolution = resolve_workspace_spawn( + tempdir.path(), + &WorkspaceSpawnRequest { + cwd: Some("worker-a".to_string()), + }, + ) + .expect("resolution"); + + assert_eq!(resolution.cwd, child); + assert!(!resolution.inherited); + } + + #[test] + fn resolve_workspace_spawn_rejects_missing_directory() { + let tempdir = tempdir().expect("tempdir"); + + let err = resolve_workspace_spawn( + tempdir.path(), + &WorkspaceSpawnRequest { + cwd: Some("missing".to_string()), + }, + ) + .expect_err("missing path should fail"); + + assert_eq!( + err, + WorkspaceSpawnError::MissingDirectory(tempdir.path().join("missing")) + ); + } +} diff --git a/codex-rs/loop/Cargo.toml b/codex-rs/loop/Cargo.toml new file mode 100644 index 000000000..71376a29b --- /dev/null +++ b/codex-rs/loop/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "codex-loop" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_loop" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +chrono = { workspace = true, features = ["serde"] } +codex-core = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +cron = "0.16.0" +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/loop/src/command.rs b/codex-rs/loop/src/command.rs new file mode 100644 index 000000000..4ee77b897 --- /dev/null +++ b/codex-rs/loop/src/command.rs @@ -0,0 +1,327 @@ +use chrono::DateTime; +use chrono::TimeZone; +use chrono::Utc; +use cron::Schedule; +use serde::Deserialize; +use serde::Serialize; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LoopSchedule { + Interval { display: String, seconds: u64 }, + Cron { display: String, normalized: String }, +} + +impl LoopSchedule { + pub fn display(&self) -> &str { + match self { + Self::Interval { display, .. } | Self::Cron { display, .. } => display, + } + } + + pub fn next_due_after( + &self, + last_scheduled_at_unix_seconds: i64, + now: DateTime, + ) -> DateTime { + match self { + Self::Interval { seconds, .. } => { + let interval = i64::try_from(*seconds).unwrap_or(i64::MAX).max(1); + let next = if last_scheduled_at_unix_seconds >= now.timestamp() { + last_scheduled_at_unix_seconds.saturating_add(interval) + } else { + let elapsed = now + .timestamp() + .saturating_sub(last_scheduled_at_unix_seconds); + let skipped_intervals = elapsed / interval; + last_scheduled_at_unix_seconds.saturating_add( + skipped_intervals.saturating_add(1).saturating_mul(interval), + ) + }; + unix_seconds_to_utc(next).unwrap_or(now) + } + Self::Cron { normalized, .. } => Schedule::from_str(normalized) + .ok() + .and_then(|schedule| { + unix_seconds_to_utc(last_scheduled_at_unix_seconds) + .and_then(|last| schedule.after(&last).next()) + }) + .map(|next| next.with_timezone(&Utc)) + .filter(|next| *next > now) + .unwrap_or(now), + } + } + + pub fn first_due_after_creation(&self, now: DateTime) -> DateTime { + match self { + Self::Interval { seconds, .. } => { + let interval = i64::try_from(*seconds).unwrap_or(i64::MAX).max(1); + unix_seconds_to_utc(now.timestamp().saturating_add(interval)).unwrap_or(now) + } + Self::Cron { normalized, .. } => Schedule::from_str(normalized) + .ok() + .and_then(|schedule| schedule.after(&now).next()) + .map(|next| next.with_timezone(&Utc)) + .unwrap_or(now), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum LoopMode { + OneShot, + #[default] + Persistent, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum LoopDeliveryMode { + #[default] + AssistantOnly, + ResultAsUser, + AssistantThenActionUser, +} + +impl LoopDeliveryMode { + pub const USER_SELECTABLE: [Self; 3] = [ + Self::AssistantOnly, + Self::ResultAsUser, + Self::AssistantThenActionUser, + ]; + + pub fn title(self) -> &'static str { + match self { + Self::AssistantOnly => "As Assistant Message", + Self::ResultAsUser => "As User Message", + Self::AssistantThenActionUser => "As User Message + Action", + } + } + + pub fn description(self) -> &'static str { + match self { + Self::AssistantOnly => { + "Default. Mirror the latest loop result into the main thread as an assistant message." + } + Self::ResultAsUser => { + "Submit the latest loop result back into the main thread as a user message." + } + Self::AssistantThenActionUser => { + "Submit the latest loop result back into the main thread as a user message, with the configured action appended at the end." + } + } + } + + pub fn short_label(self) -> &'static str { + match self { + Self::AssistantOnly => "assistant message", + Self::ResultAsUser => "user message", + Self::AssistantThenActionUser => "user message + action", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoopCommand { + Focus { + id: String, + }, + Create { + id: Option, + schedule: LoopSchedule, + prompt: String, + }, +} + +pub fn parse_loop_command(spec: &str) -> Result { + let spec = spec.trim(); + if spec.is_empty() { + return Err( + "Usage: /loop or /loop " + .to_string(), + ); + } + + if let Ok((schedule, prompt)) = parse_schedule_and_prompt(spec) { + if prompt.trim().is_empty() { + return Err("expected a prompt after the schedule".to_string()); + } + return Ok(LoopCommand::Create { + id: None, + schedule, + prompt, + }); + } + + let tokens = spec.split_whitespace().collect::>(); + if tokens.len() == 1 { + return Ok(LoopCommand::Focus { + id: tokens[0].to_string(), + }); + } + + let id = tokens[0].trim(); + validate_loop_id(id)?; + let rest = spec[id.len()..].trim(); + let (schedule, prompt) = parse_schedule_and_prompt(rest)?; + if prompt.trim().is_empty() { + return Err("expected a prompt after the schedule".to_string()); + } + Ok(LoopCommand::Create { + id: Some(id.to_string()), + schedule, + prompt, + }) +} + +pub fn parse_loop_schedule(spec: &str) -> Result { + let (schedule, prompt) = parse_schedule_and_prompt(spec)?; + if !prompt.is_empty() { + return Err("expected only a schedule".to_string()); + } + Ok(schedule) +} + +pub fn validate_loop_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("loop id cannot be empty".to_string()); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err("loop id must use only letters, digits, underscores, or hyphens".to_string()); + } + Ok(()) +} + +fn parse_schedule_and_prompt(spec: &str) -> Result<(LoopSchedule, String), String> { + let tokens = spec.split_whitespace().collect::>(); + if tokens.is_empty() { + return Err("expected a schedule".to_string()); + } + + if let Some(seconds) = parse_interval_seconds(tokens[0]) { + let prompt = spec[tokens[0].len()..].trim().to_string(); + return Ok(( + LoopSchedule::Interval { + display: tokens[0].to_string(), + seconds, + }, + prompt, + )); + } + + for field_count in [7usize, 6, 5] { + if tokens.len() < field_count { + continue; + } + let display = tokens[..field_count].join(" "); + let normalized = normalize_cron_expression(&display, field_count); + if Schedule::from_str(&normalized).is_ok() { + let prompt = tokens[field_count..].join(" "); + return Ok(( + LoopSchedule::Cron { + display, + normalized, + }, + prompt, + )); + } + } + + Err( + "could not parse the schedule; use `5m`-style intervals or a 5/6/7-field cron expression" + .to_string(), + ) +} + +fn parse_interval_seconds(token: &str) -> Option { + let mut index = 0usize; + let mut total = 0u64; + let bytes = token.as_bytes(); + while index < bytes.len() { + let digits_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + if digits_start == index || index >= bytes.len() { + return None; + } + let value = token[digits_start..index].parse::().ok()?; + let multiplier = match bytes[index] as char { + 's' => 1, + 'm' => 60, + 'h' => 60 * 60, + 'd' => 60 * 60 * 24, + _ => return None, + }; + total = total.checked_add(value.checked_mul(multiplier)?)?; + index += 1; + } + (total > 0).then_some(total) +} + +fn normalize_cron_expression(expression: &str, field_count: usize) -> String { + match field_count { + 5 => format!("0 {expression} *"), + 6 => format!("{expression} *"), + _ => expression.to_string(), + } +} + +fn unix_seconds_to_utc(unix_seconds: i64) -> Option> { + Utc.timestamp_opt(unix_seconds, 0).single() +} + +#[cfg(test)] +mod tests { + use super::LoopCommand; + use super::LoopSchedule; + use super::parse_loop_command; + use super::parse_loop_schedule; + use pretty_assertions::assert_eq; + + #[test] + fn parse_interval_loop_command() { + assert_eq!( + parse_loop_command("5m check status").expect("parse"), + LoopCommand::Create { + id: None, + schedule: LoopSchedule::Interval { + display: "5m".to_string(), + seconds: 300, + }, + prompt: "check status".to_string(), + } + ); + } + + #[test] + fn parse_persistent_loop_command() { + assert_eq!( + parse_loop_command("director 30m review status").expect("parse"), + LoopCommand::Create { + id: Some("director".to_string()), + schedule: LoopSchedule::Interval { + display: "30m".to_string(), + seconds: 1800, + }, + prompt: "review status".to_string(), + } + ); + } + + #[test] + fn parse_cron_schedule() { + assert_eq!( + parse_loop_schedule("*/5 * * * *").expect("schedule"), + LoopSchedule::Cron { + display: "*/5 * * * *".to_string(), + normalized: "0 */5 * * * *".to_string(), + } + ); + } +} diff --git a/codex-rs/loop/src/execution.rs b/codex-rs/loop/src/execution.rs new file mode 100644 index 000000000..b8358d9c0 --- /dev/null +++ b/codex-rs/loop/src/execution.rs @@ -0,0 +1,214 @@ +use codex_core::config::Config; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersistedLoopExecutionSettings { + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub writable_roots: Vec, +} + +pub fn apply_loop_execution_settings( + config: &mut Config, + settings: &PersistedLoopExecutionSettings, + workspace_cwd: &Path, +) -> Result { + let resolved_cwd = settings + .cwd + .as_ref() + .map(|cwd| resolve_absolute_path(cwd, workspace_cwd)) + .transpose()?; + if let Some(cwd) = resolved_cwd { + config.cwd = cwd.into(); + } + + if !settings.writable_roots.is_empty() { + let writable_roots = resolve_writable_roots(settings, workspace_cwd)?; + let network_access = config + .permissions + .sandbox_policy + .get() + .has_full_network_access(); + config + .permissions + .sandbox_policy + .set(SandboxPolicy::WorkspaceWrite { + writable_roots, + read_only_access: ReadOnlyAccess::FullAccess, + network_access, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }) + .map_err(|err| format!("Failed to configure `/loop` writable roots: {err}"))?; + } + + Ok(loop_developer_instructions(settings)) +} + +pub fn loop_execution_summary( + settings: &PersistedLoopExecutionSettings, + session_cwd: &Path, +) -> String { + let cwd = settings + .cwd + .as_ref() + .map(|path| format!("`{}`", path.display())) + .unwrap_or_else(|| format!("session default (`{}`)", session_cwd.display())); + let writable_scope = if settings.writable_roots.is_empty() { + "session default".to_string() + } else { + settings + .writable_roots + .iter() + .map(|path| format!("`{}`", path.display())) + .collect::>() + .join(", ") + }; + format!("CWD: {cwd}. Writable scope: {writable_scope}.") +} + +pub fn writable_roots_editor_text(settings: &PersistedLoopExecutionSettings) -> String { + settings + .writable_roots + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join("\n") +} + +pub fn cwd_editor_text(settings: &PersistedLoopExecutionSettings, session_cwd: &Path) -> String { + settings + .cwd + .clone() + .unwrap_or_else(|| session_cwd.to_path_buf()) + .display() + .to_string() +} + +pub fn parse_loop_writable_roots( + input: &str, + workspace_cwd: &Path, +) -> Result, String> { + let mut seen = BTreeSet::new(); + let mut writable_roots = Vec::new(); + + for raw_line in input.lines() { + let trimmed = raw_line.trim(); + if trimmed.is_empty() { + continue; + } + let absolute = resolve_pathbuf(PathBuf::from(trimmed), workspace_cwd)?; + let metadata = fs::metadata(&absolute) + .map_err(|err| format!("Writable directory `{trimmed}` is unavailable: {err}"))?; + if !metadata.is_dir() { + return Err(format!( + "Writable directory `{trimmed}` is not a directory." + )); + } + + let persisted = normalize_persisted_path(absolute.as_path(), workspace_cwd); + if seen.insert(persisted.clone()) { + writable_roots.push(persisted); + } + } + + Ok(writable_roots) +} + +pub fn parse_loop_cwd(input: &str, workspace_cwd: &Path) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("Working directory cannot be empty.".to_string()); + } + let absolute = resolve_pathbuf(PathBuf::from(trimmed), workspace_cwd)?; + let metadata = fs::metadata(&absolute) + .map_err(|err| format!("Working directory `{trimmed}` is unavailable: {err}"))?; + if !metadata.is_dir() { + return Err(format!("Working directory `{trimmed}` is not a directory.")); + } + Ok(normalize_persisted_path(absolute.as_path(), workspace_cwd)) +} + +fn loop_developer_instructions(settings: &PersistedLoopExecutionSettings) -> String { + let mut parts = vec![ + "This is a hidden `/loop` execution thread.".to_string(), + "Use the current main-thread context only as background.".to_string(), + "Keep work scoped to this scheduled task.".to_string(), + ]; + if let Some(cwd) = &settings.cwd { + parts.push(format!( + "Use `{}` as the execution working directory.", + cwd.display() + )); + } else { + parts.push( + "Use the same working directory as the parent thread unless the runtime overrides it." + .to_string(), + ); + } + if settings.writable_roots.is_empty() { + parts.push( + "Use the same permissions and tool access as the parent thread unless the runtime overrides them." + .to_string(), + ); + } else { + let writable_roots = settings + .writable_roots + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + parts.push(format!( + "Only write files inside these directories: {writable_roots}." + )); + } + parts.join(" ") +} + +fn resolve_writable_roots( + settings: &PersistedLoopExecutionSettings, + workspace_cwd: &Path, +) -> Result, String> { + settings + .writable_roots + .iter() + .map(|path| resolve_absolute_path(path, workspace_cwd)) + .collect() +} + +fn resolve_absolute_path(path: &Path, workspace_cwd: &Path) -> Result { + let absolute = resolve_pathbuf(path.to_path_buf(), workspace_cwd)?; + AbsolutePathBuf::from_absolute_path(absolute.clone()) + .map_err(|err| format!("Invalid path `{}`: {err}", absolute.display())) +} + +fn resolve_pathbuf(path: PathBuf, workspace_cwd: &Path) -> Result { + let absolute = if path.is_absolute() { + path + } else { + workspace_cwd.join(path) + }; + fs::canonicalize(&absolute) + .map_err(|err| format!("Path `{}` is unavailable: {err}", absolute.display())) +} + +fn normalize_persisted_path(path: &Path, workspace_cwd: &Path) -> PathBuf { + if let Ok(relative) = path.strip_prefix(workspace_cwd) { + if relative.as_os_str().is_empty() { + PathBuf::from(".") + } else { + relative.to_path_buf() + } + } else { + path.to_path_buf() + } +} diff --git a/codex-rs/loop/src/lib.rs b/codex-rs/loop/src/lib.rs new file mode 100644 index 000000000..2c8ffd7cf --- /dev/null +++ b/codex-rs/loop/src/lib.rs @@ -0,0 +1,32 @@ +mod command; +mod execution; +mod model; + +pub use command::LoopCommand; +pub use command::LoopDeliveryMode; +pub use command::LoopMode; +pub use command::LoopSchedule; +pub use command::parse_loop_command; +pub use command::parse_loop_schedule; +pub use command::validate_loop_id; +pub use execution::PersistedLoopExecutionSettings; +pub use execution::apply_loop_execution_settings; +pub use execution::cwd_editor_text; +pub use execution::loop_execution_summary; +pub use execution::parse_loop_cwd; +pub use execution::parse_loop_writable_roots; +pub use execution::writable_roots_editor_text; +pub use model::LoopTimerCompletionPlan; +pub use model::PersistedLoopTimer; +pub use model::PersistedLoopTimersFile; +pub use model::build_loop_result_user_message_with_action; +pub use model::build_loop_run_input; +pub use model::effective_loop_delivery_mode; +pub use model::format_timestamp; +pub use model::load_loop_timers; +pub use model::loop_id_prefix; +pub use model::loop_item_name; +pub use model::loop_timers_path; +pub use model::next_due_for_timer; +pub use model::prompt_prefix; +pub use model::timer_descriptor; diff --git a/codex-rs/loop/src/model.rs b/codex-rs/loop/src/model.rs new file mode 100644 index 000000000..aa1ef15dd --- /dev/null +++ b/codex-rs/loop/src/model.rs @@ -0,0 +1,130 @@ +use chrono::DateTime; +use chrono::TimeZone; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use crate::command::LoopDeliveryMode; +use crate::command::LoopMode; +use crate::command::LoopSchedule; +use crate::execution::PersistedLoopExecutionSettings; + +const LOOP_TIMER_FILE_NAME: &str = "loop_timers.json"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersistedLoopTimersFile { + #[serde(default)] + pub timers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersistedLoopTimer { + pub id: String, + #[serde(default)] + pub mode: LoopMode, + pub prompt: String, + #[serde(default)] + pub action: Option, + #[serde(default)] + pub delivery_mode: Option, + #[serde(default)] + pub execution: PersistedLoopExecutionSettings, + pub schedule: LoopSchedule, + pub enabled: bool, + #[serde(default)] + pub rollout_path: Option, + pub created_at_unix_seconds: i64, + pub last_scheduled_at_unix_seconds: Option, + pub last_completed_at_unix_seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopTimerCompletionPlan { + pub summary_message: String, + pub mirror_prompt: bool, + pub followup_user_message: Option, +} + +pub fn effective_loop_delivery_mode(timer: Option<&PersistedLoopTimer>) -> LoopDeliveryMode { + timer + .and_then(|timer| timer.delivery_mode) + .unwrap_or_default() +} + +pub fn timer_descriptor(timer: &PersistedLoopTimer) -> &'static str { + match timer.mode { + LoopMode::OneShot => "one-shot", + LoopMode::Persistent => "persistent", + } +} + +pub fn loop_item_name(timer: &PersistedLoopTimer) -> String { + match timer.mode { + LoopMode::OneShot => format!("one-shot {}", prompt_prefix(&timer.prompt)), + LoopMode::Persistent => timer.id.clone(), + } +} + +pub fn loop_id_prefix(id: &str) -> String { + id.chars().take(8).collect() +} + +pub fn prompt_prefix(prompt: &str) -> String { + let prefix = prompt.chars().take(48).collect::(); + if prompt.chars().count() > 48 { + format!("{prefix}...") + } else { + prefix + } +} + +pub fn build_loop_run_input(prompt: &str, recent_main_messages: &[String]) -> String { + if recent_main_messages.is_empty() { + return prompt.to_string(); + } + let recent_messages = recent_main_messages.join("\n\n"); + format!("Recent main-thread messages:\n{recent_messages}\n\nOriginal loop prompt:\n{prompt}") +} + +pub fn build_loop_result_user_message_with_action(result: &str, action: Option<&str>) -> String { + let Some(action) = action.map(str::trim).filter(|action| !action.is_empty()) else { + return result.to_string(); + }; + format!("{result}\n\nAdditional action:\n{action}") +} + +pub fn next_due_for_timer(timer: &PersistedLoopTimer, now: DateTime) -> Option> { + if !timer.enabled { + return None; + } + match timer.last_scheduled_at_unix_seconds { + Some(last_scheduled_at) => Some(timer.schedule.next_due_after(last_scheduled_at, now)), + None => Some(timer.schedule.first_due_after_creation(now)), + } +} + +pub fn load_loop_timers(cwd: &Path) -> std::io::Result { + let path = loop_timers_path(cwd); + if !path.exists() { + return Ok(PersistedLoopTimersFile { timers: Vec::new() }); + } + let contents = fs::read_to_string(path)?; + serde_json::from_str(&contents).map_err(std::io::Error::other) +} + +pub fn loop_timers_path(cwd: &Path) -> PathBuf { + cwd.join(".codex").join(LOOP_TIMER_FILE_NAME) +} + +pub fn format_timestamp(unix_seconds: i64) -> String { + unix_seconds_to_utc(unix_seconds) + .map(|timestamp| timestamp.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| unix_seconds.to_string()) +} + +fn unix_seconds_to_utc(unix_seconds: i64) -> Option> { + Utc.timestamp_opt(unix_seconds, 0).single() +} diff --git a/codex-rs/threadmessages/Cargo.toml b/codex-rs/threadmessages/Cargo.toml new file mode 100644 index 000000000..b88c83870 --- /dev/null +++ b/codex-rs/threadmessages/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-threadmessages" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_threadmessages" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/threadmessages/src/lib.rs b/codex-rs/threadmessages/src/lib.rs new file mode 100644 index 000000000..0aa80583f --- /dev/null +++ b/codex-rs/threadmessages/src/lib.rs @@ -0,0 +1,123 @@ +use serde::Deserialize; +use serde::Serialize; + +const MAX_PREVIEW_CHARS: usize = 120; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum JumpTargetKind { + UserMessage, + AgentMessage, + Reasoning, + ToolCall, + Event, +} + +impl JumpTargetKind { + pub fn display_name(self) -> &'static str { + match self { + Self::UserMessage => "User Message", + Self::AgentMessage => "Agent Message", + Self::Reasoning => "Reasoning", + Self::ToolCall => "Tool Call", + Self::Event => "Event", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JumpTarget { + pub cell_index: usize, + pub ordinal: usize, + pub kind: JumpTargetKind, + pub title: String, + pub preview: String, +} + +impl JumpTarget { + pub fn new( + cell_index: usize, + ordinal: usize, + kind: JumpTargetKind, + preview: impl Into, + ) -> Self { + let preview = normalize_preview(preview.into()); + Self { + cell_index, + ordinal, + kind, + title: format!("{} {ordinal}", kind.display_name()), + preview, + } + } + + pub fn search_value(&self) -> String { + format!("{} {}", self.title, self.preview) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct JumpCatalog { + pub targets: Vec, +} + +impl JumpCatalog { + pub fn new(targets: Vec) -> Self { + Self { targets } + } + + pub fn is_empty(&self) -> bool { + self.targets.is_empty() + } + + pub fn len(&self) -> usize { + self.targets.len() + } +} + +fn normalize_preview(preview: String) -> String { + let collapsed = preview.split_whitespace().collect::>().join(" "); + let mut normalized = collapsed.trim().to_string(); + if normalized.len() > MAX_PREVIEW_CHARS { + normalized.truncate(MAX_PREVIEW_CHARS); + normalized.push_str("..."); + } + normalized +} + +#[cfg(test)] +mod tests { + use super::JumpCatalog; + use super::JumpTarget; + use super::JumpTargetKind; + use pretty_assertions::assert_eq; + + #[test] + fn jump_target_normalizes_preview_whitespace() { + let target = JumpTarget::new( + 4, + 2, + JumpTargetKind::AgentMessage, + " first line\n second line ", + ); + + assert_eq!(target.title, "Agent Message 2"); + assert_eq!(target.preview, "first line second line"); + assert_eq!( + target.search_value(), + "Agent Message 2 first line second line" + ); + } + + #[test] + fn jump_catalog_reports_size() { + let catalog = JumpCatalog::new(vec![JumpTarget::new( + 0, + 1, + JumpTargetKind::UserMessage, + "hello", + )]); + + assert_eq!(catalog.len(), 1); + assert!(!catalog.is_empty()); + } +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 4613ddcf8..47b2d7822 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -29,25 +29,30 @@ base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-ansi-escape = { workspace = true } +codex-accounts = { workspace = true } codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-backend-client = { workspace = true } codex-chatgpt = { workspace = true } codex-client = { workspace = true } +codex-btw = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } +codex-ext = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-loop = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } +codex-threadmessages = { workspace = true } codex-tui-app-server = { workspace = true } codex-utils-approval-presets = { workspace = true } codex-utils-absolute-path = { workspace = true } @@ -60,6 +65,7 @@ codex-utils-sleep-inhibitor = { workspace = true } codex-utils-string = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } +cron = "0.16.0" derive_more = { workspace = true, features = ["is_variant"] } diffy = { workspace = true } dirs = { workspace = true } @@ -110,6 +116,7 @@ unicode-width = { workspace = true } url = { workspace = true } webbrowser = { workspace = true } uuid = { workspace = true } +which = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } @@ -122,7 +129,6 @@ hound = { version = "3.5", optional = true } libc = { workspace = true } [target.'cfg(windows)'.dependencies] -which = { workspace = true } windows-sys = { version = "0.52", features = [ "Win32_Foundation", "Win32_System_Console", diff --git a/codex-rs/tui_app_server/Cargo.toml b/codex-rs/tui_app_server/Cargo.toml index b05b498ac..5e27f5141 100644 --- a/codex-rs/tui_app_server/Cargo.toml +++ b/codex-rs/tui_app_server/Cargo.toml @@ -34,23 +34,29 @@ base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-ansi-escape = { workspace = true } +codex-accounts = { workspace = true } codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } +codex-backend-client = { workspace = true } codex-chatgpt = { workspace = true } codex-client = { workspace = true } +codex-btw = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } +codex-ext = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-loop = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } +codex-threadmessages = { workspace = true } codex-utils-approval-presets = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } @@ -112,6 +118,7 @@ unicode-width = { workspace = true } url = { workspace = true } webbrowser = { workspace = true } uuid = { workspace = true } +which = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } @@ -124,7 +131,6 @@ hound = { version = "3.5", optional = true } libc = { workspace = true } [target.'cfg(windows)'.dependencies] -which = { workspace = true } windows-sys = { version = "0.52", features = [ "Win32_Foundation", "Win32_System_Console", From 27a54e5702029f83548cf303ed9e28bc08c4d448 Mon Sep 17 00:00:00 2001 From: piping Date: Thu, 26 Mar 2026 17:58:55 +0800 Subject: [PATCH 03/25] feat: extend core collaboration and account runtime --- codex-rs/app-server/README.md | 3 + .../app-server/tests/suite/v2/turn_start.rs | 11 +- codex-rs/cli/src/login.rs | 216 +++++++- codex-rs/cli/src/main.rs | 14 +- codex-rs/core/config.schema.json | 75 ++- codex-rs/core/src/config/config_tests.rs | 109 ++++ codex-rs/core/src/config/mod.rs | 18 + codex-rs/core/src/config/types.rs | 60 ++ .../collaboration_mode_presets.rs | 12 +- .../collaboration_mode_presets_tests.rs | 14 +- codex-rs/core/src/thread_manager.rs | 23 + codex-rs/core/src/thread_manager_tests.rs | 32 ++ codex-rs/core/src/tools/handlers/mod.rs | 1 + .../core/src/tools/handlers/multi_agents.rs | 410 +++++++++++++- .../src/tools/handlers/multi_agents/spawn.rs | 4 + .../src/tools/handlers/multi_agents_tests.rs | 80 +++ .../src/tools/handlers/request_user_input.rs | 124 ++++- .../handlers/request_user_input_tests.rs | 26 + codex-rs/core/src/tools/spec.rs | 523 ++++++++++-------- codex-rs/core/src/tools/spec_tests.rs | 24 + codex-rs/login/src/server.rs | 3 +- docs/config.md | 17 + 22 files changed, 1540 insertions(+), 259 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1ca468827..8a691f6a5 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -996,6 +996,9 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives. ### request_user_input +The built-in `question` tool and the legacy `request_user_input` tool both use this request/response flow. +By default, `question` is available in both Default and Plan mode; `request_user_input` remains a legacy compatibility tool that is always available in Plan mode and only available in Default mode when `default_mode_request_user_input` is enabled. + 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. ### 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 8d7ca0261..21f3b08f9 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,10 @@ 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` tool is available in Default mode. The legacy `request_user_input` tool is unavailable in Default mode and will return an error.") + ); Ok(()) } @@ -671,7 +674,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/cli/src/login.rs b/codex-rs/cli/src/login.rs index d0cc1a3a1..3ab7f375e 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -7,10 +7,15 @@ //! into a one-shot CLI command while still producing a durable `codex-login.log` artifact that //! support can request from users. +use codex_accounts::AccountPoolStore; +use codex_accounts::ManagedAccountSnapshot; +use codex_accounts::persist_current_managed_account_snapshot; +use codex_accounts::persist_managed_account_auth_snapshot; use codex_core::CodexAuth; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::AuthMode; use codex_core::auth::CLIENT_ID; +use codex_core::auth::load_auth_dot_json; use codex_core::auth::login_with_api_key; use codex_core::auth::logout; use codex_core::config::Config; @@ -22,7 +27,10 @@ use codex_utils_cli::CliConfigOverrides; use std::fs::OpenOptions; use std::io::IsTerminal; use std::io::Read; +use std::path::Path; use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use tracing_appender::non_blocking; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::EnvFilter; @@ -110,13 +118,67 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { ); } +fn now_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .unwrap_or(i64::MAX) +} + +fn upsert_managed_account_snapshot( + codex_home: &Path, + snapshot: &ManagedAccountSnapshot, + set_active: bool, +) -> std::io::Result<()> { + let account_id = snapshot.profile.id.clone(); + AccountPoolStore::new(codex_home.to_path_buf()).update(|state| { + state.upsert_account(snapshot.profile.clone()); + if set_active { + state.set_active_account(&account_id, now_timestamp()); + } + })?; + Ok(()) +} + +fn snapshot_existing_managed_account_before_login( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let Some(snapshot) = + persist_current_managed_account_snapshot(codex_home, auth_credentials_store_mode)? + else { + return Ok(()); + }; + upsert_managed_account_snapshot(codex_home, &snapshot, /*set_active*/ false) +} + +fn register_current_managed_account_after_login( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + managed_account_alias: Option<&str>, +) -> std::io::Result<()> { + let Some(auth) = load_auth_dot_json(codex_home, auth_credentials_store_mode)? else { + return Ok(()); + }; + let Some(snapshot) = + persist_managed_account_auth_snapshot(codex_home, &auth, managed_account_alias)? + else { + return Ok(()); + }; + upsert_managed_account_snapshot(codex_home, &snapshot, /*set_active*/ true) +} + pub async fn login_with_chatgpt( codex_home: PathBuf, forced_chatgpt_workspace_id: Option, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + managed_account_alias: Option, ) -> std::io::Result<()> { + snapshot_existing_managed_account_before_login(&codex_home, cli_auth_credentials_store_mode)?; + let opts = ServerOptions::new( - codex_home, + codex_home.clone(), CLIENT_ID.to_string(), forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, @@ -125,10 +187,18 @@ pub async fn login_with_chatgpt( print_login_server_start(server.actual_port, &server.auth_url); - server.block_until_done().await + server.block_until_done().await?; + register_current_managed_account_after_login( + &codex_home, + cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) } -pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { +pub async fn run_login_with_chatgpt( + cli_config_overrides: CliConfigOverrides, + managed_account_alias: Option, +) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting browser login flow"); @@ -144,6 +214,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> config.codex_home, forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + managed_account_alias, ) .await { @@ -219,6 +290,7 @@ pub async fn run_login_with_device_code( cli_config_overrides: CliConfigOverrides, issuer_base_url: Option, client_id: Option, + managed_account_alias: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); @@ -227,6 +299,15 @@ pub async fn run_login_with_device_code( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + if let Err(err) = snapshot_existing_managed_account_before_login( + &config.codex_home, + config.cli_auth_credentials_store_mode, + ) { + eprintln!("Error preparing managed account snapshots: {err}"); + std::process::exit(1); + } + + let codex_home = config.codex_home.clone(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home, @@ -239,6 +320,14 @@ pub async fn run_login_with_device_code( } match run_device_code_login(opts).await { Ok(()) => { + if let Err(err) = register_current_managed_account_after_login( + &codex_home, + config.cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) { + eprintln!("Error finalizing managed account login: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -257,6 +346,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( cli_config_overrides: CliConfigOverrides, issuer_base_url: Option, client_id: Option, + managed_account_alias: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); @@ -265,7 +355,15 @@ pub async fn run_login_with_device_code_fallback_to_browser( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + if let Err(err) = snapshot_existing_managed_account_before_login( + &config.codex_home, + config.cli_auth_credentials_store_mode, + ) { + eprintln!("Error preparing managed account snapshots: {err}"); + std::process::exit(1); + } + let codex_home = config.codex_home.clone(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home, @@ -280,6 +378,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( match run_device_code_login(opts.clone()).await { Ok(()) => { + if let Err(err) = register_current_managed_account_after_login( + &codex_home, + config.cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) { + eprintln!("Error finalizing managed account login: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -291,6 +397,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( print_login_server_start(server.actual_port, &server.auth_url); match server.block_until_done().await { Ok(()) => { + if let Err(err) = register_current_managed_account_after_login( + &codex_home, + config.cli_auth_credentials_store_mode, + managed_account_alias.as_deref(), + ) { + eprintln!("Error finalizing managed account login: {err}"); + std::process::exit(1); + } eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } @@ -392,6 +506,20 @@ fn safe_format_key(key: &str) -> String { #[cfg(test)] mod tests { + use codex_app_server_protocol::AuthMode as ApiAuthMode; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use codex_ext::AccountPoolStore; + use codex_ext::ManagedAccountAuthStore; + use pretty_assertions::assert_eq; + use serde_json::json; + use tempfile::tempdir; + + use crate::login::register_current_managed_account_after_login; + use crate::login::snapshot_existing_managed_account_before_login; + use super::safe_format_key; #[test] @@ -405,4 +533,86 @@ mod tests { let key = "sk-proj-12345"; assert_eq!(safe_format_key(key), "***"); } + + fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String { + let header = json!({"alg":"none","typ":"JWT"}); + let payload = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |value: serde_json::Value| -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&value).expect("serialize")) + }; + format!("{}.{}.sig", encode(header), encode(payload)) + } + + fn chatgpt_auth(account_id: &str, email: &str) -> AuthDotJson { + AuthDotJson { + auth_mode: Some(ApiAuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&fake_jwt( + email, account_id, "pro", + )) + .expect("id token"), + access_token: fake_jwt(email, account_id, "pro"), + refresh_token: "refresh-token".to_string(), + account_id: Some(account_id.to_string()), + }), + last_refresh: None, + } + } + + #[test] + fn snapshot_existing_managed_account_before_login_copies_root_auth() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("acct-existing", "existing@example.com"); + save_auth(tempdir.path(), &auth, AuthCredentialsStoreMode::File).expect("save root auth"); + + snapshot_existing_managed_account_before_login( + tempdir.path(), + AuthCredentialsStoreMode::File, + ) + .expect("snapshot existing auth"); + + let managed_auth = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()) + .load_account_auth("acct-existing") + .expect("managed auth"); + let pool = AccountPoolStore::new(tempdir.path().to_path_buf()) + .load() + .expect("load account pool"); + + assert_eq!(managed_auth, auth); + assert_eq!(pool.accounts[0].alias, "acct-existing"); + } + + #[test] + fn register_current_managed_account_after_login_saves_alias_and_marks_active() { + let tempdir = tempdir().expect("tempdir"); + let auth = chatgpt_auth("acct-primary", "primary@example.com"); + save_auth(tempdir.path(), &auth, AuthCredentialsStoreMode::File).expect("save root auth"); + + register_current_managed_account_after_login( + tempdir.path(), + AuthCredentialsStoreMode::File, + Some("Primary"), + ) + .expect("register managed account"); + + let managed_auth = ManagedAccountAuthStore::new(tempdir.path().to_path_buf()) + .load_account_auth("acct-primary") + .expect("managed auth"); + let pool = AccountPoolStore::new(tempdir.path().to_path_buf()) + .load() + .expect("load account pool"); + + assert_eq!(managed_auth, auth); + assert_eq!(pool.active_account_id.as_deref(), Some("acct-primary")); + assert_eq!(pool.accounts[0].alias, "Primary"); + } } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 12a531d35..56fd0fabf 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -279,6 +279,13 @@ struct LoginCommand { #[clap(skip)] config_overrides: CliConfigOverrides, + #[arg( + long = "auth", + value_name = "ALIAS", + help = "Save this ChatGPT login into the managed account pool with the given alias" + )] + auth: Option, + #[arg( long = "with-api-key", help = "Read the API key from stdin (e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`)" @@ -805,6 +812,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { login_cli.config_overrides, login_cli.issuer_base_url, login_cli.client_id, + login_cli.auth, ) .await; } else if login_cli.api_key.is_some() { @@ -813,10 +821,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ); std::process::exit(1); } else if login_cli.with_api_key { + if login_cli.auth.is_some() { + eprintln!("The --auth flag is only supported for ChatGPT login flows."); + std::process::exit(1); + } let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; } else { - run_login_with_chatgpt(login_cli.config_overrides).await; + run_login_with_chatgpt(login_cli.config_overrides, login_cli.auth).await; } } } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1d1d6dd9a..35aab2871 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1670,6 +1670,31 @@ "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, + "display_preferences": { + "allOf": [ + { + "$ref": "#/definitions/TuiDisplayPreferences" + } + ], + "default": { + "show_exec_commands": true, + "show_patch_diffs": true, + "show_tool_results": true, + "show_waited_messages": true + }, + "description": "Transcript visibility preferences that affect only TUI rendering." + }, + "loop": { + "allOf": [ + { + "$ref": "#/definitions/TuiLoopConfig" + } + ], + "default": { + "completion_mirror_mode": "prompt-and-response" + }, + "description": "Controls how completed `/loop` runs are mirrored back into the main thread." + }, "model_availability_nux": { "allOf": [ { @@ -1726,6 +1751,54 @@ }, "type": "object" }, + "TuiDisplayPreferences": { + "additionalProperties": false, + "properties": { + "show_exec_commands": { + "default": true, + "description": "Show command execution transcript cells. Defaults to `true`.", + "type": "boolean" + }, + "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" + }, + "show_waited_messages": { + "default": true, + "description": "Show \"Waited for ...\" transcript messages for background terminal interactions. Defaults to `true`.", + "type": "boolean" + } + }, + "type": "object" + }, + "TuiLoopCompletionMirrorMode": { + "enum": [ + "prompt-and-response", + "response-only" + ], + "type": "string" + }, + "TuiLoopConfig": { + "additionalProperties": false, + "properties": { + "completion_mirror_mode": { + "allOf": [ + { + "$ref": "#/definitions/TuiLoopCompletionMirrorMode" + } + ], + "default": "prompt-and-response", + "description": "Controls what `/loop` mirrors back into the main thread after a scheduled run finishes. Defaults to `prompt-and-response`." + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { @@ -2574,4 +2647,4 @@ }, "title": "ConfigToml", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7d2f6c573..7ffc23242 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -14,6 +14,9 @@ use crate::config::types::ModelAvailabilityNuxConfig; use crate::config::types::NotificationMethod; use crate::config::types::Notifications; use crate::config::types::ToolSuggestDiscoverableType; +use crate::config::types::TuiDisplayPreferences; +use crate::config::types::TuiLoopCompletionMirrorMode; +use crate::config::types::TuiLoopConfig; use crate::config_loader::RequirementSource; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; @@ -270,6 +273,8 @@ fn config_toml_deserializes_model_availability_nux() { ("gpt-foo".to_string(), 2), ]), }, + loop_config: TuiLoopConfig::default(), + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -289,6 +294,60 @@ fn runtime_config_defaults_model_availability_nux() { ); } +#[test] +fn runtime_config_loads_tui_display_preferences() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + display_preferences: TuiDisplayPreferences { + show_tool_results: false, + show_exec_commands: false, + show_waited_messages: false, + show_patch_diffs: true, + }, + ..Tui::default() + }), + ..ConfigToml::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").path().to_path_buf(), + ) + .expect("load config"); + + assert_eq!( + cfg.tui_display_preferences, + TuiDisplayPreferences { + show_tool_results: false, + show_exec_commands: false, + show_waited_messages: false, + show_patch_diffs: true, + } + ); +} + +#[test] +fn runtime_config_loads_tui_loop_completion_mirror_mode() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + loop_config: TuiLoopConfig { + completion_mirror_mode: TuiLoopCompletionMirrorMode::ResponseOnly, + }, + ..Tui::default() + }), + ..ConfigToml::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").path().to_path_buf(), + ) + .expect("load config"); + + assert_eq!( + cfg.tui_loop_completion_mirror_mode, + TuiLoopCompletionMirrorMode::ResponseOnly + ); +} + #[test] fn config_toml_deserializes_permission_profiles() { let toml = r#" @@ -948,6 +1007,8 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig::default(), + loop_config: TuiLoopConfig::default(), + display_preferences: TuiDisplayPreferences::default(), } ); } @@ -4466,6 +4527,8 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode::PromptAndResponse, + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4609,6 +4672,8 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode::PromptAndResponse, + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4750,6 +4815,8 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode::PromptAndResponse, + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(false), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -4877,6 +4944,8 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode::PromptAndResponse, + tui_display_preferences: TuiDisplayPreferences::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -6233,6 +6302,10 @@ struct TuiTomlTest { notifications: Notifications, #[serde(default)] notification_method: NotificationMethod, + #[serde(default, rename = "loop")] + loop_config: TuiLoopConfig, + #[serde(default)] + display_preferences: TuiDisplayPreferences, } #[derive(Deserialize, Debug, PartialEq)] @@ -6273,3 +6346,39 @@ fn test_tui_notification_method() { toml::from_str(toml).expect("deserialize notification_method=\"bel\""); assert_eq!(parsed.tui.notification_method, NotificationMethod::Bel); } + +#[test] +fn test_tui_display_preferences() { + let toml = r#" + [tui.display_preferences] + show_tool_results = false + show_exec_commands = false + show_waited_messages = false + show_patch_diffs = true + "#; + let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize tui.display_preferences"); + assert_eq!( + parsed.tui.display_preferences, + TuiDisplayPreferences { + show_tool_results: false, + show_exec_commands: false, + show_waited_messages: false, + show_patch_diffs: true, + } + ); +} + +#[test] +fn test_tui_loop_completion_mirror_mode() { + let toml = r#" + [tui.loop] + completion_mirror_mode = "response-only" + "#; + let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize tui.loop"); + assert_eq!( + parsed.tui.loop_config, + TuiLoopConfig { + completion_mirror_mode: TuiLoopCompletionMirrorMode::ResponseOnly, + } + ); +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b2b55feec..9bdb6c023 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -24,6 +24,8 @@ use crate::config::types::SkillsConfig; use crate::config::types::ToolSuggestConfig; use crate::config::types::ToolSuggestDiscoverable; use crate::config::types::Tui; +use crate::config::types::TuiDisplayPreferences; +use crate::config::types::TuiLoopCompletionMirrorMode; use crate::config::types::UriBasedFileOpener; use crate::config::types::WindowsSandboxModeToml; use crate::config::types::WindowsToml; @@ -350,6 +352,12 @@ pub struct Config { /// Persisted startup availability NUX state for model tooltips. pub model_availability_nux: ModelAvailabilityNuxConfig, + /// Transcript visibility preferences that affect only TUI rendering. + pub tui_display_preferences: TuiDisplayPreferences, + + /// Controls what completed `/loop` runs mirror back into the main thread. + pub tui_loop_completion_mirror_mode: TuiLoopCompletionMirrorMode, + /// Start the TUI in the specified collaboration mode (plan/default). /// Controls whether the TUI uses the terminal's alternate screen buffer. @@ -2719,6 +2727,16 @@ impl Config { .as_ref() .map(|t| t.model_availability_nux.clone()) .unwrap_or_default(), + tui_loop_completion_mirror_mode: cfg + .tui + .as_ref() + .map(|t| t.loop_config.completion_mirror_mode) + .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/config/types.rs b/codex-rs/core/src/config/types.rs index a69fec343..88ff2aac3 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -736,6 +736,58 @@ pub struct ModelAvailabilityNuxConfig { pub shown_count: HashMap, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TuiLoopCompletionMirrorMode { + #[default] + PromptAndResponse, + ResponseOnly, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiLoopConfig { + /// Controls what `/loop` mirrors back into the main thread after a scheduled run finishes. + /// Defaults to `prompt-and-response`. + #[serde(default)] + pub completion_mirror_mode: TuiLoopCompletionMirrorMode, +} + +#[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 command execution transcript cells. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_exec_commands: bool, + + /// Show "Waited for ..." transcript messages for background terminal interactions. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_waited_messages: 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_exec_commands: true, + show_waited_messages: 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)] @@ -796,6 +848,14 @@ pub struct Tui { /// Startup tooltip availability NUX state persisted by the TUI. #[serde(default)] pub model_availability_nux: ModelAvailabilityNuxConfig, + + /// Controls how completed `/loop` runs are mirrored back into the main thread. + #[serde(default, rename = "loop")] + pub loop_config: TuiLoopConfig, + + /// Transcript visibility preferences that affect only TUI rendering. + #[serde(default)] + pub display_preferences: TuiDisplayPreferences, } const fn default_true() -> bool { diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index dceab9f3b..0dbf0f412 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -84,21 +84,23 @@ fn request_user_input_availability_message( ) -> String { let mode_name = mode.display_name(); if mode.allows_request_user_input() - || (default_mode_request_user_input && mode == ModeKind::Default) + || (mode == ModeKind::Default && default_mode_request_user_input) { - format!("The `request_user_input` tool is available in {mode_name} mode.") + format!("The `question` and `request_user_input` tools are available in {mode_name} mode.") + } else if mode == ModeKind::Default { + "The `question` tool is available in Default mode. The legacy `request_user_input` tool is unavailable in Default mode and will return an error.".to_string() } 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` and `request_user_input` tools are unavailable in {mode_name} mode. If you call either tool while in {mode_name} mode, it 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 (or the legacy `request_user_input` tool) rather than writing a multiple choice question as a textual assistant message. Use `question` when you need a larger structured form or a mix of choices and freeform fields. 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. Use `question` when you need a larger structured form or a mix of choices and freeform fields. 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/core/src/models_manager/collaboration_mode_presets_tests.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets_tests.rs index b0969f6eb..87c19406b 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets_tests.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets_tests.rs @@ -34,7 +34,8 @@ fn default_mode_instructions_replace_mode_names_placeholder() { let expected_availability_message = request_user_input_availability_message(ModeKind::Default, 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("Use `question` when you need a larger structured form")); } #[test] @@ -44,8 +45,11 @@ fn default_mode_instructions_use_plain_text_questions_when_feature_disabled() { .expect("default preset should include instructions") .expect("default instructions should be set"); - assert!(!default_instructions.contains("prefer using the `request_user_input` tool")); - assert!( - default_instructions.contains("ask the user directly with a concise plain-text question") - ); + assert!(default_instructions.contains( + "The `question` tool is available in Default mode. The legacy `request_user_input` tool is unavailable in Default mode and will return an error." + )); + assert!(default_instructions.contains("prefer using the `question` tool")); + assert!(default_instructions.contains( + "For a single simple clarification, ask the user directly with a concise plain-text question" + )); } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 7f272f959..9eddf23a5 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -452,6 +452,29 @@ impl ThreadManager { .await } + pub async fn start_thread_with_history_and_source( + &self, + config: Config, + initial_history: InitialHistory, + session_source: SessionSource, + ) -> CodexResult { + Box::pin(self.state.spawn_thread_with_source( + config, + initial_history, + Arc::clone(&self.state.auth_manager), + self.agent_control(), + session_source, + Vec::new(), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + /*inherited_shell_snapshot*/ None, + /*inherited_exec_policy*/ None, + /*parent_trace*/ None, + /*user_shell_override*/ None, + )) + .await + } + pub async fn resume_thread_from_rollout( &self, config: Config, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index c80f5f743..d1241e351 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -271,6 +271,38 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { assert!(manager.list_thread_ids().await.is_empty()); } +#[tokio::test] +async fn start_thread_with_history_and_source_uses_requested_session_source() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.clone(); + config.ephemeral = true; + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + + let thread = manager + .start_thread_with_history_and_source( + config, + InitialHistory::New, + SessionSource::Custom("btw".to_string()), + ) + .await + .expect("start btw thread"); + + let snapshot = thread.thread.config_snapshot().await; + assert_eq!( + snapshot.session_source, + SessionSource::Custom("btw".to_string()) + ); + assert!(snapshot.ephemeral); +} + #[tokio::test] async fn new_uses_configured_openai_provider_for_model_refresh() { let server = MockServer::start().await; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index ba0a0eb50..2436265bc 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -48,6 +48,7 @@ pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; +pub(crate) use request_user_input::question_tool_description; pub(crate) use request_user_input::request_user_input_tool_description; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 0bf7b7bcd..18ef218a8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -11,33 +11,47 @@ use crate::agent::agent_resolver::resolve_agent_targets; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; use crate::codex::TurnContext; +use crate::config::Config; +use crate::error::CodexErr; use crate::function_tool::FunctionCallError; +use crate::models_manager::manager::RefreshStrategy; +use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; -pub(crate) use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; +use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CollabCloseBeginEvent; use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; pub(crate) use close_agent::Handler as CloseAgentHandler; pub(crate) use resume_agent::Handler as ResumeAgentHandler; @@ -45,12 +59,406 @@ pub(crate) use send_input::Handler as SendInputHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; +/// Minimum wait timeout to prevent tight polling loops from burning CPU. +pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; +pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; +pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; + +fn function_arguments(payload: ToolPayload) -> Result { + match payload { + ToolPayload::Function { arguments } => Ok(arguments), + _ => Err(FunctionCallError::RespondToModel( + "collab handler received unsupported payload".to_string(), + )), + } +} + +fn tool_output_json_text(value: &T, tool_name: &str) -> String +where + T: Serialize, +{ + serde_json::to_string(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string() + }) +} + +fn tool_output_response_item( + call_id: &str, + payload: &ToolPayload, + value: &T, + success: Option, + tool_name: &str, +) -> ResponseInputItem +where + T: Serialize, +{ + FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success) + .to_response_item(call_id, payload) +} + +fn tool_output_code_mode_result(value: &T, tool_name: &str) -> JsonValue +where + T: Serialize, +{ + serde_json::to_value(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")) + }) +} + pub mod close_agent; mod resume_agent; mod send_input; mod spawn; pub(crate) mod wait; +fn build_wait_agent_statuses( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut entries = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if let Some(status) = statuses.get(&receiver_agent.thread_id) { + entries.push(CollabAgentStatusEntry { + thread_id: receiver_agent.thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + status: status.clone(), + }); + } + } + + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains_key(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries +} + +fn collab_spawn_error(err: CodexErr) -> FunctionCallError { + match err { + CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message), + err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), + } +} + +fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { + match err { + CodexErr::ThreadNotFound(id) => { + FunctionCallError::RespondToModel(format!("agent with id {id} not found")) + } + CodexErr::InternalAgentDied => { + FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) + } + CodexErr::UnsupportedOperation(_) => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), + } +} + +fn thread_spawn_source( + parent_thread_id: ThreadId, + parent_session_source: &SessionSource, + depth: i32, + agent_role: Option<&str>, + task_name: Option, +) -> Result { + let agent_path = task_name + .as_deref() + .map(|task_name| { + parent_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root) + .join(task_name) + .map_err(FunctionCallError::RespondToModel) + }) + .transpose()?; + Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname: None, + agent_role: agent_role.map(str::to_string), + })) +} + +fn parse_collab_input( + message: Option, + items: Option>, +) -> Result, FunctionCallError> { + match (message, items) { + (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel( + "Provide either message or items, but not both".to_string(), + )), + (None, None) => Err(FunctionCallError::RespondToModel( + "Provide one of: message or items".to_string(), + )), + (Some(message), None) => { + if message.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "Empty message can't be sent to an agent".to_string(), + )); + } + Ok(vec![UserInput::Text { + text: message, + text_elements: Vec::new(), + }]) + } + (None, Some(items)) => { + if items.is_empty() { + return Err(FunctionCallError::RespondToModel( + "Items can't be empty".to_string(), + )); + } + Ok(items) + } + } +} + +fn input_preview(items: &[UserInput]) -> String { + let parts: Vec = items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_string(), + UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), + UserInput::Skill { name, path } => { + format!("[skill:${name}]({})", path.display()) + } + UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), + _ => "[input]".to_string(), + }) + .collect(); + + parts.join("\n") +} + +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 +/// runtime-owned fields carried on `turn`, including model selection, reasoning settings, +/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step; +/// skipping this helper and cloning stale config state directly can send the child agent out with +/// the wrong provider or runtime policy. +pub(crate) fn build_agent_spawn_config( + base_instructions: &BaseInstructions, + turn: &TurnContext, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + config.base_instructions = Some(base_instructions.text.clone()); + Ok(config) +} + +fn build_agent_resume_config( + turn: &TurnContext, + child_depth: i32, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + apply_spawn_agent_overrides(&mut config, child_depth); + // For resume, keep base instructions sourced from rollout/session metadata. + config.base_instructions = None; + Ok(config) +} + +fn build_agent_shared_config(turn: &TurnContext) -> Result { + let base_config = turn.config.clone(); + let mut config = (*base_config).clone(); + config.model = Some(turn.model_info.slug.clone()); + config.model_provider = turn.provider.clone(); + config.model_reasoning_effort = turn.reasoning_effort; + config.model_reasoning_summary = Some(turn.reasoning_summary); + config.developer_instructions = turn.developer_instructions.clone(); + config.compact_prompt = turn.compact_prompt.clone(); + apply_spawn_agent_runtime_overrides(&mut config, turn)?; + + Ok(config) +} + +/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. +/// +/// These values are chosen by the live turn rather than persisted config, so leaving them stale +/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing. +fn apply_spawn_agent_runtime_overrides( + config: &mut Config, + turn: &TurnContext, +) -> Result<(), FunctionCallError> { + config + .permissions + .approval_policy + .set(turn.approval_policy.value()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) + })?; + config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); + config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); + config.cwd = turn.cwd.clone(); + config + .permissions + .sandbox_policy + .set(turn.sandbox_policy.get().clone()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) + })?; + config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone(); + config.permissions.network_sandbox_policy = turn.network_sandbox_policy; + Ok(()) +} + +fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { + if child_depth >= config.agent_max_depth { + let _ = config.features.disable(Feature::SpawnCsv); + let _ = config.features.disable(Feature::Collab); + } +} + +async fn apply_requested_spawn_agent_model_overrides( + session: &Session, + turn: &TurnContext, + config: &mut Config, + requested_model: Option<&str>, + requested_reasoning_effort: Option, +) -> Result<(), FunctionCallError> { + if requested_model.is_none() && requested_reasoning_effort.is_none() { + return Ok(()); + } + + if let Some(requested_model) = requested_model { + let available_models = session + .services + .models_manager + .list_models(RefreshStrategy::Offline) + .await; + let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; + let selected_model_info = session + .services + .models_manager + .get_model_info(&selected_model_name, config) + .await; + + config.model = Some(selected_model_name.clone()); + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &selected_model_name, + &selected_model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } else { + config.model_reasoning_effort = selected_model_info.default_reasoning_level; + } + + return Ok(()); + } + + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &turn.model_info.slug, + &turn.model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } + + Ok(()) +} + +fn find_spawn_agent_model_name( + available_models: &[codex_protocol::openai_models::ModelPreset], + requested_model: &str, +) -> Result { + available_models + .iter() + .find(|model| model.model == requested_model) + .map(|model| model.model.clone()) + .ok_or_else(|| { + let available = available_models + .iter() + .map(|model| model.model.as_str()) + .collect::>() + .join(", "); + FunctionCallError::RespondToModel(format!( + "Unknown model `{requested_model}` for spawn_agent. Available models: {available}" + )) + }) +} + +fn validate_spawn_agent_reasoning_effort( + model: &str, + supported_reasoning_levels: &[ReasoningEffortPreset], + requested_reasoning_effort: ReasoningEffort, +) -> Result<(), FunctionCallError> { + if supported_reasoning_levels + .iter() + .any(|preset| preset.effort == requested_reasoning_effort) + { + return Ok(()); + } + + let supported = supported_reasoning_levels + .iter() + .map(|preset| preset.effort.to_string()) + .collect::>() + .join(", "); + Err(FunctionCallError::RespondToModel(format!( + "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" + ))) +} + #[cfg(test)] #[path = "multi_agents_tests.rs"] mod tests; 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 53ab4d35f..2857ca57f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -72,6 +72,9 @@ 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 = cwd; + } apply_spawn_agent_overrides(&mut config, child_depth); let result = session @@ -176,6 +179,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_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 77ed20dd8..ff0d19d87 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -320,6 +320,73 @@ async fn spawn_agent_includes_task_name_key_when_not_named() { 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().to_path_buf(); + + 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().to_path_buf(); + + 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() + )) + ); +} + #[tokio::test] async fn spawn_agent_errors_when_manager_dropped() { let (session, turn) = make_session_and_context().await; @@ -2282,6 +2349,19 @@ async fn build_agent_spawn_config_uses_turn_context_values() { assert_eq!(config, expected); } +#[test] +fn resolve_requested_agent_cwd_resolves_relative_path() { + 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"); + + let resolved = resolve_requested_agent_cwd(temp_dir.path(), Some("worker-a")) + .expect("cwd should resolve") + .expect("cwd should be present"); + + assert_eq!(resolved, child_workspace); +} + #[tokio::test] async fn build_agent_spawn_config_preserves_base_user_instructions() { let (_session, mut turn) = make_session_and_context().await; 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 4d95a2c20..af293a5e7 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -10,15 +10,36 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES; use codex_protocol::request_user_input::RequestUserInputArgs; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QuestionOptionsPolicy { + RequireOptions, + AllowFreeform, +} + +fn question_is_available(mode: ModeKind) -> bool { + mode.allows_request_user_input() || mode == ModeKind::Default +} + fn request_user_input_is_available(mode: ModeKind, default_mode_request_user_input: bool) -> bool { mode.allows_request_user_input() || (default_mode_request_user_input && mode == ModeKind::Default) } -fn format_allowed_modes(default_mode_request_user_input: bool) -> String { +fn tool_is_available( + tool_name: &str, + mode: ModeKind, + default_mode_request_user_input: bool, +) -> bool { + match tool_name { + "question" => question_is_available(mode), + _ => request_user_input_is_available(mode, default_mode_request_user_input), + } +} + +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(); @@ -30,6 +51,15 @@ 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}.") +} + pub(crate) fn request_user_input_unavailable_message( mode: ModeKind, default_mode_request_user_input: bool, @@ -44,13 +74,69 @@ pub(crate) fn request_user_input_unavailable_message( } } +pub(crate) 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")) + } +} + pub(crate) 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", + "Request user input for one to three short questions and wait for the response.", + default_mode_request_user_input, ) } +pub(crate) fn question_tool_description(default_mode_request_user_input: bool) -> String { + interactive_question_tool_description( + "question", + "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, + ) +} + +fn question_options_policy(tool_name: &str) -> QuestionOptionsPolicy { + match tool_name { + "question" => QuestionOptionsPolicy::AllowFreeform, + _ => QuestionOptionsPolicy::RequireOptions, + } +} + +fn normalize_question_args( + args: &mut RequestUserInputArgs, + options_policy: QuestionOptionsPolicy, +) -> Result<(), FunctionCallError> { + 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; + } + } + + if options_policy == QuestionOptionsPolicy::RequireOptions + && args + .questions + .iter() + .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)) + { + return Err(FunctionCallError::RespondToModel( + "request_user_input requires non-empty options for every question".to_string(), + )); + } + + Ok(()) +} + pub struct RequestUserInputHandler { pub default_mode_request_user_input: bool, } @@ -68,6 +154,7 @@ impl ToolHandler for RequestUserInputHandler { session, turn, call_id, + tool_name, payload, .. } = invocation; @@ -75,32 +162,23 @@ impl ToolHandler for RequestUserInputHandler { let arguments = match payload { ToolPayload::Function { arguments } => arguments, _ => { - return Err(FunctionCallError::RespondToModel( - "request_user_input handler received unsupported payload".to_string(), - )); + return Err(FunctionCallError::RespondToModel(format!( + "{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" => 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 mut args: RequestUserInputArgs = parse_arguments(&arguments)?; - let missing_options = args - .questions - .iter() - .any(|question| question.options.as_ref().is_none_or(Vec::is_empty)); - if missing_options { - return Err(FunctionCallError::RespondToModel( - "request_user_input requires non-empty options for every question".to_string(), - )); - } - for question in &mut args.questions { - question.is_other = true; - } + normalize_question_args(&mut args, question_options_policy(&tool_name))?; let response = session .request_user_input(turn.as_ref(), call_id, args) .await diff --git a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs index f4df3c43c..ceb8b7da6 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_tests.rs @@ -33,6 +33,20 @@ fn request_user_input_unavailable_messages_respect_default_mode_feature_flag() { ); } +#[test] +fn question_unavailable_messages_allow_default_mode() { + 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_tool_description_mentions_available_modes() { assert_eq!( @@ -44,3 +58,15 @@ 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 question_tool_description_mentions_available_modes() { + assert_eq!( + question_tool_description(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(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() + ); +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 3c10c3b84..2a486499c 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -23,9 +23,10 @@ use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME; use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; -use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::question_tool_description; use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; @@ -167,39 +168,6 @@ fn send_input_output_schema() -> JsonValue { }) } -fn list_agents_output_schema() -> JsonValue { - json!({ - "type": "object", - "properties": { - "agents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "agent_name": { - "type": "string", - "description": "Canonical task name for the agent when available, otherwise the agent id." - }, - "agent_status": { - "description": "Last known status of the agent.", - "allOf": [agent_status_output_schema()] - }, - "last_task_message": { - "type": ["string", "null"], - "description": "Most recent user or inter-agent instruction received by the agent, when available." - } - }, - "required": ["agent_name", "agent_status", "last_task_message"], - "additionalProperties": false - }, - "description": "Live agents visible in the current root thread tree." - } - }, - "required": ["agents"], - "additionalProperties": false - }) -} - fn resume_agent_output_schema() -> JsonValue { json!({ "type": "object", @@ -211,7 +179,7 @@ fn resume_agent_output_schema() -> JsonValue { }) } -fn wait_output_schema_v1() -> JsonValue { +fn wait_output_schema() -> JsonValue { json!({ "type": "object", "properties": { @@ -230,24 +198,6 @@ fn wait_output_schema_v1() -> JsonValue { }) } -fn wait_output_schema_v2() -> JsonValue { - json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Brief wait summary without the agent's final content." - }, - "timed_out": { - "type": "boolean", - "description": "Whether the wait call returned due to timeout before any agent reached a final status." - } - }, - "required": ["message", "timed_out"], - "additionalProperties": false - }) -} - fn close_agent_output_schema() -> JsonValue { json!({ "type": "object", @@ -388,11 +338,8 @@ impl ToolsConfig { let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); - let include_search_tool = - model_info.supports_search_tool && features.enabled(Feature::ToolSearch); - let include_tool_suggest = features.enabled(Feature::ToolSuggest) - && features.enabled(Feature::Apps) - && features.enabled(Feature::Plugins); + let include_search_tool = model_info.supports_search_tool; + let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest); let include_original_image_detail = can_request_original_image_detail(features, model_info); let include_artifact_tools = features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime(); @@ -1186,6 +1133,15 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { ), }, ), + ( + "cwd".to_string(), + JsonSchema::String { + description: Some( + "Optional working directory for the new agent. Defaults to inheriting the parent cwd. Relative paths resolve against the parent cwd." + .to_string(), + ), + }, + ), ]); properties.insert( "task_name".to_string(), @@ -1451,80 +1407,6 @@ fn create_send_input_tool() -> ToolSpec { }) } -fn create_send_message_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "target".to_string(), - JsonSchema::String { - description: Some( - "Agent id or canonical task name to message (from spawn_agent).".to_string(), - ), - }, - ), - ("items".to_string(), create_collab_input_items_schema()), - ( - "interrupt".to_string(), - JsonSchema::Boolean { - description: Some( - "When true, stop the agent's current task and handle this immediately. When false (default), queue this message." - .to_string(), - ), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "send_message".to_string(), - description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["target".to_string(), "items".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: Some(send_input_output_schema()), - }) -} - -fn create_assign_task_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "target".to_string(), - JsonSchema::String { - description: Some( - "Agent id or canonical task name to message (from spawn_agent).".to_string(), - ), - }, - ), - ("items".to_string(), create_collab_input_items_schema()), - ( - "interrupt".to_string(), - JsonSchema::Boolean { - description: Some( - "When true, stop the agent's current task and handle this immediately. When false (default), queue this message." - .to_string(), - ), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "assign_task".to_string(), - description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["target".to_string(), "items".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: Some(send_input_output_schema()), - }) -} - fn create_resume_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -1550,7 +1432,7 @@ fn create_resume_agent_tool() -> ToolSpec { }) } -fn wait_agent_tool_parameters() -> JsonSchema { +fn create_wait_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( "targets".to_string(), @@ -1571,66 +1453,111 @@ fn wait_agent_tool_parameters() -> JsonSchema { }, ); - JsonSchema::Object { - properties, - required: Some(vec!["targets".to_string()]), - additional_properties: Some(false.into()), - } -} - -fn create_wait_agent_tool_v1() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "wait_agent".to_string(), description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." .to_string(), strict: false, defer_loading: None, - parameters: wait_agent_tool_parameters(), - output_schema: Some(wait_output_schema_v1()), + parameters: JsonSchema::Object { + properties, + required: Some(vec!["targets".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(wait_output_schema()), }) } -fn create_wait_agent_tool_v2() -> ToolSpec { - ToolSpec::Function(ResponsesApiTool { - name: "wait_agent".to_string(), - description: "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline." - .to_string(), - strict: false, - defer_loading: None, - parameters: wait_agent_tool_parameters(), - output_schema: Some(wait_output_schema_v2()), - }) -} +fn create_request_user_input_tool( + collaboration_modes_config: CollaborationModesConfig, +) -> ToolSpec { + let mut option_props = BTreeMap::new(); + option_props.insert( + "label".to_string(), + JsonSchema::String { + description: Some("User-facing label (1-5 words).".to_string()), + }, + ); + option_props.insert( + "description".to_string(), + JsonSchema::String { + description: Some( + "One short sentence explaining impact/tradeoff if selected.".to_string(), + ), + }, + ); -fn create_list_agents_tool() -> ToolSpec { - let properties = BTreeMap::from([( - "path_prefix".to_string(), + 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(), + ), + items: Box::new(JsonSchema::Object { + properties: option_props, + required: Some(vec!["label".to_string(), "description".to_string()]), + additional_properties: Some(false.into()), + }), + }; + + let mut question_props = BTreeMap::new(); + question_props.insert( + "id".to_string(), + JsonSchema::String { + description: Some("Stable identifier for mapping answers (snake_case).".to_string()), + }, + ); + question_props.insert( + "header".to_string(), JsonSchema::String { description: Some( - "Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets." - .to_string(), + "Short header label shown in the UI (12 or fewer chars).".to_string(), ), }, - )]); + ); + question_props.insert( + "question".to_string(), + JsonSchema::String { + description: Some("Single-sentence prompt shown to the user.".to_string()), + }, + ); + question_props.insert("options".to_string(), options_schema); + + let questions_schema = JsonSchema::Array { + description: Some( + "Questions to show the user. Use as many as needed for the form.".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(), + ]), + additional_properties: Some(false.into()), + }), + }; + + let mut properties = BTreeMap::new(); + properties.insert("questions".to_string(), questions_schema); ToolSpec::Function(ResponsesApiTool { - name: "list_agents".to_string(), - description: "List live agents in the current root thread tree. Optionally filter by task-path prefix." - .to_string(), + name: "request_user_input".to_string(), + description: request_user_input_tool_description( + collaboration_modes_config.default_mode_request_user_input, + ), strict: false, defer_loading: None, parameters: JsonSchema::Object { properties, - required: None, + required: Some(vec!["questions".to_string()]), additional_properties: Some(false.into()), }, - output_schema: Some(list_agents_output_schema()), + output_schema: None, }) } -fn create_request_user_input_tool( - collaboration_modes_config: CollaborationModesConfig, -) -> ToolSpec { +fn create_question_tool(collaboration_modes_config: CollaborationModesConfig) -> ToolSpec { let mut option_props = BTreeMap::new(); option_props.insert( "label".to_string(), @@ -1649,7 +1576,7 @@ fn create_request_user_input_tool( 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." + "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 { @@ -1677,20 +1604,22 @@ fn create_request_user_input_tool( question_props.insert( "question".to_string(), JsonSchema::String { - description: Some("Single-sentence prompt shown to the user.".to_string()), + description: Some("Prompt shown to the user for this field.".to_string()), }, ); question_props.insert("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( + "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: question_props, required: Some(vec![ "id".to_string(), "header".to_string(), "question".to_string(), - "options".to_string(), ]), additional_properties: Some(false.into()), }), @@ -1700,8 +1629,8 @@ fn create_request_user_input_tool( properties.insert("questions".to_string(), questions_schema); ToolSpec::Function(ResponsesApiTool { - name: "request_user_input".to_string(), - description: request_user_input_tool_description( + name: "question".to_string(), + description: question_tool_description( collaboration_modes_config.default_mode_request_user_input, ), strict: false, @@ -1838,6 +1767,59 @@ fn create_test_sync_tool() -> ToolSpec { }) } +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, + }) +} + fn create_tool_search_tool(app_tools: &HashMap) -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2045,6 +2027,111 @@ fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String { } } +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, + }) +} + fn create_list_dir_tool() -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2592,12 +2679,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; @@ -2612,11 +2701,6 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::multi_agents::SendInputHandler; use crate::tools::handlers::multi_agents::SpawnAgentHandler; use crate::tools::handlers::multi_agents::WaitAgentHandler; - use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2; - use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; - use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; - use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; - use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use std::sync::Arc; let mut builder = ToolRegistryBuilder::new(); @@ -2793,6 +2877,14 @@ pub(crate) fn build_specs_with_discoverable_tools( } if config.request_user_input { + push_tool_spec( + &mut builder, + create_question_tool(CollaborationModesConfig { + default_mode_request_user_input: config.default_mode_request_user_input, + }), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); push_tool_spec( &mut builder, create_request_user_input_tool(CollaborationModesConfig { @@ -2801,6 +2893,7 @@ pub(crate) fn build_specs_with_discoverable_tools( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); + builder.register_handler("question", request_user_input_handler.clone()); builder.register_handler("request_user_input", request_user_input_handler); } @@ -2868,6 +2961,34 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("apply_patch", apply_patch_handler); } + if config + .experimental_supported_tools + .contains(&"grep_files".to_string()) + { + let grep_files_handler = Arc::new(GrepFilesHandler); + push_tool_spec( + &mut builder, + create_grep_files_tool(), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + builder.register_handler("grep_files", grep_files_handler); + } + + if config + .experimental_supported_tools + .contains(&"read_file".to_string()) + { + let read_file_handler = Arc::new(ReadFileHandler); + push_tool_spec( + &mut builder, + create_read_file_tool(), + /*supports_parallel_tool_calls*/ true, + config.code_mode_enabled, + ); + builder.register_handler("read_file", read_file_handler); + } + if config .experimental_supported_tools .iter() @@ -2975,22 +3096,10 @@ pub(crate) fn build_specs_with_discoverable_tools( ); push_tool_spec( &mut builder, - if config.multi_agent_v2 { - create_send_message_tool() - } else { - create_send_input_tool() - }, + create_send_input_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - if config.multi_agent_v2 { - push_tool_spec( - &mut builder, - create_assign_task_tool(), - /*supports_parallel_tool_calls*/ false, - config.code_mode_enabled, - ); - } if !config.multi_agent_v2 { push_tool_spec( &mut builder, @@ -3002,11 +3111,7 @@ pub(crate) fn build_specs_with_discoverable_tools( } push_tool_spec( &mut builder, - if config.multi_agent_v2 { - create_wait_agent_tool_v2() - } else { - create_wait_agent_tool_v1() - }, + create_wait_agent_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); @@ -3016,23 +3121,9 @@ pub(crate) fn build_specs_with_discoverable_tools( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - if config.multi_agent_v2 { - push_tool_spec( - &mut builder, - create_list_agents_tool(), - /*supports_parallel_tool_calls*/ false, - config.code_mode_enabled, - ); - builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2)); - builder.register_handler("send_message", Arc::new(SendMessageHandlerV2)); - builder.register_handler("assign_task", Arc::new(AssignTaskHandlerV2)); - builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2)); - builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2)); - } else { - builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); - builder.register_handler("send_input", Arc::new(SendInputHandler)); - builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); - } + builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); + builder.register_handler("send_input", Arc::new(SendInputHandler)); + builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); builder.register_handler("close_agent", Arc::new(CloseAgentHandler)); } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 6089e8bf7..bf48defe1 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -457,6 +457,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_exec_command_tool(true, false), create_write_stdin_tool(), PLAN_TOOL.clone(), + create_question_tool(CollaborationModesConfig::default()), create_request_user_input_tool(CollaborationModesConfig::default()), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { @@ -575,6 +576,7 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { else { panic!("spawn_agent should use object params"); }; + assert!(properties.contains_key("cwd")); assert!(properties.contains_key("task_name")); assert_eq!(required.as_ref(), None); let output_schema = output_schema @@ -830,6 +832,7 @@ fn test_build_specs_agent_job_worker_tools_enabled() { "report_agent_job_result", ], ); + assert_lacks_tool_name(&tools, "question"); assert_lacks_tool_name(&tools, "request_user_input"); } @@ -849,6 +852,11 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let question_tool = find_tool(&tools, "question"); + assert_eq!( + question_tool.spec, + create_question_tool(CollaborationModesConfig::default()) + ); let request_user_input_tool = find_tool(&tools, "request_user_input"); assert_eq!( request_user_input_tool.spec, @@ -867,6 +875,13 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + let question_tool = find_tool(&tools, "question"); + assert_eq!( + question_tool.spec, + create_question_tool(CollaborationModesConfig { + default_mode_request_user_input: true, + }) + ); let request_user_input_tool = find_tool(&tools, "request_user_input"); assert_eq!( request_user_input_tool.spec, @@ -1353,6 +1368,7 @@ fn test_build_specs_gpt5_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1376,6 +1392,7 @@ fn test_build_specs_gpt51_codex_default() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1401,6 +1418,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", @@ -1426,6 +1444,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", @@ -1449,6 +1468,7 @@ fn test_gpt_5_1_codex_max_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1472,6 +1492,7 @@ fn test_codex_5_1_mini_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1495,6 +1516,7 @@ fn test_gpt_5_defaults() { "shell", &[ "update_plan", + "question", "request_user_input", "web_search", "view_image", @@ -1517,6 +1539,7 @@ fn test_gpt_5_1_defaults() { "shell_command", &[ "update_plan", + "question", "request_user_input", "apply_patch", "web_search", @@ -1542,6 +1565,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/login/src/server.rs b/codex-rs/login/src/server.rs index b726eeed8..02e673bb9 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -1084,13 +1084,12 @@ pub(crate) async fn obtain_api_key( } #[cfg(test)] mod tests { - use pretty_assertions::assert_eq; - use super::TokenEndpointErrorDetail; use super::parse_token_endpoint_error; use super::redact_sensitive_query_value; use super::redact_sensitive_url_parts; use super::sanitize_url_for_logging; + use pretty_assertions::assert_eq; #[test] fn parse_token_endpoint_error_prefers_error_description() { diff --git a/docs/config.md b/docs/config.md index 71f3548de..b0bfa9715 100644 --- a/docs/config.md +++ b/docs/config.md @@ -88,4 +88,21 @@ developer message Codex inserts when realtime becomes active. It only affects the realtime start message in prompt history and does not change websocket backend prompt settings or the realtime end/inactive message. +## Loop timers + +`[tui.loop]` controls what a completed `/loop` run mirrors back into the +main thread. + +```toml +[tui.loop] +completion_mirror_mode = "response-only" +``` + +Supported values: + +- `prompt-and-response` (default): mirror `/loop ` and the latest assistant reply. +- `response-only`: mirror only the latest assistant reply. + +Follow-up delivery is configured per loop in `Ctrl-P -> Loop Manager -> Delivery Mode`. + Ctrl+C/Ctrl+D quitting uses a ~1 second double-press hint (`ctrl + c again to quit`). From bf6d46b1799604bfd6af69992a2127d4eec5a3aa Mon Sep 17 00:00:00 2001 From: piping Date: Thu, 26 Mar 2026 17:59:05 +0800 Subject: [PATCH 04/25] feat: add btw, loop manager, and TUI workflow enhancements --- codex-rs/Cargo.lock | 298 ++- codex-rs/core/src/agent/control.rs | 25 +- codex-rs/core/src/agent/registry.rs | 1 + codex-rs/core/src/codex.rs | 2 + codex-rs/core/src/state/turn.rs | 1 + .../core/src/tools/handlers/grep_files.rs | 176 ++ codex-rs/core/src/tools/handlers/mod.rs | 6 + .../core/src/tools/handlers/multi_agents.rs | 1 - .../src/tools/handlers/multi_agents/spawn.rs | 7 +- .../src/tools/handlers/multi_agents_v2.rs | 5 + codex-rs/core/src/tools/handlers/read_file.rs | 489 +++++ codex-rs/loop/src/execution.rs | 2 +- codex-rs/tui/src/app.rs | 1384 ++++++++++++- codex-rs/tui/src/app/btw.rs | 495 +++++ .../tui/src/app/display_preferences_menu.rs | 102 + codex-rs/tui/src/app/jump_navigation.rs | 106 + codex-rs/tui/src/app/key_chord.rs | 146 ++ codex-rs/tui/src/app/loop_timers.rs | 1826 +++++++++++++++++ ...i__app__btw__tests__btw_loading_popup.snap | 41 + ...ui__app__btw__tests__btw_result_popup.snap | 53 + ...__tests__loop_timer_background_notice.snap | 5 + codex-rs/tui/src/app/thread_menu.rs | 48 + codex-rs/tui/src/app_backtrack.rs | 23 + codex-rs/tui/src/app_event.rs | 174 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 252 ++- .../tui/src/bottom_pane/custom_prompt_view.rs | 6 + codex-rs/tui/src/bottom_pane/footer.rs | 50 +- .../src/bottom_pane/list_selection_view.rs | 586 +++++- codex-rs/tui/src/bottom_pane/mod.rs | 24 +- ...__tests__footer_mode_shortcut_overlay.snap | 9 +- ...shortcuts_collaboration_modes_enabled.snap | 14 +- ...tests__footer_shortcuts_shift_and_esc.snap | 9 +- ...__tests__accounts_delete_confirmation.snap | 15 + ...ion_view__tests__accounts_delete_menu.snap | 21 + ..._selection_view__tests__accounts_menu.snap | 24 + ...ction_view__tests__control_panel_menu.snap | 16 + ...ion_view__tests__jump_to_message_menu.snap | 17 + ..._list_selection_footer_path_truncates.snap | 13 + ...lection_view__tests__loop_create_menu.snap | 15 + ...tion_view__tests__loop_execution_menu.snap | 20 + ...lection_view__tests__loop_timers_menu.snap | 22 + ...selection_view__tests__show_hide_menu.snap | 41 + ...st_selection_view__tests__thread_menu.snap | 15 + codex-rs/tui/src/chatwidget.rs | 778 ++++++- ...twidget__tests__slash_btw_usage_error.snap | 5 + ...widget__tests__slash_loop_usage_error.snap | 5 + .../tui/src/chatwidget/status_surfaces.rs | 8 + codex-rs/tui/src/chatwidget/tests.rs | 265 +++ codex-rs/tui/src/display_preferences.rs | 179 ++ codex-rs/tui/src/exec_cell/model.rs | 26 +- codex-rs/tui/src/exec_cell/render.rs | 79 +- codex-rs/tui/src/external_editor.rs | 45 +- codex-rs/tui/src/history_cell.rs | 336 ++- codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/pager_overlay.rs | 15 +- codex-rs/tui/src/rate_limits.rs | 20 + codex-rs/tui/src/slash_command.rs | 10 + ...nfo_availability_nux_tooltip_snapshot.snap | 2 +- ...ched_limits_hide_credits_without_flag.snap | 2 +- ..._snapshot_includes_credits_and_limits.snap | 2 +- ..._status_snapshot_includes_forked_from.snap | 2 +- ...tatus_snapshot_includes_monthly_limit.snap | 2 +- ...s_snapshot_includes_reasoning_details.snap | 2 +- ...s_snapshot_shows_empty_limits_message.snap | 2 +- ...snapshot_shows_missing_limits_message.snap | 2 +- ...s_snapshot_shows_stale_limits_message.snap | 2 +- ...snapshot_truncates_in_narrow_terminal.snap | 2 +- codex-rs/tui_app_server/src/app.rs | 1232 ++++++++++- .../src/app/display_preferences_menu.rs | 102 + .../tui_app_server/src/app/jump_navigation.rs | 106 + codex-rs/tui_app_server/src/app/key_chord.rs | 146 ++ .../tui_app_server/src/app/thread_menu.rs | 48 + codex-rs/tui_app_server/src/app_backtrack.rs | 23 + codex-rs/tui_app_server/src/app_event.rs | 46 + .../src/bottom_pane/chat_composer.rs | 252 ++- .../tui_app_server/src/bottom_pane/footer.rs | 50 +- .../src/bottom_pane/list_selection_view.rs | 434 +++- .../tui_app_server/src/bottom_pane/mod.rs | 31 + ...__tests__footer_mode_shortcut_overlay.snap | 9 +- ...shortcuts_collaboration_modes_enabled.snap | 13 +- ...tests__footer_shortcuts_shift_and_esc.snap | 9 +- ...__tests__accounts_delete_confirmation.snap | 15 + ...ion_view__tests__accounts_delete_menu.snap | 21 + ..._selection_view__tests__accounts_menu.snap | 24 + ...ction_view__tests__control_panel_menu.snap | 15 + ...ion_view__tests__jump_to_message_menu.snap | 17 + ..._list_selection_footer_path_truncates.snap | 13 + ...selection_view__tests__show_hide_menu.snap | 41 + ...st_selection_view__tests__thread_menu.snap | 15 + codex-rs/tui_app_server/src/chatwidget.rs | 304 ++- .../tui_app_server/src/chatwidget/tests.rs | 130 ++ .../tui_app_server/src/display_preferences.rs | 179 ++ .../tui_app_server/src/exec_cell/model.rs | 26 +- .../tui_app_server/src/exec_cell/render.rs | 79 +- .../tui_app_server/src/external_editor.rs | 45 +- codex-rs/tui_app_server/src/history_cell.rs | 336 ++- codex-rs/tui_app_server/src/lib.rs | 2 + codex-rs/tui_app_server/src/model_catalog.rs | 12 +- codex-rs/tui_app_server/src/pager_overlay.rs | 15 +- codex-rs/tui_app_server/src/rate_limits.rs | 20 + ...nfo_availability_nux_tooltip_snapshot.snap | 2 +- ...ched_limits_hide_credits_without_flag.snap | 2 +- ..._snapshot_includes_credits_and_limits.snap | 2 +- ..._status_snapshot_includes_forked_from.snap | 2 +- ...tatus_snapshot_includes_monthly_limit.snap | 2 +- ...s_snapshot_includes_reasoning_details.snap | 2 +- ...s_snapshot_shows_empty_limits_message.snap | 2 +- ...snapshot_shows_missing_limits_message.snap | 2 +- ...s_snapshot_shows_stale_limits_message.snap | 2 +- ...snapshot_truncates_in_narrow_terminal.snap | 2 +- docs/loop-design.md | 268 +++ docs/loop-proposal.md | 57 + docs/loop-spec.md | 217 ++ docs/tui-chat-composer.md | 10 +- 114 files changed, 12180 insertions(+), 580 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/grep_files.rs create mode 100644 codex-rs/core/src/tools/handlers/read_file.rs create mode 100644 codex-rs/tui/src/app/btw.rs create mode 100644 codex-rs/tui/src/app/display_preferences_menu.rs create mode 100644 codex-rs/tui/src/app/jump_navigation.rs create mode 100644 codex-rs/tui/src/app/key_chord.rs create mode 100644 codex-rs/tui/src/app/loop_timers.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_loading_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__btw__tests__btw_result_popup.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__loop_timers__tests__loop_timer_background_notice.snap create mode 100644 codex-rs/tui/src/app/thread_menu.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__accounts_delete_confirmation.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__accounts_delete_menu.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__accounts_menu.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__control_panel_menu.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__jump_to_message_menu.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_path_truncates.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__loop_create_menu.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__loop_execution_menu.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__loop_timers_menu.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__show_hide_menu.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__thread_menu.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_btw_usage_error.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_loop_usage_error.snap create mode 100644 codex-rs/tui/src/display_preferences.rs create mode 100644 codex-rs/tui/src/rate_limits.rs create mode 100644 codex-rs/tui_app_server/src/app/display_preferences_menu.rs create mode 100644 codex-rs/tui_app_server/src/app/jump_navigation.rs create mode 100644 codex-rs/tui_app_server/src/app/key_chord.rs create mode 100644 codex-rs/tui_app_server/src/app/thread_menu.rs create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__list_selection_view__tests__accounts_delete_confirmation.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__list_selection_view__tests__accounts_delete_menu.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__list_selection_view__tests__accounts_menu.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__list_selection_view__tests__control_panel_menu.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__list_selection_view__tests__jump_to_message_menu.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__list_selection_view__tests__list_selection_footer_path_truncates.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__list_selection_view__tests__show_hide_menu.snap create mode 100644 codex-rs/tui_app_server/src/bottom_pane/snapshots/codex_tui_app_server__bottom_pane__list_selection_view__tests__thread_menu.snap create mode 100644 codex-rs/tui_app_server/src/display_preferences.rs create mode 100644 codex-rs/tui_app_server/src/rate_limits.rs create mode 100644 docs/loop-design.md create mode 100644 docs/loop-proposal.md create mode 100644 docs/loop-spec.md diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9fc695795..8764b5456 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -402,7 +402,7 @@ checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "app_test_support" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "base64 0.22.1", @@ -1384,9 +1384,22 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" +[[package]] +name = "codex-accounts" +version = "0.1.7" +dependencies = [ + "base64 0.22.1", + "codex-app-server-protocol", + "codex-core", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-analytics" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-git-utils", "codex-login", @@ -1402,7 +1415,7 @@ dependencies = [ [[package]] name = "codex-ansi-escape" -version = "0.0.0" +version = "0.1.7" dependencies = [ "ansi-to-tui", "ratatui", @@ -1411,7 +1424,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "assert_matches", @@ -1441,7 +1454,7 @@ dependencies = [ [[package]] name = "codex-app-server" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "app_test_support", @@ -1504,7 +1517,7 @@ dependencies = [ [[package]] name = "codex-app-server-client" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-app-server", "codex-app-server-protocol", @@ -1525,7 +1538,7 @@ dependencies = [ [[package]] name = "codex-app-server-protocol" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "clap", @@ -1553,7 +1566,7 @@ dependencies = [ [[package]] name = "codex-app-server-test-client" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "clap", @@ -1574,7 +1587,7 @@ dependencies = [ [[package]] name = "codex-apply-patch" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "assert_cmd", @@ -1590,7 +1603,7 @@ dependencies = [ [[package]] name = "codex-arg0" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-apply-patch", @@ -1605,7 +1618,7 @@ dependencies = [ [[package]] name = "codex-artifacts" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-package-manager", "flate2", @@ -1626,7 +1639,7 @@ dependencies = [ [[package]] name = "codex-async-utils" -version = "0.0.0" +version = "0.1.7" dependencies = [ "async-trait", "pretty_assertions", @@ -1636,7 +1649,7 @@ dependencies = [ [[package]] name = "codex-backend-client" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-backend-openapi-models", @@ -1651,16 +1664,24 @@ dependencies = [ [[package]] name = "codex-backend-openapi-models" -version = "0.0.0" +version = "0.1.7" dependencies = [ "serde", "serde_json", "serde_with", ] +[[package]] +name = "codex-btw" +version = "0.1.7" +dependencies = [ + "pretty_assertions", + "serde", +] + [[package]] name = "codex-chatgpt" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "clap", @@ -1678,13 +1699,15 @@ dependencies = [ [[package]] name = "codex-cli" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "assert_cmd", "assert_matches", + "base64 0.22.1", "clap", "clap_complete", + "codex-accounts", "codex-app-server", "codex-app-server-protocol", "codex-app-server-test-client", @@ -1695,6 +1718,7 @@ dependencies = [ "codex-core", "codex-exec", "codex-execpolicy", + "codex-ext", "codex-features", "codex-login", "codex-mcp-server", @@ -1728,7 +1752,7 @@ dependencies = [ [[package]] name = "codex-client" -version = "0.0.0" +version = "0.1.7" dependencies = [ "async-trait", "bytes", @@ -1758,7 +1782,7 @@ dependencies = [ [[package]] name = "codex-cloud-requirements" -version = "0.0.0" +version = "0.1.7" dependencies = [ "async-trait", "base64 0.22.1", @@ -1781,7 +1805,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "async-trait", @@ -1812,7 +1836,7 @@ dependencies = [ [[package]] name = "codex-cloud-tasks-client" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "async-trait", @@ -1827,7 +1851,7 @@ dependencies = [ [[package]] name = "codex-code-mode" -version = "0.0.0" +version = "0.1.7" dependencies = [ "async-trait", "pretty_assertions", @@ -1841,7 +1865,7 @@ dependencies = [ [[package]] name = "codex-config" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -1865,7 +1889,7 @@ dependencies = [ [[package]] name = "codex-connectors" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-app-server-protocol", @@ -1877,7 +1901,7 @@ dependencies = [ [[package]] name = "codex-core" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "arc-swap", @@ -1998,7 +2022,7 @@ dependencies = [ [[package]] name = "codex-core-skills" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-analytics", @@ -2027,7 +2051,7 @@ dependencies = [ [[package]] name = "codex-debug-client" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "clap", @@ -2039,7 +2063,7 @@ dependencies = [ [[package]] name = "codex-exec" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "assert_cmd", @@ -2081,7 +2105,7 @@ dependencies = [ [[package]] name = "codex-exec-server" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "async-trait", @@ -2105,7 +2129,7 @@ dependencies = [ [[package]] name = "codex-execpolicy" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "clap", @@ -2122,7 +2146,7 @@ dependencies = [ [[package]] name = "codex-execpolicy-legacy" -version = "0.0.0" +version = "0.1.7" dependencies = [ "allocative", "anyhow", @@ -2142,16 +2166,32 @@ dependencies = [ [[package]] name = "codex-experimental-api-macros" -version = "0.0.0" +version = "0.1.7" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] +[[package]] +name = "codex-ext" +version = "0.1.7" +dependencies = [ + "base64 0.22.1", + "codex-accounts", + "codex-app-server-protocol", + "codex-btw", + "codex-core", + "codex-threadmessages", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-features" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-login", "codex-otel", @@ -2165,7 +2205,7 @@ dependencies = [ [[package]] name = "codex-feedback" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-protocol", @@ -2177,7 +2217,7 @@ dependencies = [ [[package]] name = "codex-file-search" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "clap", @@ -2193,7 +2233,7 @@ dependencies = [ [[package]] name = "codex-git-utils" -version = "0.0.0" +version = "0.1.7" dependencies = [ "assert_matches", "codex-utils-absolute-path", @@ -2212,7 +2252,7 @@ dependencies = [ [[package]] name = "codex-hooks" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "chrono", @@ -2230,7 +2270,7 @@ dependencies = [ [[package]] name = "codex-instructions" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-protocol", "pretty_assertions", @@ -2239,7 +2279,7 @@ dependencies = [ [[package]] name = "codex-keyring-store" -version = "0.0.0" +version = "0.1.7" dependencies = [ "keyring", "tracing", @@ -2247,7 +2287,7 @@ dependencies = [ [[package]] name = "codex-linux-sandbox" -version = "0.0.0" +version = "0.1.7" dependencies = [ "cc", "clap", @@ -2269,7 +2309,7 @@ dependencies = [ [[package]] name = "codex-lmstudio" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-core", "reqwest", @@ -2282,7 +2322,7 @@ dependencies = [ [[package]] name = "codex-login" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "async-trait", @@ -2318,9 +2358,24 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-loop" +version = "0.1.7" +dependencies = [ + "chrono", + "codex-core", + "codex-protocol", + "codex-utils-absolute-path", + "cron", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-mcp-server" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-arg0", @@ -2349,7 +2404,7 @@ dependencies = [ [[package]] name = "codex-network-proxy" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "async-trait", @@ -2380,7 +2435,7 @@ dependencies = [ [[package]] name = "codex-ollama" -version = "0.0.0" +version = "0.1.7" dependencies = [ "assert_matches", "async-stream", @@ -2398,7 +2453,7 @@ dependencies = [ [[package]] name = "codex-otel" -version = "0.0.0" +version = "0.1.7" dependencies = [ "chrono", "codex-api", @@ -2430,7 +2485,7 @@ dependencies = [ [[package]] name = "codex-package-manager" -version = "0.0.0" +version = "0.1.7" dependencies = [ "fd-lock", "flate2", @@ -2450,7 +2505,7 @@ dependencies = [ [[package]] name = "codex-plugin" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-utils-absolute-path", "codex-utils-plugins", @@ -2459,7 +2514,7 @@ dependencies = [ [[package]] name = "codex-process-hardening" -version = "0.0.0" +version = "0.1.7" dependencies = [ "libc", "pretty_assertions", @@ -2467,7 +2522,7 @@ dependencies = [ [[package]] name = "codex-protocol" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-execpolicy", @@ -2495,7 +2550,7 @@ dependencies = [ [[package]] name = "codex-responses-api-proxy" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "clap", @@ -2511,7 +2566,7 @@ dependencies = [ [[package]] name = "codex-rmcp-client" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "axum", @@ -2545,7 +2600,7 @@ dependencies = [ [[package]] name = "codex-rollout" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "async-trait", @@ -2570,7 +2625,7 @@ dependencies = [ [[package]] name = "codex-sandboxing" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-network-proxy", "codex-protocol", @@ -2587,7 +2642,7 @@ dependencies = [ [[package]] name = "codex-secrets" -version = "0.0.0" +version = "0.1.7" dependencies = [ "age", "anyhow", @@ -2608,7 +2663,7 @@ dependencies = [ [[package]] name = "codex-shell-command" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "base64 0.22.1", @@ -2628,7 +2683,7 @@ dependencies = [ [[package]] name = "codex-shell-escalation" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "async-trait", @@ -2649,7 +2704,7 @@ dependencies = [ [[package]] name = "codex-skills" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-utils-absolute-path", "include_dir", @@ -2658,7 +2713,7 @@ dependencies = [ [[package]] name = "codex-state" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "chrono", @@ -2681,7 +2736,7 @@ dependencies = [ [[package]] name = "codex-stdio-to-uds" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-utils-cargo-bin", @@ -2692,7 +2747,7 @@ dependencies = [ [[package]] name = "codex-terminal-detection" -version = "0.0.0" +version = "0.1.7" dependencies = [ "pretty_assertions", "tracing", @@ -2700,16 +2755,24 @@ dependencies = [ [[package]] name = "codex-test-macros" -version = "0.0.0" +version = "0.1.7" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] +[[package]] +name = "codex-threadmessages" +version = "0.1.7" +dependencies = [ + "pretty_assertions", + "serde", +] + [[package]] name = "codex-tui" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "arboard", @@ -2717,27 +2780,32 @@ dependencies = [ "base64 0.22.1", "chrono", "clap", + "codex-accounts", "codex-ansi-escape", "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", "codex-backend-client", + "codex-btw", "codex-chatgpt", "codex-cli", "codex-client", "codex-cloud-requirements", "codex-core", "codex-exec-server", + "codex-ext", "codex-features", "codex-feedback", "codex-file-search", "codex-git-utils", "codex-login", + "codex-loop", "codex-otel", "codex-protocol", "codex-shell-command", "codex-state", "codex-terminal-detection", + "codex-threadmessages", "codex-tui-app-server", "codex-utils-absolute-path", "codex-utils-approval-presets", @@ -2753,6 +2821,7 @@ dependencies = [ "codex-windows-sandbox", "color-eyre", "cpal", + "cron", "crossterm", "derive_more 2.1.1", "diffy", @@ -2805,7 +2874,7 @@ dependencies = [ [[package]] name = "codex-tui-app-server" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "arboard", @@ -2813,25 +2882,31 @@ dependencies = [ "base64 0.22.1", "chrono", "clap", + "codex-accounts", "codex-ansi-escape", "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", + "codex-backend-client", + "codex-btw", "codex-chatgpt", "codex-cli", "codex-client", "codex-cloud-requirements", "codex-core", + "codex-ext", "codex-features", "codex-feedback", "codex-file-search", "codex-git-utils", "codex-login", + "codex-loop", "codex-otel", "codex-protocol", "codex-shell-command", "codex-state", "codex-terminal-detection", + "codex-threadmessages", "codex-utils-absolute-path", "codex-utils-approval-presets", "codex-utils-cargo-bin", @@ -2898,7 +2973,7 @@ dependencies = [ [[package]] name = "codex-utils-absolute-path" -version = "0.0.0" +version = "0.1.7" dependencies = [ "dirs", "path-absolutize", @@ -2912,14 +2987,14 @@ dependencies = [ [[package]] name = "codex-utils-approval-presets" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-protocol", ] [[package]] name = "codex-utils-cache" -version = "0.0.0" +version = "0.1.7" dependencies = [ "lru 0.16.3", "sha1", @@ -2928,7 +3003,7 @@ dependencies = [ [[package]] name = "codex-utils-cargo-bin" -version = "0.0.0" +version = "0.1.7" dependencies = [ "assert_cmd", "runfiles", @@ -2937,7 +3012,7 @@ dependencies = [ [[package]] name = "codex-utils-cli" -version = "0.0.0" +version = "0.1.7" dependencies = [ "clap", "codex-protocol", @@ -2948,15 +3023,15 @@ dependencies = [ [[package]] name = "codex-utils-elapsed" -version = "0.0.0" +version = "0.1.7" [[package]] name = "codex-utils-fuzzy-match" -version = "0.0.0" +version = "0.1.7" [[package]] name = "codex-utils-home-dir" -version = "0.0.0" +version = "0.1.7" dependencies = [ "dirs", "pretty_assertions", @@ -2965,7 +3040,7 @@ dependencies = [ [[package]] name = "codex-utils-image" -version = "0.0.0" +version = "0.1.7" dependencies = [ "base64 0.22.1", "codex-utils-cache", @@ -2977,7 +3052,7 @@ dependencies = [ [[package]] name = "codex-utils-json-to-toml" -version = "0.0.0" +version = "0.1.7" dependencies = [ "pretty_assertions", "serde_json", @@ -2986,7 +3061,7 @@ dependencies = [ [[package]] name = "codex-utils-oss" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-core", "codex-lmstudio", @@ -2995,7 +3070,7 @@ dependencies = [ [[package]] name = "codex-utils-output-truncation" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-protocol", "codex-utils-string", @@ -3004,7 +3079,7 @@ dependencies = [ [[package]] name = "codex-utils-path" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-utils-absolute-path", "dunce", @@ -3014,7 +3089,7 @@ dependencies = [ [[package]] name = "codex-utils-plugins" -version = "0.0.0" +version = "0.1.7" dependencies = [ "serde", "serde_json", @@ -3023,7 +3098,7 @@ dependencies = [ [[package]] name = "codex-utils-pty" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "filedescriptor", @@ -3039,7 +3114,7 @@ dependencies = [ [[package]] name = "codex-utils-readiness" -version = "0.0.0" +version = "0.1.7" dependencies = [ "assert_matches", "async-trait", @@ -3050,14 +3125,14 @@ dependencies = [ [[package]] name = "codex-utils-rustls-provider" -version = "0.0.0" +version = "0.1.7" dependencies = [ "rustls", ] [[package]] name = "codex-utils-sandbox-summary" -version = "0.0.0" +version = "0.1.7" dependencies = [ "codex-core", "codex-protocol", @@ -3067,7 +3142,7 @@ dependencies = [ [[package]] name = "codex-utils-sleep-inhibitor" -version = "0.0.0" +version = "0.1.7" dependencies = [ "core-foundation 0.9.4", "libc", @@ -3077,14 +3152,14 @@ dependencies = [ [[package]] name = "codex-utils-stream-parser" -version = "0.0.0" +version = "0.1.7" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-utils-string" -version = "0.0.0" +version = "0.1.7" dependencies = [ "pretty_assertions", "regex-lite", @@ -3092,14 +3167,14 @@ dependencies = [ [[package]] name = "codex-utils-template" -version = "0.0.0" +version = "0.1.7" dependencies = [ "pretty_assertions", ] [[package]] name = "codex-v8-poc" -version = "0.0.0" +version = "0.1.7" dependencies = [ "pretty_assertions", "v8", @@ -3107,7 +3182,7 @@ dependencies = [ [[package]] name = "codex-windows-sandbox" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "base64 0.22.1", @@ -3321,7 +3396,7 @@ dependencies = [ [[package]] name = "core_test_support" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "assert_cmd", @@ -3436,6 +3511,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "cron" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97" +dependencies = [ + "chrono", + "once_cell", + "phf", + "winnow", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -6158,7 +6245,7 @@ checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" [[package]] name = "mcp_test_support" -version = "0.0.0" +version = "0.1.7" dependencies = [ "anyhow", "codex-core", @@ -7230,6 +7317,39 @@ dependencies = [ "indexmap 2.13.0", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "phf_shared" version = "0.11.3" diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index a3b989768..9c0382ea3 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -5,10 +5,8 @@ use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; use crate::codex_thread::ThreadConfigSnapshot; -use crate::context_manager::is_user_turn_boundary; use crate::error::CodexErr; use crate::error::Result as CodexResult; -use crate::event_mapping::parse_turn_item; use crate::find_archived_thread_path_by_id_str; use crate::find_thread_path_by_id_str; use crate::rollout::RolloutRecorder; @@ -20,10 +18,7 @@ use crate::thread_manager::ThreadManagerState; use codex_features::Feature; use codex_protocol::AgentPath; use codex_protocol::ThreadId; -use codex_protocol::items::TurnItem; -use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; @@ -34,7 +29,6 @@ use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; use codex_state::DirectionalThreadSpawnEdgeStatus; -use serde::Serialize; use std::collections::HashMap; use std::collections::VecDeque; use std::sync::Arc; @@ -42,6 +36,19 @@ use std::sync::Weak; use tokio::sync::watch; use tracing::warn; +#[cfg(test)] +use crate::context_manager::is_user_turn_boundary; +#[cfg(test)] +use crate::event_mapping::parse_turn_item; +#[cfg(test)] +use codex_protocol::items::TurnItem; +#[cfg(test)] +use codex_protocol::models::ContentItem; +#[cfg(test)] +use codex_protocol::models::ResponseInputItem; +#[cfg(test)] +use serde::Serialize; + const AGENT_NAMES: &str = include_str!("agent_names.txt"); const FORKED_SPAWN_AGENT_OUTPUT_MESSAGE: &str = "You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context."; @@ -57,6 +64,7 @@ pub(crate) struct LiveAgent { pub(crate) status: AgentStatus, } +#[cfg(test)] #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub(crate) struct ListedAgent { pub(crate) agent_name: String, @@ -707,6 +715,7 @@ impl AgentControl { .join("\n") } + #[cfg(test)] pub(crate) async fn list_agents( &self, current_session_source: &SessionSource, @@ -1049,6 +1058,7 @@ fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option, prefix: &AgentPath) -> bool { if prefix.is_root() { return true; @@ -1063,6 +1073,7 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b }) } +#[cfg(test)] async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option { let pending_input = thread.codex.session.pending_input_snapshot().await; if let Some(message) = pending_input @@ -1094,11 +1105,13 @@ async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option Option { let response_item: ResponseItem = item.clone().into(); last_task_message_from_item(&response_item) } +#[cfg(test)] fn last_task_message_from_item(item: &ResponseItem) -> Option { if !is_user_turn_boundary(item) { return None; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index f78c8d08b..c8f6dc413 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -152,6 +152,7 @@ impl AgentRegistry { .cloned() } + #[cfg(test)] pub(crate) fn live_agents(&self) -> Vec { self.active_agents .lock() diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f29d37fe2..ae8958959 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3976,6 +3976,7 @@ impl Session { } } + #[cfg(test)] pub(crate) async fn pending_input_snapshot(&self) -> Vec { let active = self.active_turn.lock().await; match active.as_ref() { @@ -4001,6 +4002,7 @@ impl Session { std::mem::take(&mut *self.idle_pending_input.lock().await) } + #[cfg(test)] pub(crate) async fn queued_response_items_for_next_turn_snapshot( &self, ) -> Vec { diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index ebfa5a8bb..c9b7ca6ac 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -198,6 +198,7 @@ impl TurnState { } } + #[cfg(test)] pub(crate) fn pending_input_snapshot(&self) -> Vec { self.pending_input.clone() } 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..fdb0fce7b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/grep_files.rs @@ -0,0 +1,176 @@ +use std::path::Path; +use std::time::Duration; + +use async_trait::async_trait; +use serde::Deserialize; +use tokio::process::Command; +use tokio::time::timeout; + +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; + +pub struct GrepFilesHandler; + +const DEFAULT_LIMIT: usize = 100; +const MAX_LIMIT: usize = 2000; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +#[derive(Deserialize)] +struct GrepFilesArgs { + pattern: String, + #[serde(default)] + include: Option, + #[serde(default)] + path: Option, + #[serde(default = "default_limit")] + limit: usize, +} + +#[async_trait] +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)?; + + let pattern = args.pattern.trim(); + if pattern.is_empty() { + return Err(FunctionCallError::RespondToModel( + "pattern must not be empty".to_string(), + )); + } + + if args.limit == 0 { + return Err(FunctionCallError::RespondToModel( + "limit must be greater than zero".to_string(), + )); + } + + let limit = args.limit.min(MAX_LIMIT); + let search_path = turn.resolve_path(args.path.clone()); + + verify_path_exists(&search_path).await?; + + let include = args.include.as_deref().map(str::trim).and_then(|val| { + if val.is_empty() { + None + } else { + Some(val.to_string()) + } + }); + + let search_results = + run_rg_search(pattern, include.as_deref(), &search_path, limit, &turn.cwd).await?; + + if search_results.is_empty() { + Ok(FunctionToolOutput::from_text( + "No matches found.".to_string(), + Some(false), + )) + } else { + Ok(FunctionToolOutput::from_text( + search_results.join("\n"), + Some(true), + )) + } + } +} + +async fn verify_path_exists(path: &Path) -> Result<(), FunctionCallError> { + tokio::fs::metadata(path).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("unable to access `{}`: {err}", path.display())) + })?; + Ok(()) +} + +async fn run_rg_search( + pattern: &str, + include: Option<&str>, + search_path: &Path, + limit: usize, + cwd: &Path, +) -> Result, FunctionCallError> { + let mut command = Command::new("rg"); + command + .current_dir(cwd) + .arg("--files-with-matches") + .arg("--sortr=modified") + .arg("--regexp") + .arg(pattern) + .arg("--no-messages"); + + if let Some(glob) = include { + command.arg("--glob").arg(glob); + } + + command.arg("--").arg(search_path); + + let output = timeout(COMMAND_TIMEOUT, command.output()) + .await + .map_err(|_| { + FunctionCallError::RespondToModel("rg timed out after 30 seconds".to_string()) + })? + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to launch rg: {err}. Ensure ripgrep is installed and on PATH." + )) + })?; + + match output.status.code() { + Some(0) => Ok(parse_results(&output.stdout, limit)), + Some(1) => Ok(Vec::new()), + _ => { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(FunctionCallError::RespondToModel(format!( + "rg failed: {stderr}" + ))) + } + } +} + +fn parse_results(stdout: &[u8], limit: usize) -> Vec { + let mut results = Vec::new(); + for line in stdout.split(|byte| *byte == b'\n') { + if line.is_empty() { + continue; + } + if let Ok(text) = std::str::from_utf8(line) { + if text.is_empty() { + continue; + } + results.push(text.to_string()); + if results.len() == limit { + break; + } + } + } + results +} + +#[cfg(test)] +#[path = "grep_files_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 2436265bc..d3e359f84 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -2,14 +2,18 @@ pub(crate) mod agent_jobs; pub mod apply_patch; mod artifacts; mod dynamic; +mod grep_files; mod js_repl; mod list_dir; mod mcp; mod mcp_resource; pub(crate) mod multi_agents; +#[cfg(test)] pub(crate) mod multi_agents_common; +#[cfg(test)] pub(crate) mod multi_agents_v2; mod plan; +mod read_file; mod request_permissions; mod request_user_input; mod shell; @@ -39,12 +43,14 @@ pub use artifacts::ArtifactsHandler; 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(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 18ef218a8..13611e7d0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -51,7 +51,6 @@ use serde_json::Value as JsonValue; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use std::sync::Arc; pub(crate) use close_agent::Handler as CloseAgentHandler; pub(crate) use resume_agent::Handler as ResumeAgentHandler; 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 2857ca57f..5bbcdd858 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -73,7 +73,12 @@ impl ToolHandler for Handler { .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 = cwd; + 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); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 00ee57976..3f7c27cb0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -30,10 +30,15 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +#[cfg(test)] pub(crate) use assign_task::Handler as AssignTaskHandler; +#[cfg(test)] pub(crate) use list_agents::Handler as ListAgentsHandler; +#[cfg(test)] pub(crate) use send_message::Handler as SendMessageHandler; +#[cfg(test)] pub(crate) use spawn::Handler as SpawnAgentHandler; +#[cfg(test)] pub(crate) use wait::Handler as WaitAgentHandler; mod assign_task; 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..b868edf5b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/read_file.rs @@ -0,0 +1,489 @@ +use std::collections::VecDeque; +use std::path::PathBuf; + +use async_trait::async_trait; +use codex_utils_string::take_bytes_at_char_boundary; +use serde::Deserialize; + +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; + +pub struct ReadFileHandler; + +const MAX_LINE_LENGTH: usize = 500; +const TAB_WIDTH: usize = 4; + +// TODO(jif) add support for block comments +const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"]; + +/// JSON arguments accepted by the `read_file` tool handler. +#[derive(Deserialize)] +struct ReadFileArgs { + /// Absolute path to the file that will be read. + file_path: String, + /// 1-indexed line number to start reading from; defaults to 1. + #[serde(default = "defaults::offset")] + offset: usize, + /// Maximum number of lines to return; defaults to 2000. + #[serde(default = "defaults::limit")] + limit: usize, + /// Determines whether the handler reads a simple slice or indentation-aware block. + #[serde(default)] + mode: ReadMode, + /// Optional indentation configuration used when `mode` is `Indentation`. + #[serde(default)] + indentation: Option, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "snake_case")] +enum ReadMode { + #[default] + Slice, + Indentation, +} +/// Additional configuration for indentation-aware reads. +#[derive(Deserialize, Clone)] +struct IndentationArgs { + /// Optional explicit anchor line; defaults to `offset` when omitted. + #[serde(default)] + anchor_line: Option, + /// Maximum indentation depth to collect; `0` means unlimited. + #[serde(default = "defaults::max_levels")] + max_levels: usize, + /// Whether to include sibling blocks at the same indentation level. + #[serde(default = "defaults::include_siblings")] + include_siblings: bool, + /// Whether to include header lines above the anchor block. This made on a best effort basis. + #[serde(default = "defaults::include_header")] + include_header: bool, + /// Optional hard cap on returned lines; defaults to the global `limit`. + #[serde(default)] + max_lines: Option, +} + +#[derive(Clone, Debug)] +struct LineRecord { + number: usize, + raw: String, + display: String, + indent: usize, +} + +impl LineRecord { + fn trimmed(&self) -> &str { + self.raw.trim_start() + } + + fn is_blank(&self) -> bool { + self.trimmed().is_empty() + } + + fn is_comment(&self) -> bool { + COMMENT_PREFIXES + .iter() + .any(|prefix| self.raw.trim().starts_with(prefix)) + } +} + +#[async_trait] +impl ToolHandler for ReadFileHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { payload, .. } = 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)?; + + let ReadFileArgs { + file_path, + offset, + limit, + mode, + indentation, + } = args; + + 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(), + )); + } + + let path = PathBuf::from(&file_path); + if !path.is_absolute() { + return Err(FunctionCallError::RespondToModel( + "file_path must be an absolute path".to_string(), + )); + } + + let collected = match mode { + ReadMode::Slice => slice::read(&path, offset, limit).await?, + ReadMode::Indentation => { + let indentation = indentation.unwrap_or_default(); + indentation::read_block(&path, offset, limit, indentation).await? + } + }; + Ok(FunctionToolOutput::from_text( + collected.join("\n"), + Some(true), + )) + } +} + +mod slice { + use crate::function_tool::FunctionCallError; + use crate::tools::handlers::read_file::format_line; + use std::path::Path; + use tokio::fs::File; + use tokio::io::AsyncBufReadExt; + use tokio::io::BufReader; + + pub async fn read( + path: &Path, + offset: usize, + limit: usize, + ) -> Result, FunctionCallError> { + let file = File::open(path).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + let mut reader = BufReader::new(file); + let mut collected = Vec::new(); + let mut seen = 0usize; + let mut buffer = Vec::new(); + + loop { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + if bytes_read == 0 { + break; + } + + if buffer.last() == Some(&b'\n') { + buffer.pop(); + if buffer.last() == Some(&b'\r') { + buffer.pop(); + } + } + + seen += 1; + + if seen < offset { + continue; + } + + if collected.len() == limit { + break; + } + + let formatted = format_line(&buffer); + collected.push(format!("L{seen}: {formatted}")); + + if collected.len() == limit { + break; + } + } + + if seen < offset { + return Err(FunctionCallError::RespondToModel( + "offset exceeds file length".to_string(), + )); + } + + Ok(collected) + } +} + +mod indentation { + use crate::function_tool::FunctionCallError; + use crate::tools::handlers::read_file::IndentationArgs; + use crate::tools::handlers::read_file::LineRecord; + use crate::tools::handlers::read_file::TAB_WIDTH; + use crate::tools::handlers::read_file::format_line; + use crate::tools::handlers::read_file::trim_empty_lines; + use std::collections::VecDeque; + use std::path::Path; + use tokio::fs::File; + use tokio::io::AsyncBufReadExt; + use tokio::io::BufReader; + + pub async fn read_block( + path: &Path, + offset: usize, + limit: usize, + options: IndentationArgs, + ) -> Result, FunctionCallError> { + let anchor_line = options.anchor_line.unwrap_or(offset); + if anchor_line == 0 { + return Err(FunctionCallError::RespondToModel( + "anchor_line must be a 1-indexed line number".to_string(), + )); + } + + let guard_limit = options.max_lines.unwrap_or(limit); + if guard_limit == 0 { + return Err(FunctionCallError::RespondToModel( + "max_lines must be greater than zero".to_string(), + )); + } + + let collected = collect_file_lines(path).await?; + if collected.is_empty() || anchor_line > collected.len() { + return Err(FunctionCallError::RespondToModel( + "anchor_line exceeds file length".to_string(), + )); + } + + let anchor_index = anchor_line - 1; + let effective_indents = compute_effective_indents(&collected); + let anchor_indent = effective_indents[anchor_index]; + + // Compute the min indent + let min_indent = if options.max_levels == 0 { + 0 + } else { + anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH) + }; + + // Cap requested lines by guard_limit and file length + let final_limit = limit.min(guard_limit).min(collected.len()); + + if final_limit == 1 { + return Ok(vec![format!( + "L{}: {}", + collected[anchor_index].number, collected[anchor_index].display + )]); + } + + // Cursors + let mut i: isize = anchor_index as isize - 1; // up (inclusive) + let mut j: usize = anchor_index + 1; // down (inclusive) + let mut i_counter_min_indent = 0; + let mut j_counter_min_indent = 0; + + let mut out = VecDeque::with_capacity(limit); + out.push_back(&collected[anchor_index]); + + while out.len() < final_limit { + let mut progressed = 0; + + // Up. + if i >= 0 { + let iu = i as usize; + if effective_indents[iu] >= min_indent { + out.push_front(&collected[iu]); + progressed += 1; + i -= 1; + + // We do not include the siblings (not applied to comments). + if effective_indents[iu] == min_indent && !options.include_siblings { + let allow_header_comment = + options.include_header && collected[iu].is_comment(); + let can_take_line = allow_header_comment || i_counter_min_indent == 0; + + if can_take_line { + i_counter_min_indent += 1; + } else { + // This line shouldn't have been taken. + out.pop_front(); + progressed -= 1; + i = -1; // consider using Option or a control flag instead of a sentinel + } + } + + // Short-cut. + if out.len() >= final_limit { + break; + } + } else { + // Stop moving up. + i = -1; + } + } + + // Down. + if j < collected.len() { + let ju = j; + if effective_indents[ju] >= min_indent { + out.push_back(&collected[ju]); + progressed += 1; + j += 1; + + // We do not include the siblings (applied to comments). + if effective_indents[ju] == min_indent && !options.include_siblings { + if j_counter_min_indent > 0 { + // This line shouldn't have been taken. + out.pop_back(); + progressed -= 1; + j = collected.len(); + } + j_counter_min_indent += 1; + } + } else { + // Stop moving down. + j = collected.len(); + } + } + + if progressed == 0 { + break; + } + } + + // Trim empty lines + trim_empty_lines(&mut out); + + Ok(out + .into_iter() + .map(|record| format!("L{}: {}", record.number, record.display)) + .collect()) + } + + async fn collect_file_lines(path: &Path) -> Result, FunctionCallError> { + let file = File::open(path).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + let mut lines = Vec::new(); + let mut number = 0usize; + + loop { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to read file: {err}")) + })?; + + if bytes_read == 0 { + break; + } + + if buffer.last() == Some(&b'\n') { + buffer.pop(); + if buffer.last() == Some(&b'\r') { + buffer.pop(); + } + } + + number += 1; + let raw = String::from_utf8_lossy(&buffer).into_owned(); + let indent = measure_indent(&raw); + let display = format_line(&buffer); + lines.push(LineRecord { + number, + raw, + display, + indent, + }); + } + + Ok(lines) + } + + fn compute_effective_indents(records: &[LineRecord]) -> Vec { + let mut effective = Vec::with_capacity(records.len()); + let mut previous_indent = 0usize; + for record in records { + if record.is_blank() { + effective.push(previous_indent); + } else { + previous_indent = record.indent; + effective.push(previous_indent); + } + } + effective + } + + fn measure_indent(line: &str) -> usize { + line.chars() + .take_while(|c| matches!(c, ' ' | '\t')) + .map(|c| if c == '\t' { TAB_WIDTH } else { 1 }) + .sum() + } +} + +fn format_line(bytes: &[u8]) -> String { + let decoded = String::from_utf8_lossy(bytes); + if decoded.len() > MAX_LINE_LENGTH { + take_bytes_at_char_boundary(&decoded, MAX_LINE_LENGTH).to_string() + } else { + decoded.into_owned() + } +} + +fn trim_empty_lines(out: &mut VecDeque<&LineRecord>) { + while matches!(out.front(), Some(line) if line.raw.trim().is_empty()) { + out.pop_front(); + } + while matches!(out.back(), Some(line) if line.raw.trim().is_empty()) { + out.pop_back(); + } +} + +mod defaults { + use super::*; + + impl Default for IndentationArgs { + fn default() -> Self { + Self { + anchor_line: None, + max_levels: max_levels(), + include_siblings: include_siblings(), + include_header: include_header(), + max_lines: None, + } + } + } + + pub fn offset() -> usize { + 1 + } + + pub fn limit() -> usize { + 2000 + } + + pub fn max_levels() -> usize { + 0 + } + + pub fn include_siblings() -> bool { + false + } + + pub fn include_header() -> bool { + true + } +} + +#[cfg(test)] +#[path = "read_file_tests.rs"] +mod tests; diff --git a/codex-rs/loop/src/execution.rs b/codex-rs/loop/src/execution.rs index b8358d9c0..af08df94a 100644 --- a/codex-rs/loop/src/execution.rs +++ b/codex-rs/loop/src/execution.rs @@ -28,7 +28,7 @@ pub fn apply_loop_execution_settings( .map(|cwd| resolve_absolute_path(cwd, workspace_cwd)) .transpose()?; if let Some(cwd) = resolved_cwd { - config.cwd = cwd.into(); + config.cwd = cwd; } if !settings.writable_roots.is_empty() { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fe1cb6ae0..6552d6226 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -16,6 +16,9 @@ use crate::chatwidget::ExternalEditorState; 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::exec_command::strip_bash_lc_and_escape; use crate::external_editor; use crate::file_search::FileSearchManager; @@ -31,6 +34,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::rate_limits::fetch_rate_limits; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; @@ -40,6 +44,11 @@ use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; +use chrono::Utc; +use codex_accounts::AccountPoolStore; +use codex_accounts::ManagedAccountAuthStore; +use codex_accounts::activate_managed_account; +use codex_accounts::persist_current_managed_account_snapshot; use codex_ansi_escape::ansi_escape_line; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::InProcessAppServerClient; @@ -96,6 +105,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FinalOutput; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; @@ -136,11 +146,27 @@ use toml::Value as TomlValue; use uuid::Uuid; mod agent_navigation; +mod btw; +mod display_preferences_menu; +mod jump_navigation; +mod key_chord; +mod loop_timers; mod pending_interactive_replay; +mod thread_menu; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; +use self::btw::BtwSessionState; +use self::display_preferences_menu::control_panel_show_hide_item; +use self::display_preferences_menu::display_preferences_items; +use self::jump_navigation::build_jump_catalog; +use self::key_chord::KeyChordAction; +use self::key_chord::KeyChordResolution; +use self::key_chord::KeyChordState; +use self::loop_timers::LoopTimersState; use self::pending_interactive_replay::PendingInteractiveReplayState; +use self::thread_menu::control_panel_thread_item; +use self::thread_menu::thread_panel_items; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; @@ -908,6 +934,7 @@ pub(crate) struct App { harness_overrides: ConfigOverrides, runtime_approval_policy_override: Option, runtime_sandbox_policy_override: Option, + display_preferences: DisplayPreferences, pub(crate) file_search: FileSearchManager, @@ -929,6 +956,7 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + key_chord: KeyChordState, /// 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 @@ -956,6 +984,8 @@ pub(crate) struct App { pending_shutdown_exit_thread_id: Option, windows_sandbox: WindowsSandboxState, + btw_session: Option, + loop_timers: LoopTimersState, thread_event_channels: HashMap, thread_event_listener_tasks: HashMap>, @@ -1011,6 +1041,7 @@ impl App { feedback_audience: self.feedback_audience, model: Some(self.chat_widget.current_model().to_string()), startup_tooltip_override: None, + display_preferences: self.display_preferences.clone(), status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), @@ -1037,6 +1068,7 @@ impl App { self.apply_runtime_policy_overrides(&mut config); self.config = config; self.chat_widget.sync_plugin_mentions_config(&self.config); + self.display_preferences.sync_from_config(&self.config); Ok(()) } @@ -1954,6 +1986,7 @@ impl App { let thread_id = session.session_id; self.primary_thread_id = Some(thread_id); self.primary_session_configured = Some(session.clone()); + self.ensure_loop_timers_loaded(); self.upsert_agent_picker_thread( thread_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, @@ -2070,6 +2103,698 @@ impl App { self.sync_active_agent_label(); } + fn open_control_panel(&mut self) { + let items = vec![ + SelectionItem { + name: "Accounts".to_string(), + description: None, + selected_description: Some("Inspect the managed multi-account pool.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenAccountsPanel))], + dismiss_on_select: false, + ..Default::default() + }, + SelectionItem { + name: "Sessions".to_string(), + description: None, + selected_description: Some("Resume or switch saved chats.".to_string()), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenResumePickerAll))], + dismiss_on_select: false, + ..Default::default() + }, + control_panel_thread_item(), + SelectionItem { + name: "Loop Manager".to_string(), + description: None, + selected_description: Some( + "Manage workspace-local `/loop` scheduled prompts.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenLoopTimersPanel))], + dismiss_on_select: false, + ..Default::default() + }, + control_panel_show_hide_item(), + ]; + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-control-panel"), + title: Some("Control Panel".to_string()), + subtitle: Some(format!("{} features available.", items.len())), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn thread_panel_params(&self, initial_selected_idx: Option) -> SelectionViewParams { + let items = thread_panel_items(); + SelectionViewParams { + view_id: Some("fork-thread-panel"), + title: Some("Thread".to_string()), + subtitle: Some(format!("{} actions available.", items.len())), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + } + } + + fn open_thread_panel(&mut self) { + let view_id = "fork-thread-panel"; + let initial_selected_idx = self.chat_widget.selected_index_for_active_view(view_id); + if !self.chat_widget.replace_selection_view_if_active( + view_id, + self.thread_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.thread_panel_params(initial_selected_idx)); + } + } + + fn append_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() { + 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); + } + } + } + + fn display_preferences_panel_params( + &self, + initial_selected_idx: Option, + ) -> SelectionViewParams { + SelectionViewParams { + view_id: Some("fork-display-preferences-panel"), + title: Some("Show / Hide UI".to_string()), + subtitle: Some("These toggles only affect this TUI session.".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(&self.display_preferences), + initial_selected_idx, + ..Default::default() + } + } + + fn open_display_preferences_panel(&mut self) { + let view_id = "fork-display-preferences-panel"; + let initial_selected_idx = self.chat_widget.selected_index_for_active_view(view_id); + if !self.chat_widget.replace_selection_view_if_active( + view_id, + self.display_preferences_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.display_preferences_panel_params(initial_selected_idx)); + } + } + + fn open_jump_to_message_panel(&mut self) { + let catalog = build_jump_catalog(&self.transcript_cells); + let subtitle = if catalog.is_empty() { + Some("No committed transcript entries are available yet.".to_string()) + } else { + Some(format!( + "{} committed transcript entr{} available.", + catalog.len(), + if catalog.len() == 1 { + "y is" + } else { + "ies are" + } + )) + }; + let items = if catalog.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 { + catalog + .targets + .iter() + .map(|target| { + let cell_index = target.cell_index; + SelectionItem { + name: target.title.clone(), + description: Some(target.preview.clone()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::JumpToTranscriptCell(cell_index)); + })], + dismiss_on_select: true, + search_value: Some(target.search_value()), + ..Default::default() + } + }) + .collect() + }; + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-jump-to-message-panel"), + title: Some("Jump To Message".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn open_accounts_panel(&mut self) { + let view_id = "fork-accounts-panel"; + let initial_selected_idx = self.chat_widget.selected_index_for_active_view(view_id); + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "{} account(s) configured. Active: {}.", + state.accounts.len(), + state.active_account_id.as_deref().unwrap_or("none") + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), + }; + + match state { + Ok(state) => { + for account in &state.accounts { + let mut description_parts = Vec::new(); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + if let Some(plan_label) = &account.plan_label { + description_parts.push(plan_label.clone()); + } + if let Some(usage_summary) = account.usage_summary() { + description_parts.push(usage_summary); + } + if let Some(cooldown_until) = account.cooldown_until { + description_parts.push(format!("cooldown until {cooldown_until}")); + } + + let account_id = account.id.clone(); + let search_value = format!( + "{} {} {}", + account.display_name(), + account.id, + account.masked_email.clone().unwrap_or_default() + ); + items.push(SelectionItem { + name: account.display_name().to_string(), + description: (!description_parts.is_empty()) + .then(|| description_parts.join(" · ")), + is_current: state.active_account_id.as_deref() == Some(account.id.as_str()), + is_disabled: !account.enabled, + disabled_reason: (!account.enabled).then(|| "Disabled".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SetManagedAccountActive(account_id.clone())); + })], + dismiss_on_select: true, + search_value: Some(search_value), + ..Default::default() + }); + } + if !state.accounts.is_empty() { + items.push(SelectionItem { + name: "Refresh Quota".to_string(), + description: Some( + "Fetch the latest quota for the active ChatGPT account.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::RefreshManagedAccountQuota))], + dismiss_on_select: false, + ..Default::default() + }); + items.push(SelectionItem { + name: "Rename".to_string(), + description: Some( + "Open alias rename actions for managed accounts.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenManagedAccountRenamePanel) + })], + dismiss_on_select: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Delete".to_string(), + description: Some( + "Open deletion actions for managed accounts.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenManagedAccountDeletePanel) + })], + dismiss_on_select: true, + ..Default::default() + }); + } + } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some(view_id), + title: Some("Accounts".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(path.display().to_string()), + initial_selected_idx, + items, + ..Default::default() + }); + } + + fn open_managed_account_rename_panel(&mut self) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "Rename aliases for {} managed account(s).", + state.accounts.len() + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), + }; + + match state { + Ok(state) => { + for account in &state.accounts { + let account_id = account.id.clone(); + let current_alias = account.alias.clone(); + let mut description_parts = Vec::new(); + if let Some(alias) = + (!current_alias.is_empty()).then_some(current_alias.clone()) + { + description_parts.push(format!("alias: {alias}")); + } + description_parts.push(account.id.clone()); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + items.push(SelectionItem { + name: format!("Rename {}", account.display_name()), + description: Some(description_parts.join(" · ")), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRenameManagedAccountAliasPrompt { + account_id: account_id.clone(), + current_alias: current_alias.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + } + } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-account-rename-panel"), + title: Some("Rename".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(path.display().to_string()), + items, + ..Default::default() + }); + } + + fn open_managed_account_delete_panel(&mut self) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let path = store.path(); + let state = self.sync_current_auth_into_account_pool(&store); + let mut items = Vec::new(); + let subtitle = match &state { + Ok(state) => Some(format!( + "Delete saved snapshots for {} managed account(s).", + state.accounts.len() + )), + Err(err) => Some(format!("Failed to read account pool: {err}")), + }; + + match state { + Ok(state) => { + for account in &state.accounts { + let account_id = account.id.clone(); + let display_name = account.display_name().to_string(); + let is_active = state.active_account_id.as_deref() == Some(account.id.as_str()); + let mut description_parts = Vec::new(); + description_parts.push(account.id.clone()); + if let Some(masked_email) = &account.masked_email { + description_parts.push(masked_email.clone()); + } + if is_active { + description_parts.push("active".to_string()); + } + items.push(SelectionItem { + name: format!("Delete {display_name}"), + description: Some(description_parts.join(" · ")), + is_disabled: is_active, + disabled_reason: is_active + .then(|| "Switch away before deleting".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenDeleteManagedAccountConfirmation { + account_id: account_id.clone(), + display_name: display_name.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }); + } + } + Err(err) => { + items.push(SelectionItem { + name: "Account pool unavailable".to_string(), + description: Some(err.to_string()), + is_disabled: true, + ..Default::default() + }); + } + } + + if items.is_empty() { + items.push(SelectionItem { + name: "No managed accounts yet".to_string(), + description: Some(format!( + "Run repeated ChatGPT login flows and persist them into {}.", + path.display() + )), + is_disabled: true, + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-account-delete-panel"), + title: Some("Delete".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(path.display().to_string()), + items, + ..Default::default() + }); + } + + fn sync_current_auth_into_account_pool( + &self, + store: &AccountPoolStore, + ) -> std::io::Result { + let snapshot = persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + )?; + store.update(|state| { + if let Some(snapshot) = &snapshot { + state.upsert_account(snapshot.profile.clone()); + } + }) + } + + fn accounts_panel_is_active(&self) -> bool { + self.chat_widget + .selected_index_for_active_view("fork-accounts-panel") + .is_some() + } + + fn apply_rate_limit_snapshot_to_accounts(&mut self, snapshot: RateLimitSnapshot) { + self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + if self.accounts_panel_is_active() { + self.open_accounts_panel(); + } + } + + fn refresh_managed_account_quota(&mut self) { + let app_event_tx = self.app_event_tx.clone(); + let auth_manager = Arc::clone(&self.auth_manager); + let base_url = self.config.chatgpt_base_url.clone(); + + tokio::spawn(async move { + let result = match auth_manager.auth().await { + Some(auth) if auth.is_chatgpt_auth() => Ok(fetch_rate_limits(base_url, auth).await), + Some(_) => Err("The active managed account does not use ChatGPT auth.".to_string()), + None => Err("No active managed account is available to refresh.".to_string()), + }; + app_event_tx.send(AppEvent::ManagedAccountQuotaRefreshed(result)); + }); + } + + fn finish_managed_account_quota_refresh( + &mut self, + result: Result, String>, + ) { + match result { + Ok(snapshots) => { + for snapshot in snapshots { + self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + } + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to refresh managed account quota: {err}" + ))); + } + } + self.open_accounts_panel(); + } + + fn set_managed_account_active(&mut self, account_id: String) { + if let Err(err) = persist_current_managed_account_snapshot( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + ) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to snapshot current managed account auth: {err}" + ))); + return; + } + if let Err(err) = activate_managed_account( + &self.config.codex_home, + self.config.cli_auth_credentials_store_mode, + &account_id, + ) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to activate managed account auth: {err}" + ))); + return; + } + self.auth_manager.reload(); + + let store = AccountPoolStore::new(self.config.codex_home.clone()); + if let Err(err) = store.update(|state| { + state.set_active_account(&account_id, Utc::now().timestamp()); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to update active managed account: {err}" + ))); + return; + } + + self.open_accounts_panel(); + } + + fn save_managed_account_alias(&mut self, account_id: String, alias: String) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + if let Err(err) = store.update(|state| { + state.rename_account_alias(&account_id, alias.clone()); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to save managed account alias: {err}" + ))); + return; + } + + self.open_managed_account_rename_panel(); + } + + fn open_managed_account_delete_confirmation( + &mut self, + account_id: String, + display_name: String, + ) { + let auth_store = ManagedAccountAuthStore::new(self.config.codex_home.clone()); + let auth_path = auth_store.account_auth_path(&account_id); + let confirm_account_id = account_id; + + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("fork-account-delete-confirm-panel"), + title: Some("Delete Account".to_string()), + subtitle: Some(format!( + "Delete saved managed-account data for {display_name}?" + )), + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(auth_path.display().to_string()), + items: vec![ + SelectionItem { + name: format!("Delete {display_name}"), + description: Some( + "Remove its saved auth snapshot and alias from the managed pool." + .to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::DeleteManagedAccount(confirm_account_id.clone())); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Return to the delete menu.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenManagedAccountDeletePanel); + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + } + + fn delete_managed_account(&mut self, account_id: String) { + let store = AccountPoolStore::new(self.config.codex_home.clone()); + let state = match store.load() { + Ok(state) => state, + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to load managed account pool: {err}" + ))); + return; + } + }; + + if state.active_account_id.as_deref() == Some(account_id.as_str()) { + self.chat_widget + .add_to_history(history_cell::new_error_event( + "Switch to another managed account before deleting the active one.".to_string(), + )); + self.open_managed_account_delete_panel(); + return; + } + + let Some(display_name) = state + .accounts + .iter() + .find(|account| account.id == account_id) + .map(|account| account.display_name().to_string()) + else { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Managed account {account_id} was not found." + ))); + self.open_managed_account_delete_panel(); + return; + }; + + let auth_store = ManagedAccountAuthStore::new(self.config.codex_home.clone()); + if let Err(err) = auth_store.delete_account_auth(&account_id) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to remove managed account auth snapshot: {err}" + ))); + return; + } + + if let Err(err) = store.update(|state| { + state.remove_account(&account_id); + }) { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to delete managed account: {err}" + ))); + return; + } + + self.chat_widget + .add_to_history(history_cell::new_info_event( + format!("Deleted managed account {display_name}."), + None, + )); + self.open_managed_account_delete_panel(); + } + + fn jump_to_transcript_cell(&mut self, tui: &mut tui::Tui, cell_index: usize) { + if cell_index >= self.transcript_cells.len() { + self.chat_widget.add_error_message(format!( + "Transcript entry {cell_index} is no longer available." + )); + return; + } + + let _ = tui.enter_alt_screen(); + let mut overlay = Overlay::new_transcript(self.transcript_cells.clone()); + if let Overlay::Transcript(transcript) = &mut overlay { + transcript.set_highlight_cell(Some(cell_index)); + } + self.overlay = Some(overlay); + tui.frame_requester().schedule_frame(); + } + /// Marks a cached picker thread closed and recomputes the contextual footer label. /// /// Closing a thread is not the same as removing it: users can still inspect finished agent @@ -2157,7 +2882,9 @@ impl App { self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; + self.primary_session_configured = None; self.pending_primary_events.clear(); + self.loop_timers.thread_history_cells.clear(); self.chat_widget.set_pending_thread_approvals(Vec::new()); self.sync_active_agent_label(); } @@ -2210,6 +2937,7 @@ impl App { feedback_audience: self.feedback_audience, model: Some(model), startup_tooltip_override: None, + display_preferences: self.display_preferences.clone(), status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), session_telemetry: self.session_telemetry.clone(), terminal_title_invalid_items_warned: self.terminal_title_invalid_items_warned.clone(), @@ -2300,6 +3028,7 @@ impl App { for event in snapshot.events { self.handle_codex_event_replay(event); } + self.replay_loop_history_cells_for_active_thread(); self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); if resume_restored_queue { @@ -2429,6 +3158,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 = @@ -2456,6 +3186,7 @@ impl App { feedback_audience, model: Some(model.clone()), startup_tooltip_override, + display_preferences: display_preferences.clone(), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned @@ -2494,6 +3225,7 @@ impl App { feedback_audience, model: config.model.clone(), startup_tooltip_override: None, + display_preferences: display_preferences.clone(), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned @@ -2538,6 +3270,7 @@ impl App { feedback_audience, model: config.model.clone(), startup_tooltip_override: None, + display_preferences: display_preferences.clone(), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), session_telemetry: session_telemetry.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned @@ -2569,6 +3302,7 @@ impl App { harness_overrides, runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, + display_preferences, file_search, enhanced_keys_supported, transcript_cells: Vec::new(), @@ -2579,6 +3313,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, @@ -2586,6 +3321,8 @@ impl App { suppress_shutdown_complete: false, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), + btw_session: None, + loop_timers: LoopTimersState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), @@ -2793,36 +3530,308 @@ impl App { if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { frame.set_cursor_position((x, y)); } - }, - )?; - if self.chat_widget.external_editor_state() == ExternalEditorState::Requested { - self.chat_widget - .set_external_editor_state(ExternalEditorState::Active); - self.app_event_tx.send(AppEvent::LaunchExternalEditor); + }, + )?; + if self.chat_widget.external_editor_state() == ExternalEditorState::Requested { + self.chat_widget + .set_external_editor_state(ExternalEditorState::Active); + self.app_event_tx.send(AppEvent::LaunchExternalEditor); + } + } + } + } + Ok(AppRunControl::Continue) + } + + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + match event { + AppEvent::OpenControlPanel => { + self.open_control_panel(); + } + AppEvent::OpenDisplayPreferencesPanel => { + self.open_display_preferences_panel(); + } + AppEvent::StartBtwDiscussion { prompt } => { + self.start_btw_discussion(prompt).await; + } + AppEvent::BtwCompleted { thread_id, result } => { + self.finish_btw_discussion(thread_id, result); + } + AppEvent::BtwInsertSummary => { + self.insert_btw_summary(); + } + AppEvent::BtwInsertFull => { + self.insert_btw_full(); + } + AppEvent::BtwDiscard => { + self.discard_btw_session(); + } + AppEvent::OpenAccountsPanel => { + self.open_accounts_panel(); + } + AppEvent::OpenThreadPanel => { + self.open_thread_panel(); + } + AppEvent::OpenLoopTimersPanel => { + self.open_loop_timers_panel(); + } + AppEvent::OpenLoopExecutionPanel { timer_id } => { + self.open_loop_execution_panel(timer_id); + } + AppEvent::OpenJumpToMessagePanel => { + self.open_jump_to_message_panel(); + } + AppEvent::CreateLoopTimer { spec } => { + self.create_loop_timer(spec); + } + AppEvent::OpenCreateLoopTimerMenu => { + self.open_create_loop_timer_menu(); + } + AppEvent::OpenCreateOneShotLoopPrompt => { + self.chat_widget.open_create_one_shot_loop_prompt(); + } + AppEvent::OpenCreatePersistentLoopPrompt => { + self.chat_widget.open_create_persistent_loop_prompt(); + } + AppEvent::TriggerLoopTimer { + timer_id, + scheduled_for_unix_seconds, + source, + } => { + let cells = self + .trigger_loop_timer(timer_id, scheduled_for_unix_seconds, source) + .await; + for cell in cells { + self.append_visible_history_cell(tui, cell); + } + self.refresh_status_surfaces(); + } + AppEvent::OpenLoopTimerActions { timer_id } => { + self.open_loop_timer_actions(timer_id); + } + AppEvent::OpenEditLoopTimerPrompt { timer_id } => { + self.open_loop_timer_prompt_editor(timer_id); + } + AppEvent::OpenEditLoopTimerSchedule { timer_id } => { + self.open_loop_timer_schedule_editor(timer_id); + } + AppEvent::OpenEditLoopTimerAction { timer_id } => { + self.open_loop_timer_action_editor(timer_id); + } + AppEvent::OpenEditLoopTimerDeliveryMode { timer_id } => { + self.open_loop_timer_delivery_mode_menu(timer_id); + } + AppEvent::OpenEditLoopWritableRoots { timer_id } => { + self.open_loop_writable_roots_editor(timer_id); + } + AppEvent::OpenEditLoopTimerCwd { timer_id } => { + self.open_loop_timer_cwd_editor(timer_id); + } + AppEvent::SaveLoopTimerPrompt { timer_id, prompt } => { + self.save_loop_timer_prompt(timer_id, prompt); + } + AppEvent::SaveLoopTimerSchedule { timer_id, schedule } => { + self.save_loop_timer_schedule(timer_id, schedule); + } + AppEvent::SaveLoopTimerAction { timer_id, action } => { + self.save_loop_timer_action(timer_id, action); + } + AppEvent::SaveLoopTimerDeliveryMode { + timer_id, + delivery_mode, + } => { + self.save_loop_timer_delivery_mode(timer_id, delivery_mode); + } + AppEvent::SaveLoopWritableRoots { + timer_id, + writable_roots, + } => { + self.save_loop_writable_roots(timer_id, writable_roots); + } + AppEvent::SaveLoopTimerCwd { timer_id, cwd } => { + self.save_loop_timer_cwd(timer_id, cwd); + } + AppEvent::ResetLoopTimerCwd { timer_id } => { + self.reset_loop_timer_cwd(timer_id); + } + AppEvent::ResetLoopWritableRoots { timer_id } => { + self.reset_loop_writable_roots(timer_id); + } + AppEvent::EnableLoopTimer { timer_id } => { + self.set_loop_timer_enabled(timer_id, /*enabled*/ true); + } + AppEvent::DisableLoopTimer { timer_id } => { + self.set_loop_timer_enabled(timer_id, /*enabled*/ false); + } + AppEvent::DeleteLoopTimer { timer_id } => { + self.delete_loop_timer(timer_id); + } + AppEvent::LoopTimerCompleted { + timer_id, + prompt, + result, + } => { + let completion = self.finish_loop_timer(timer_id, prompt, result); + for cell in completion.cells { + self.append_visible_history_cell(tui, cell); + } + if let Some(followup_user_message) = completion.followup_user_message { + self.chat_widget + .submit_loop_followup_user_message(followup_user_message); + } + self.refresh_status_surfaces(); + } + AppEvent::UndoLastUserMessage => { + self.undo_last_user_message(); + tui.frame_requester().schedule_frame(); + } + AppEvent::OpenManagedAccountRenamePanel => { + self.open_managed_account_rename_panel(); + } + AppEvent::OpenManagedAccountDeletePanel => { + self.open_managed_account_delete_panel(); + } + AppEvent::RefreshManagedAccountQuota => { + self.refresh_managed_account_quota(); + } + AppEvent::SetManagedAccountActive(account_id) => { + self.set_managed_account_active(account_id); + } + AppEvent::OpenRenameManagedAccountAliasPrompt { + account_id, + current_alias, + } => { + self.chat_widget + .open_managed_account_alias_prompt(account_id, current_alias); + } + AppEvent::OpenDeleteManagedAccountConfirmation { + account_id, + display_name, + } => { + self.open_managed_account_delete_confirmation(account_id, display_name); + } + AppEvent::SaveManagedAccountAlias { account_id, alias } => { + self.save_managed_account_alias(account_id, alias); + } + AppEvent::DeleteManagedAccount(account_id) => { + self.delete_managed_account(account_id); + } + AppEvent::NewSession => { + self.start_fresh_session_with_summary_hint(tui).await; + } + AppEvent::ClearUi => { + self.clear_terminal_ui(tui, /*redraw_header*/ false)?; + self.reset_app_ui_state_after_clear(); + + self.start_fresh_session_with_summary_hint(tui).await; + } + AppEvent::OpenResumePicker => { + match crate::resume_picker::run_resume_picker( + tui, + &self.config, + /*show_all*/ false, + crate::resume_picker::SessionSourceFilter::InteractiveOnly, + ) + .await? + { + SessionSelection::Resume(target_session) => { + let current_cwd = self.config.cwd.to_path_buf(); + let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( + tui, + &self.config, + ¤t_cwd, + target_session.thread_id, + &target_session.path, + CwdPromptAction::Resume, + /*allow_prompt*/ true, + ) + .await? + { + crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd, + crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(), + crate::ResolveCwdOutcome::Exit => { + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } + }; + let mut resume_config = match self + .rebuild_config_for_resume_or_fallback(¤t_cwd, resume_cwd) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; + self.apply_runtime_policy_overrides(&mut resume_config); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + match self + .server + .resume_thread_from_rollout( + resume_config.clone(), + target_session.path.clone(), + self.auth_manager.clone(), + /*parent_trace*/ None, + ) + .await + { + Ok(resumed) => { + self.shutdown_current_thread().await; + self.config = resume_config; + tui.set_notification_method(self.config.tui_notification_method); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); + self.replace_chat_widget(ChatWidget::new_from_existing( + init, + resumed.thread, + resumed.session_configured, + )); + self.reset_thread_event_state(); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + let path_display = target_session.path.display(); + self.chat_widget.add_error_message(format!( + "Failed to resume session from {path_display}: {err}" + )); + } + } } + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::Fork(_) => {} } - } - } - Ok(AppRunControl::Continue) - } - - async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - match event { - AppEvent::NewSession => { - self.start_fresh_session_with_summary_hint(tui).await; - } - AppEvent::ClearUi => { - self.clear_terminal_ui(tui, /*redraw_header*/ false)?; - self.reset_app_ui_state_after_clear(); - self.start_fresh_session_with_summary_hint(tui).await; + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); } - AppEvent::OpenResumePicker => { + AppEvent::OpenResumePickerAll => { match crate::resume_picker::run_resume_picker( tui, &self.config, - /*show_all*/ false, - crate::resume_picker::SessionSourceFilter::InteractiveOnly, + /*show_all*/ true, + crate::resume_picker::SessionSourceFilter::IncludeNonInteractive, ) .await? { @@ -2997,29 +4006,7 @@ impl App { } 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; - } - } - if self.overlay.is_some() { - self.deferred_history_lines.extend(display); - } else { - tui.insert_history_lines(display); - } - } + self.append_visible_history_cell(tui, cell); } AppEvent::ApplyThreadRollback { num_turns } => { if self.apply_non_pending_thread_rollback(num_turns) { @@ -3154,7 +4141,10 @@ impl App { self.chat_widget.apply_file_search_result(query, matches); } AppEvent::RateLimitSnapshotFetched(snapshot) => { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); + self.apply_rate_limit_snapshot_to_accounts(snapshot); + } + AppEvent::ManagedAccountQuotaRefreshed(result) => { + self.finish_managed_account_quota_refresh(result); } AppEvent::ConnectorsLoaded { result, is_final } => { self.chat_widget.on_connectors_loaded(result, is_final); @@ -4015,9 +5005,33 @@ impl App { AppEvent::OpenAgentPicker => { self.open_agent_picker().await; } + AppEvent::ToggleDisplayPreference(key) => { + let enabled = !self.display_preferences.is_enabled(key); + let edit = display_preference_edit(key, enabled); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await + { + Ok(()) => { + self.display_preferences.set_enabled(key, enabled); + set_display_preference_in_config(&mut self.config, key, enabled); + } + Err(err) => { + tracing::error!(error = %err, "failed to persist display preference"); + self.chat_widget + .add_error_message(format!("Failed to save display preference: {err}")); + } + } + self.open_display_preferences_panel(); + tui.frame_requester().schedule_frame(); + } AppEvent::SelectAgentThread(thread_id) => { self.select_agent_thread(tui, thread_id).await?; } + AppEvent::JumpToTranscriptCell(cell_index) => { + self.jump_to_transcript_cell(tui, cell_index); + } AppEvent::OpenSkillsList => { self.chat_widget.open_skills_list(); } @@ -4520,9 +5534,9 @@ impl App { 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." - .to_string(), - )); + "Cannot open external editor: set $VISUAL or $EDITOR, or install `vim`." + .to_string(), + )); self.reset_external_editor_state(tui); return; } @@ -4577,7 +5591,46 @@ 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(); + } + } + None + } + } + } + async fn handle_key_event(&mut self, tui: &mut tui::Tui, 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 @@ -4696,12 +5749,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); } _ => { @@ -4760,6 +5807,8 @@ mod tests { use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; + use crate::display_preferences::DisplayPreferenceKey; + use crate::display_preferences::display_preference_edit; use crate::file_search::FileSearchManager; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; @@ -6914,10 +7963,13 @@ guardian_approval = true app.chat_widget.config_ref(), app.chat_widget.current_model(), event, - is_first, - None, - None, - false, + crate::history_cell::SessionInfoOptions { + is_first_event: is_first, + tooltip_override: None, + display_preferences: app.display_preferences.clone(), + auth_plan: None, + show_fast_status: false, + }, )) as Arc }; @@ -7009,6 +8061,62 @@ guardian_approval = true assert_snapshot!("clear_ui_header_fast_status_gpt54_only", rendered); } + #[tokio::test] + async fn display_preferences_panel_toggle_replaces_active_view_so_esc_closes_it() { + let mut app = make_test_app().await; + app.open_display_preferences_panel(); + assert!(app.chat_widget.has_active_view()); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget + .selected_index_for_active_view("fork-display-preferences-panel"), + Some(2) + ); + + app.display_preferences.set_enabled( + crate::display_preferences::DisplayPreferenceKey::ToolResults, + false, + ); + app.open_display_preferences_panel(); + assert_eq!( + app.chat_widget + .selected_index_for_active_view("fork-display-preferences-panel"), + Some(2) + ); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!app.chat_widget.has_active_view()); + } + + #[tokio::test] + async fn display_preferences_panel_esc_returns_to_control_panel() { + let mut app = make_test_app().await; + app.open_control_panel(); + app.open_display_preferences_panel(); + assert_eq!( + app.chat_widget + .selected_index_for_active_view("fork-display-preferences-panel"), + Some(0) + ); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget + .selected_index_for_active_view("fork-control-panel"), + Some(0) + ); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!app.chat_widget.has_active_view()); + } + 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(); @@ -7040,6 +8148,7 @@ guardian_approval = true harness_overrides: ConfigOverrides::default(), runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, + display_preferences: DisplayPreferences::default(), file_search, transcript_cells: Vec::new(), overlay: None, @@ -7050,6 +8159,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, @@ -7057,6 +8167,8 @@ guardian_approval = true suppress_shutdown_complete: false, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), + btw_session: None, + loop_timers: LoopTimersState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), @@ -7104,6 +8216,7 @@ guardian_approval = true harness_overrides: ConfigOverrides::default(), runtime_approval_policy_override: None, runtime_sandbox_policy_override: None, + display_preferences: DisplayPreferences::default(), file_search, transcript_cells: Vec::new(), overlay: None, @@ -7114,6 +8227,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, @@ -7121,6 +8235,8 @@ guardian_approval = true suppress_shutdown_complete: false, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), + btw_session: None, + loop_timers: LoopTimersState::default(), thread_event_channels: HashMap::new(), thread_event_listener_tasks: HashMap::new(), agent_navigation: AgentNavigationState::default(), @@ -7538,6 +8654,33 @@ guardian_approval = true Ok(()) } + #[tokio::test] + async fn refresh_in_memory_config_from_disk_loads_latest_display_preferences() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf(); + + assert!(app.display_preferences.show_tool_results()); + assert!(app.display_preferences.show_exec_commands()); + + ConfigEditsBuilder::new(&app.config.codex_home) + .with_edits([ + display_preference_edit(DisplayPreferenceKey::ToolResults, false), + display_preference_edit(DisplayPreferenceKey::ExecCommands, false), + ]) + .apply() + .await + .expect("persist display preferences"); + + app.refresh_in_memory_config_from_disk().await?; + + assert!(!app.display_preferences.show_tool_results()); + assert!(!app.display_preferences.show_exec_commands()); + assert!(!app.config.tui_display_preferences.show_tool_results); + assert!(!app.config.tui_display_preferences.show_exec_commands); + Ok(()) + } + #[tokio::test] async fn refresh_in_memory_config_from_disk_best_effort_keeps_current_config_on_error() -> Result<()> { @@ -7701,10 +8844,13 @@ guardian_approval = true app.chat_widget.config_ref(), app.chat_widget.current_model(), event, - is_first, - None, - None, - false, + crate::history_cell::SessionInfoOptions { + is_first_event: is_first, + tooltip_override: None, + display_preferences: app.display_preferences.clone(), + auth_plan: None, + show_fast_status: false, + }, )) as Arc }; @@ -7825,6 +8971,116 @@ guardian_approval = true assert_eq!(rollback_turns, Some(1)); } + #[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_u_triggers_undo_last_user_message() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + + app.transcript_cells = vec![Arc::new(UserHistoryCell { + message: "latest".to_string(), + text_elements: Vec::new(), + 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::NONE)), + None + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), "latest"); + + 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_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::NONE)), + None + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + } + #[tokio::test] async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().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..559e64da0 --- /dev/null +++ b/codex-rs/tui/src/app/btw.rs @@ -0,0 +1,495 @@ +use super::App; +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::render::renderable::ColumnRenderable; +use codex_btw::full_insert_text; +use codex_btw::merge_developer_instructions; +use codex_btw::preview_text; +use codex_btw::summarize_message; +use codex_core::CodexThread; +use codex_core::RolloutRecorder; +use codex_protocol::ThreadId; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::TurnItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use std::path::Path; +use std::sync::Arc; +use tokio::task::JoinHandle; + +const BTW_DISCUSSION_VIEW_ID: &str = "btw_discussion"; +const BTW_CONTEXT_BUDGET_TOKENS: usize = 2_000; + +pub(crate) struct BtwSessionState { + pub(crate) thread_id: ThreadId, + pub(crate) thread: Arc, + pub(crate) listener_handle: JoinHandle<()>, + pub(crate) final_message: Option, +} + +impl App { + pub(crate) async fn start_btw_discussion(&mut self, 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; + } + + let mut btw_config = self.config.clone(); + btw_config.ephemeral = true; + btw_config.include_apply_patch_tool = false; + if let Err(err) = btw_config + .permissions + .approval_policy + .set(AskForApproval::Never) + { + self.chat_widget + .add_error_message(format!("Failed to configure `/btw` approvals: {err}")); + return; + } + if let Err(err) = btw_config + .permissions + .sandbox_policy + .set(SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::default(), + network_access: false, + }) + { + self.chat_widget + .add_error_message(format!("Failed to configure `/btw` sandbox: {err}")); + return; + } + btw_config.developer_instructions = Some(merge_developer_instructions( + btw_config.developer_instructions.take(), + )); + + let initial_history = + build_btw_initial_history(self.chat_widget.rollout_path().as_deref()).await; + self.open_btw_loading_panel(); + + let new_thread = match self + .server + .start_thread_with_history_and_source( + btw_config, + initial_history, + SessionSource::SubAgent(SubAgentSource::Other("btw".to_string())), + ) + .await + { + Ok(new_thread) => new_thread, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to start `/btw`: {err}")); + return; + } + }; + + let thread_id = new_thread.thread_id; + let thread = new_thread.thread; + let app_event_tx = self.app_event_tx.clone(); + let listener_thread = Arc::clone(&thread); + let listener_handle = tokio::spawn(async move { + let mut last_agent_message = None; + loop { + match listener_thread.next_event().await { + Ok(event) => match event.msg { + EventMsg::ItemCompleted(item_completed) => { + if let TurnItem::AgentMessage(message) = item_completed.item { + let text = message + .content + .into_iter() + .map(|content| match content { + AgentMessageContent::Text { text } => text, + }) + .collect::(); + if !text.trim().is_empty() { + last_agent_message = Some(text); + } + } + } + EventMsg::TurnComplete(turn_complete) => { + let message = turn_complete.last_agent_message.or(last_agent_message); + app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: message.ok_or_else(|| { + "Temporary discussion finished without a final answer." + .to_string() + }), + }); + break; + } + EventMsg::Error(error) => { + app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err(error.message), + }); + break; + } + EventMsg::ShutdownComplete => { + app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err("Temporary discussion closed before a final answer." + .to_string()), + }); + break; + } + _ => {} + }, + Err(err) => { + app_event_tx.send(AppEvent::BtwCompleted { + thread_id, + result: Err(format!("Temporary discussion failed: {err}")), + }); + break; + } + } + } + }); + + self.btw_session = Some(BtwSessionState { + thread_id, + thread: Arc::clone(&thread), + listener_handle, + final_message: None, + }); + + let op = Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: trimmed_prompt.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }; + if let Err(err) = thread.submit(op).await { + self.chat_widget + .add_error_message(format!("Failed to submit `/btw`: {err}")); + self.cleanup_btw_session(); + } + } + + 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_panel(&message); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("`/btw` failed: {err}")); + self.cleanup_btw_session(); + } + } + } + + pub(crate) fn insert_btw_summary(&mut self) { + let Some(message) = self + .btw_session + .as_ref() + .and_then(|session| session.final_message.as_deref()) + else { + self.chat_widget + .add_error_message("`/btw` summary is unavailable.".to_string()); + self.cleanup_btw_session(); + return; + }; + + let summary = summarize_message(message); + self.insert_btw_text(summary, "Inserted `/btw` summary into the composer."); + } + + pub(crate) fn insert_btw_full(&mut self) { + let Some(message) = self + .btw_session + .as_ref() + .and_then(|session| session.final_message.as_deref()) + else { + self.chat_widget + .add_error_message("`/btw` answer is unavailable.".to_string()); + self.cleanup_btw_session(); + return; + }; + + let text = full_insert_text(message); + self.insert_btw_text(text, "Inserted `/btw` answer into the composer."); + } + + pub(crate) fn discard_btw_session(&mut self) { + self.chat_widget.add_info_message( + "Discarded `/btw` discussion.".to_string(), + /*hint*/ None, + ); + self.cleanup_btw_session(); + } + + fn insert_btw_text(&mut self, text: String, confirmation: &str) { + if !self + .chat_widget + .composer_text_with_pending() + .trim() + .is_empty() + { + self.chat_widget.insert_str("\n\n"); + } + self.chat_widget.insert_str(&text); + self.chat_widget + .add_info_message(confirmation.to_string(), /*hint*/ None); + self.cleanup_btw_session(); + } + + fn cleanup_btw_session(&mut self) { + let Some(session) = self.btw_session.take() else { + return; + }; + + session.listener_handle.abort(); + let thread = session.thread; + let thread_id = session.thread_id; + let server = Arc::clone(&self.server); + tokio::spawn(async move { + let _ = thread.shutdown_and_wait().await; + let _ = server.remove_thread(&thread_id).await; + }); + } + + fn open_btw_loading_panel(&mut self) { + self.chat_widget + .show_selection_view(btw_loading_view_params()); + } + + fn open_btw_result_panel(&mut self, message: &str) { + self.chat_widget + .show_selection_view(btw_result_view_params(message)); + } +} + +fn btw_loading_view_params() -> SelectionViewParams { + 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: ColumnRenderable::with(vec![ + Box::new("Temporary `/btw` discussion".to_string()) + as Box, + Box::new( + Paragraph::new( + "Codex is answering in a hidden ephemeral thread. Nothing will be written back \ + to the main thread unless you explicitly choose an insert action." + .to_string(), + ) + .wrap(Wrap { trim: false }), + ) as Box, + ]) + .into(), + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +fn btw_result_view_params(message: &str) -> SelectionViewParams { + let preview = preview_text(message); + 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 thread untouched." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::BtwDiscard))], + dismiss_on_select: true, + ..Default::default() + }, + ], + side_content: Paragraph::new(preview).wrap(Wrap { trim: false }).into(), + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::BtwDiscard))), + ..Default::default() + } +} + +async fn build_btw_initial_history(rollout_path: Option<&Path>) -> InitialHistory { + let Some(rollout_path) = rollout_path else { + return InitialHistory::New; + }; + let Ok(history) = RolloutRecorder::get_rollout_history(rollout_path).await else { + return InitialHistory::New; + }; + let items = history.get_rollout_items(); + if items.is_empty() { + return InitialHistory::New; + } + + let session_meta = items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(_) => Some(item.clone()), + _ => None, + }); + let latest_turn_context_index = items + .iter() + .enumerate() + .rev() + .find_map(|(index, item)| matches!(item, RolloutItem::TurnContext(_)).then_some(index)); + let latest_turn_context = latest_turn_context_index.map(|index| items[index].clone()); + + let mut used_tokens = 0usize; + let mut selected_tail = Vec::new(); + for (index, item) in items.iter().enumerate().rev() { + if matches!(item, RolloutItem::SessionMeta(_)) { + continue; + } + if Some(index) == latest_turn_context_index { + continue; + } + + let item_tokens = approx_rollout_item_tokens(item); + if !selected_tail.is_empty() + && used_tokens.saturating_add(item_tokens) > BTW_CONTEXT_BUDGET_TOKENS + { + break; + } + used_tokens = used_tokens.saturating_add(item_tokens); + selected_tail.push(item.clone()); + } + selected_tail.reverse(); + + let mut selected = Vec::new(); + if let Some(session_meta) = session_meta { + selected.push(session_meta); + } + if let Some(turn_context) = latest_turn_context { + selected.push(turn_context); + } + selected.extend(selected_tail); + + if selected.is_empty() { + InitialHistory::New + } else { + InitialHistory::Forked(selected) + } +} + +fn approx_rollout_item_tokens(item: &RolloutItem) -> usize { + serde_json::to_string(item) + .ok() + .map(|text| text.len().saturating_add(3) / 4) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::btw_loading_view_params; + use super::btw_result_view_params; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::list_selection_view::ListSelectionView; + use crate::render::renderable::Renderable; + use codex_btw::summarize_message; + 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; + + 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(), tx); + + assert_snapshot!("btw_loading_popup", render_selection_popup(&view, 92, 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, 92, 28)); + } + + #[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." + ); + } +} diff --git a/codex-rs/tui/src/app/display_preferences_menu.rs b/codex-rs/tui/src/app/display_preferences_menu.rs new file mode 100644 index 000000000..a818d354d --- /dev/null +++ b/codex-rs/tui/src/app/display_preferences_menu.rs @@ -0,0 +1,102 @@ +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionItem; +use crate::display_preferences::DisplayPreferenceKey; +use crate::display_preferences::DisplayPreferences; + +pub(crate) fn control_panel_show_hide_item() -> SelectionItem { + SelectionItem { + name: "Show / Hide UI".to_string(), + description: None, + selected_description: Some( + "Toggle local TUI-only transcript and UI visibility settings.".to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenDisplayPreferencesPanel) + })], + dismiss_on_select: false, + ..Default::default() + } +} + +pub(crate) fn display_preferences_items( + display_preferences: &DisplayPreferences, +) -> Vec { + [ + DisplayPreferenceKey::RawThinking, + DisplayPreferenceKey::StartupTooltips, + DisplayPreferenceKey::ToolResults, + DisplayPreferenceKey::ExecCommands, + DisplayPreferenceKey::WaitedMessages, + DisplayPreferenceKey::PatchDiffs, + ] + .into_iter() + .map(|key| display_preference_item(display_preferences, key)) + .collect() +} + +fn display_preference_item( + display_preferences: &DisplayPreferences, + key: DisplayPreferenceKey, +) -> SelectionItem { + let enabled = display_preferences.is_enabled(key); + let (name, description) = match (key, enabled) { + (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::StartupTooltips, true) => ( + "Hide Startup Tooltips", + "Currently visible. Hide welcome and session tooltip hints.", + ), + (DisplayPreferenceKey::StartupTooltips, false) => ( + "Show Startup Tooltips", + "Currently hidden. Reveal welcome and session tooltip hints.", + ), + (DisplayPreferenceKey::ToolResults, true) => ( + "Hide Tool Results", + "Currently visible. Keep tool invocations but collapse result details.", + ), + (DisplayPreferenceKey::ToolResults, false) => ( + "Show Tool Results", + "Currently hidden. Reveal tool result details in transcript cells.", + ), + (DisplayPreferenceKey::ExecCommands, true) => ( + "Hide Command Execution", + "Currently visible. Hide command execution cells and keep model replies.", + ), + (DisplayPreferenceKey::ExecCommands, false) => ( + "Show Command Execution", + "Currently hidden. Reveal command execution cells in this TUI only.", + ), + (DisplayPreferenceKey::WaitedMessages, true) => ( + "Hide Waited Messages", + "Currently visible. Hide 'Waited for ...' background terminal messages.", + ), + (DisplayPreferenceKey::WaitedMessages, false) => ( + "Show Waited Messages", + "Currently hidden. Reveal 'Waited for ...' background terminal messages.", + ), + (DisplayPreferenceKey::PatchDiffs, true) => ( + "Hide Patch / Edit Diff", + "Currently visible. Collapse patch and edit diff summaries.", + ), + (DisplayPreferenceKey::PatchDiffs, false) => ( + "Show Patch / Edit Diff", + "Currently hidden. Reveal patch and edit diff summaries.", + ), + }; + + 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() + } +} 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..768e4e89d --- /dev/null +++ b/codex-rs/tui/src/app/jump_navigation.rs @@ -0,0 +1,106 @@ +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; +use codex_threadmessages::JumpCatalog; +use codex_threadmessages::JumpTarget; +use codex_threadmessages::JumpTargetKind; +use ratatui::text::Line; +use std::sync::Arc; + +pub(crate) fn build_jump_catalog(cells: &[Arc]) -> JumpCatalog { + 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; + } + + JumpCatalog::new(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 super::build_jump_catalog; + use crate::history_cell::AgentMessageCell; + use crate::history_cell::PlainHistoryCell; + use crate::history_cell::UserHistoryCell; + use codex_threadmessages::JumpTargetKind; + use pretty_assertions::assert_eq; + use ratatui::text::Line; + use std::sync::Arc; + + #[test] + fn build_jump_catalog_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")], + true, + )), + ]; + + let catalog = build_jump_catalog(&cells); + + assert_eq!(catalog.len(), 2); + assert_eq!(catalog.targets[0].kind, JumpTargetKind::UserMessage); + assert_eq!(catalog.targets[0].preview, "first question"); + assert_eq!(catalog.targets[1].kind, JumpTargetKind::AgentMessage); + assert_eq!(catalog.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..7d7b3778d --- /dev/null +++ b/codex-rs/tui/src/app/key_chord.rs @@ -0,0 +1,146 @@ +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, +} + +#[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::NONE) => { + KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage) + } + (KeyCode::Char('y'), KeyModifiers::NONE) => { + KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput) + } + (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_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::NONE)), + KeyChordResolution::Matched(KeyChordAction::UndoLastUserMessage) + ); + assert_eq!(state, KeyChordState::Idle); + } + + #[test] + fn ctrl_x_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::NONE)), + KeyChordResolution::Matched(KeyChordAction::CopyLatestOutput) + ); + 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/loop_timers.rs b/codex-rs/tui/src/app/loop_timers.rs new file mode 100644 index 000000000..f9f2375d4 --- /dev/null +++ b/codex-rs/tui/src/app/loop_timers.rs @@ -0,0 +1,1826 @@ +use super::App; +use crate::app_event::AppEvent; +use crate::app_event::LoopTimerTriggerSource; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::history_cell::AgentMessageCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::UserHistoryCell; +use crate::history_cell::new_info_event; +use crate::markdown::append_markdown; +use chrono::DateTime; +use chrono::Utc; +use codex_core::CodexThread; +use codex_core::RolloutRecorder; +use codex_core::config::types::TuiLoopCompletionMirrorMode; +use codex_core::content_items_to_text; +use codex_loop::LoopCommand; +use codex_loop::LoopDeliveryMode; +use codex_loop::LoopMode; +use codex_loop::PersistedLoopExecutionSettings; +use codex_loop::PersistedLoopTimer; +use codex_loop::PersistedLoopTimersFile; +use codex_loop::apply_loop_execution_settings; +use codex_loop::build_loop_result_user_message_with_action; +use codex_loop::build_loop_run_input; +use codex_loop::cwd_editor_text; +use codex_loop::effective_loop_delivery_mode; +use codex_loop::format_timestamp; +use codex_loop::load_loop_timers; +use codex_loop::loop_execution_summary; +use codex_loop::loop_id_prefix; +use codex_loop::loop_item_name; +use codex_loop::loop_timers_path; +use codex_loop::next_due_for_timer; +use codex_loop::parse_loop_command; +use codex_loop::parse_loop_cwd; +use codex_loop::parse_loop_schedule; +use codex_loop::parse_loop_writable_roots; +use codex_loop::prompt_prefix; +use codex_loop::timer_descriptor; +use codex_loop::writable_roots_editor_text; +use codex_protocol::ThreadId; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use ratatui::text::Line; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::task::JoinHandle; + +const LOOP_TIMERS_VIEW_ID: &str = "fork-loop-timers-panel"; +const LOOP_CREATE_VIEW_ID: &str = "fork-loop-create-panel"; +const LOOP_TIMER_ACTIONS_VIEW_ID: &str = "fork-loop-timer-actions-panel"; +const LOOP_EXECUTION_VIEW_ID: &str = "fork-loop-execution-panel"; +const LOOP_CONTEXT_BUDGET_TOKENS: usize = 2_000; + +#[derive(Default)] +pub(crate) struct LoopTimersState { + workspace_cwd: Option, + timers: BTreeMap, + scheduler_tasks: HashMap>, + active_runs: HashMap, + pub(super) thread_history_cells: HashMap>>, +} + +struct ActiveLoopRun { + thread_id: ThreadId, + thread: Arc, + listener_handle: JoinHandle<()>, +} + +pub(crate) struct LoopTimerCompletion { + pub(crate) cells: Vec>, + pub(crate) followup_user_message: Option, +} + +impl App { + fn loop_timers_panel_params(&self, initial_selected_idx: Option) -> SelectionViewParams { + let path = loop_timers_path(self.config.cwd.as_path()); + let subtitle = Some(format!( + "{} timer(s) configured for {}.", + self.loop_timers.timers.len(), + self.config.cwd.display() + )); + + let mut items = self + .loop_timers + .timers + .values() + .map(|timer| { + loop_timer_selection_item( + timer, + self.loop_timers.active_runs.contains_key(&timer.id), + ) + }) + .collect::>(); + + items.insert( + 0, + SelectionItem { + name: "Create Loop Agent".to_string(), + description: Some( + "Create a one-shot or persistent `/loop` entry from a guided form.".to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenCreateLoopTimerMenu))], + dismiss_on_select: true, + ..Default::default() + }, + ); + + if self.loop_timers.timers.is_empty() { + items.push(SelectionItem { + name: "No loop timers yet".to_string(), + description: Some( + "Use Create Loop Agent or `/loop 5m ` to add one.".to_string(), + ), + is_disabled: true, + ..Default::default() + }); + } + + SelectionViewParams { + view_id: Some(LOOP_TIMERS_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle, + footer_hint: Some(standard_popup_hint_line()), + footer_path: Some(path.display().to_string()), + initial_selected_idx, + items, + ..Default::default() + } + } + + pub(crate) fn open_loop_timers_panel(&mut self) { + self.ensure_loop_timers_loaded(); + + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(LOOP_TIMERS_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + LOOP_TIMERS_VIEW_ID, + self.loop_timers_panel_params(initial_selected_idx), + ) { + self.chat_widget + .show_selection_view(self.loop_timers_panel_params(initial_selected_idx)); + } + } + + pub(crate) fn open_create_loop_timer_menu(&mut self) { + self.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some(LOOP_CREATE_VIEW_ID), + title: Some("Loop Manager".to_string()), + subtitle: Some("Create loop agent".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "One-Shot Loop".to_string(), + description: Some( + "Schedule a loop that keeps firing, but uses a fresh hidden thread each run." + .to_string(), + ), + actions: vec![Box::new(|tx| tx.send(AppEvent::OpenCreateOneShotLoopPrompt))], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Persistent Loop".to_string(), + description: Some( + "Schedule a loop with a stable id and a private long-lived hidden context." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenCreatePersistentLoopPrompt) + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + on_cancel: Some(Box::new(|tx| tx.send(AppEvent::OpenLoopTimersPanel))), + ..Default::default() + }); + } + + fn loop_execution_panel_params( + &self, + timer_id: &str, + initial_selected_idx: Option, + ) -> SelectionViewParams { + let Some(timer) = self.loop_timers.timers.get(timer_id) else { + return SelectionViewParams::default(); + }; + let items = vec![ + SelectionItem { + name: "Working Directory".to_string(), + description: Some(loop_execution_summary(&timer.execution, self.config.cwd.as_path())), + actions: vec![Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::OpenEditLoopTimerCwd { + timer_id: timer_id.clone(), + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use Session Working Directory".to_string(), + description: Some( + "Clear the per-loop cwd override and inherit the main thread working directory." + .to_string(), + ), + is_disabled: timer.execution.cwd.is_none(), + actions: vec![Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::ResetLoopTimerCwd { + timer_id: timer_id.clone(), + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Writable Directories".to_string(), + description: Some( + "Restrict loop file writes to specific directories. Leave empty to inherit the session scope." + .to_string(), + ), + actions: vec![Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::OpenEditLoopWritableRoots { + timer_id: timer_id.clone(), + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use Session Writable Scope".to_string(), + description: Some( + "Clear the per-loop writable-directory override and inherit the main thread sandbox scope." + .to_string(), + ), + is_disabled: timer.execution.writable_roots.is_empty(), + actions: vec![Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::ResetLoopWritableRoots { + timer_id: timer_id.clone(), + }) + } + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + SelectionViewParams { + view_id: Some(LOOP_EXECUTION_VIEW_ID), + title: Some("Loop Execution".to_string()), + subtitle: Some(format!("Execution settings · {}", timer_descriptor(timer))), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + on_cancel: Some(Box::new({ + let timer_id = timer_id.to_string(); + move |tx| { + tx.send(AppEvent::OpenLoopTimerActions { + timer_id: timer_id.clone(), + }) + } + })), + ..Default::default() + } + } + + pub(crate) fn open_loop_execution_panel(&mut self, timer_id: String) { + self.ensure_loop_timers_loaded(); + + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(LOOP_EXECUTION_VIEW_ID); + if !self.chat_widget.replace_selection_view_if_active( + LOOP_EXECUTION_VIEW_ID, + self.loop_execution_panel_params(&timer_id, initial_selected_idx), + ) { + self.chat_widget.show_selection_view( + self.loop_execution_panel_params(&timer_id, initial_selected_idx), + ); + } + } + + fn refresh_loop_timers_panel_if_active(&mut self) { + let initial_selected_idx = self + .chat_widget + .selected_index_for_active_view(LOOP_TIMERS_VIEW_ID); + let _ = self.chat_widget.replace_selection_view_if_active( + LOOP_TIMERS_VIEW_ID, + self.loop_timers_panel_params(initial_selected_idx), + ); + } + + pub(crate) fn create_loop_timer(&mut self, spec: String) { + self.ensure_loop_timers_loaded(); + + let parsed = match parse_loop_command(spec.trim()) { + Ok(parsed) => parsed, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to create `/loop`: {err}")); + return; + } + }; + + let now = Utc::now(); + let (timer_id, message) = match parsed { + LoopCommand::Focus { id } => { + if self.loop_timers.timers.contains_key(&id) { + self.open_loop_timer_actions(id); + } else { + self.chat_widget.add_error_message(format!( + "Unknown loop `{id}`. Create it with `/loop {id}