mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
Merge upstream/main: keep ours for TUI/CLI/workflows/docs; adopt compatible core/test additions (by-bucket)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 408 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
@@ -0,0 +1,9 @@
|
||||
Attempt to solve the reported issue.
|
||||
|
||||
If a code change is required, create a new branch, commit the fix, and open a pull request that resolves the problem.
|
||||
|
||||
Here is the original GitHub issue that triggered this run:
|
||||
|
||||
### {CODEX_ACTION_ISSUE_TITLE}
|
||||
|
||||
{CODEX_ACTION_ISSUE_BODY}
|
||||
@@ -0,0 +1,7 @@
|
||||
Review this PR and respond with a very concise final message, formatted in Markdown.
|
||||
|
||||
There should be a summary of the changes (1-2 sentences) and a few bullet points if necessary.
|
||||
|
||||
Then provide the **review** (1-2 sentences plus bullet points, friendly tone).
|
||||
|
||||
{CODEX_ACTION_GITHUB_EVENT_PATH} contains the JSON that triggered this GitHub workflow. It contains the `base` and `head` refs that define this PR. Both refs are available locally.
|
||||
@@ -0,0 +1,139 @@
|
||||
Review this PR and respond with a very concise final message, formatted in Markdown.
|
||||
|
||||
There should be a summary of the changes (1-2 sentences) and a few bullet points if necessary.
|
||||
|
||||
Then provide the **review** (1-2 sentences plus bullet points, friendly tone).
|
||||
|
||||
Things to look out for when doing the review:
|
||||
|
||||
## General Principles
|
||||
|
||||
- **Make sure the pull request body explains the motivation behind the change.** If the author has failed to do this, call it out, and if you think you can deduce the motivation behind the change, propose copy.
|
||||
- Ideally, the PR body also contains a small summary of the change. For small changes, the PR title may be sufficient.
|
||||
- Each PR should ideally do one conceptual thing. For example, if a PR does a refactoring as well as introducing a new feature, push back and suggest the refactoring be done in a separate PR. This makes things easier for the reviewer, as refactoring changes can often be far-reaching, yet quick to review.
|
||||
- When introducing new code, be on the lookout for code that duplicates existing code. When found, propose a way to refactor the existing code such that it should be reused.
|
||||
|
||||
## Code Organization
|
||||
|
||||
- Each create in the Cargo workspace in `codex-rs` has a specific purpose: make a note if you believe new code is not introduced in the correct crate.
|
||||
- When possible, try to keep the `core` crate as small as possible. Non-core but shared logic is often a good candidate for `codex-rs/common`.
|
||||
- Be wary of large files and offer suggestions for how to break things into more reasonably-sized files.
|
||||
- Rust files should generally be organized such that the public parts of the API appear near the top of the file and helper functions go below. This is analagous to the "inverted pyramid" structure that is favored in journalism.
|
||||
|
||||
## Assertions in Tests
|
||||
|
||||
Assert the equality of the entire objects instead of doing "piecemeal comparisons," performing `assert_eq!()` on individual fields.
|
||||
|
||||
Note that unit tests also function as "executable documentation." As shown in the following example, "piecemeal comparisons" are often more verbose, provide less coverage, and are not as useful as executable documentation.
|
||||
|
||||
For example, suppose you have the following enum:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Message {
|
||||
Request {
|
||||
id: String,
|
||||
method: String,
|
||||
params: Option<serde_json::Value>,
|
||||
},
|
||||
Notification {
|
||||
method: String,
|
||||
params: Option<serde_json::Value>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This is an example of a _piecemeal_ comparison:
|
||||
|
||||
```rust
|
||||
// BAD: Piecemeal Comparison
|
||||
|
||||
#[test]
|
||||
fn test_get_latest_messages() {
|
||||
let messages = get_latest_messages();
|
||||
assert_eq!(messages.len(), 2);
|
||||
|
||||
let m0 = &messages[0];
|
||||
match m0 {
|
||||
Message::Request { id, method, params } => {
|
||||
assert_eq!(id, "123");
|
||||
assert_eq!(method, "subscribe");
|
||||
assert_eq!(
|
||||
*params,
|
||||
Some(json!({
|
||||
"conversation_id": "x42z86"
|
||||
}))
|
||||
)
|
||||
}
|
||||
Message::Notification { .. } => {
|
||||
panic!("expected Request");
|
||||
}
|
||||
}
|
||||
|
||||
let m1 = &messages[1];
|
||||
match m1 {
|
||||
Message::Request { .. } => {
|
||||
panic!("expected Notification");
|
||||
}
|
||||
Message::Notification { method, params } => {
|
||||
assert_eq!(method, "log");
|
||||
assert_eq!(
|
||||
*params,
|
||||
Some(json!({
|
||||
"level": "info",
|
||||
"message": "subscribed"
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a _deep_ comparison:
|
||||
|
||||
```rust
|
||||
// GOOD: Verify the entire structure with a single assert_eq!().
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_get_latest_messages() {
|
||||
let messages = get_latest_messages();
|
||||
|
||||
assert_eq!(
|
||||
vec![
|
||||
Message::Request {
|
||||
id: "123".to_string(),
|
||||
method: "subscribe".to_string(),
|
||||
params: Some(json!({
|
||||
"conversation_id": "x42z86"
|
||||
})),
|
||||
},
|
||||
Message::Notification {
|
||||
method: "log".to_string(),
|
||||
params: Some(json!({
|
||||
"level": "info",
|
||||
"message": "subscribed"
|
||||
})),
|
||||
},
|
||||
],
|
||||
messages,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## More Tactical Rust Things To Look Out For
|
||||
|
||||
- Do not use `unsafe` (unless you have a really, really good reason like using an operating system API directly and no safe wrapper exists). For example, there are cases where it is tempting to use `unsafe` in order to use `std::env::set_var()`, but this indeed `unsafe` and has led to race conditions on multiple occasions. (When this happens, find a mechanism other than environment variables to use for configuration.)
|
||||
- Encourage the use of small enums or the newtype pattern in Rust if it helps readability without adding significant cognitive load or lines of code.
|
||||
- If you see opportunities for the changes in a diff to use more idiomatic Rust, please make specific recommendations. For example, favor the use of expressions over `return`.
|
||||
- When modifying a `Cargo.toml` file, make sure that dependency lists stay alphabetically sorted. Also consider whether a new dependency is added to the appropriate place (e.g., `[dependencies]` versus `[dev-dependencies]`)
|
||||
|
||||
## Pull Request Body
|
||||
|
||||
- If the nature of the change seems to have a visual component (which is often the case for changes to `codex-rs/tui`), recommend including a screenshot or video to demonstrate the change, if appropriate.
|
||||
- References to existing GitHub issues and PRs are encouraged, where appropriate, though you likely do not have network access, so may not be able to help here.
|
||||
|
||||
# PR Information
|
||||
|
||||
{CODEX_ACTION_GITHUB_EVENT_PATH} contains the JSON that triggered this GitHub workflow. It contains the `base` and `head` refs that define this PR. Both refs are available locally.
|
||||
@@ -0,0 +1,7 @@
|
||||
Troubleshoot whether the reported issue is valid.
|
||||
|
||||
Provide a concise and respectful comment summarizing the findings.
|
||||
|
||||
### {CODEX_ACTION_ISSUE_TITLE}
|
||||
|
||||
{CODEX_ACTION_ISSUE_BODY}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 MiB |
@@ -0,0 +1,6 @@
|
||||
# External (non-OpenAI) Pull Request Requirements
|
||||
|
||||
Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed:
|
||||
https://github.com/openai/codex/blob/main/docs/contributing.md
|
||||
|
||||
If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes.
|
||||
@@ -0,0 +1,60 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request: { branches: [main] }
|
||||
push: { branches: [main] }
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.8.1
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.store_path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
# Run all tasks using workspace filters
|
||||
|
||||
- name: Ensure staging a release works.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ./codex-cli/scripts/stage_release.sh
|
||||
|
||||
- name: Ensure root README.md contains only ASCII and certain Unicode code points
|
||||
run: ./scripts/asciicheck.py README.md
|
||||
- name: Check root README ToC
|
||||
run: python3 scripts/readme_toc.py README.md
|
||||
|
||||
- name: Ensure codex-cli/README.md contains only ASCII and certain Unicode code points
|
||||
run: ./scripts/asciicheck.py codex-cli/README.md
|
||||
- name: Check codex-cli/README ToC
|
||||
run: python3 scripts/readme_toc.py codex-cli/README.md
|
||||
@@ -0,0 +1,29 @@
|
||||
name: CLA Assistant
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: contributor-assistant/[email protected]
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
path-to-document: https://github.com/openai/codex/blob/main/docs/CLA.md
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: cla-signatures
|
||||
allowlist: dependabot[bot]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Codespell configuration is within .codespellrc
|
||||
---
|
||||
name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codespell:
|
||||
name: Check for spelling errors
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Annotate locations with typos
|
||||
uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1
|
||||
- name: Codespell
|
||||
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2
|
||||
with:
|
||||
ignore_words_file: .codespellignore
|
||||
@@ -0,0 +1,204 @@
|
||||
name: rust-ci
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
# CI builds in debug (dev) for faster signal.
|
||||
|
||||
jobs:
|
||||
# --- Detect what changed (always runs) -------------------------------------
|
||||
changed:
|
||||
name: Detect changed areas
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
codex: ${{ steps.detect.outputs.codex }}
|
||||
workflows: ${{ steps.detect.outputs.workflows }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Detect changed paths (no external action)
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
BASE_SHA='${{ github.event.pull_request.base.sha }}'
|
||||
echo "Base SHA: $BASE_SHA"
|
||||
# List files changed between base and current HEAD (merge-base aware)
|
||||
mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA"...HEAD)
|
||||
else
|
||||
# On push / manual runs, default to running everything
|
||||
files=("codex-rs/force" ".github/force")
|
||||
fi
|
||||
|
||||
codex=false
|
||||
workflows=false
|
||||
for f in "${files[@]}"; do
|
||||
[[ $f == codex-rs/* ]] && codex=true
|
||||
[[ $f == .github/* ]] && workflows=true
|
||||
done
|
||||
|
||||
echo "codex=$codex" >> "$GITHUB_OUTPUT"
|
||||
echo "workflows=$workflows" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# --- CI that doesn't need specific targets ---------------------------------
|
||||
general:
|
||||
name: Format / etc
|
||||
runs-on: ubuntu-24.04
|
||||
needs: changed
|
||||
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: codex-rs
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/[email protected]
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: cargo fmt
|
||||
run: cargo fmt -- --config imports_granularity=Item --check
|
||||
|
||||
# --- CI to validate on different os/targets --------------------------------
|
||||
lint_build_test:
|
||||
name: ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
needs: changed
|
||||
# Keep job-level if to avoid spinning up runners when not needed
|
||||
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: codex-rs
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
profile: dev
|
||||
- runner: macos-14
|
||||
target: x86_64-apple-darwin
|
||||
profile: dev
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
profile: dev
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
profile: dev
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
profile: dev
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
profile: dev
|
||||
- runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
profile: dev
|
||||
- runner: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
profile: dev
|
||||
|
||||
# Also run representative release builds on Mac and Linux because
|
||||
# there could be release-only build errors we want to catch.
|
||||
# Hopefully this also pre-populates the build cache to speed up
|
||||
# releases.
|
||||
- runner: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
profile: release
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
profile: release
|
||||
- runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
profile: release
|
||||
- runner: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
profile: release
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/[email protected]
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
components: clippy
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
${{ github.workspace }}/codex-rs/target/
|
||||
key: cargo-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
|
||||
name: Install musl build tools
|
||||
run: |
|
||||
sudo apt install -y musl-tools pkg-config && sudo rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: cargo clippy
|
||||
id: clippy
|
||||
run: cargo clippy --target ${{ matrix.target }} --all-features --tests --profile ${{ matrix.profile }} -- -D warnings
|
||||
|
||||
# Running `cargo build` from the workspace root builds the workspace using
|
||||
# the union of all features from third-party crates. This can mask errors
|
||||
# where individual crates have underspecified features. To avoid this, we
|
||||
# run `cargo check` for each crate individually, though because this is
|
||||
# slower, we only do this for the x86_64-unknown-linux-gnu target.
|
||||
- name: cargo check individual crates
|
||||
id: cargo_check_all_crates
|
||||
if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' && matrix.profile != 'release' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 \
|
||||
| xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo check --profile ${{ matrix.profile }}'
|
||||
|
||||
- name: cargo test
|
||||
id: test
|
||||
# `cargo test` takes too long for release builds to run them on every PR
|
||||
if: ${{ matrix.profile != 'release' }}
|
||||
continue-on-error: true
|
||||
run: cargo test --all-features --target ${{ matrix.target }} --profile ${{ matrix.profile }}
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# Fail the job if any of the previous steps failed.
|
||||
- name: verify all steps passed
|
||||
if: |
|
||||
steps.clippy.outcome == 'failure' ||
|
||||
steps.cargo_check_all_crates.outcome == 'failure' ||
|
||||
steps.test.outcome == 'failure'
|
||||
run: |
|
||||
echo "One or more checks failed (clippy, cargo_check_all_crates, or test). See logs for details."
|
||||
exit 1
|
||||
|
||||
# --- Gatherer job that you mark as the ONLY required status -----------------
|
||||
results:
|
||||
name: CI results (required)
|
||||
needs: [changed, general, lint_build_test]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Summarize
|
||||
shell: bash
|
||||
run: |
|
||||
echo "general: ${{ needs.general.result }}"
|
||||
echo "matrix : ${{ needs.lint_build_test.result }}"
|
||||
|
||||
# If nothing relevant changed (PR touching only root README, etc.),
|
||||
# declare success regardless of other jobs.
|
||||
if [[ '${{ needs.changed.outputs.codex }}' != 'true' && '${{ needs.changed.outputs.workflows }}' != 'true' && '${{ github.event_name }}' != 'push' ]]; then
|
||||
echo 'No relevant changes -> CI not required.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise require the jobs to have succeeded
|
||||
[[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; }
|
||||
[[ '${{ needs.lint_build_test.result }}' == 'success' ]] || { echo 'matrix failed'; exit 1; }
|
||||
@@ -0,0 +1,221 @@
|
||||
# Release workflow for codex-rs.
|
||||
# To release, follow a workflow like:
|
||||
# ```
|
||||
# git tag -a rust-v0.1.0 -m "Release 0.1.0"
|
||||
# git push origin rust-v0.1.0
|
||||
# ```
|
||||
|
||||
name: rust-release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "rust-v*.*.*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tag-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Validate tag matches Cargo.toml version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Tag validation"
|
||||
|
||||
# 1. Must be a tag and match the regex
|
||||
[[ "${GITHUB_REF_TYPE}" == "tag" ]] \
|
||||
|| { echo "❌ Not a tag push"; exit 1; }
|
||||
[[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \
|
||||
|| { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; }
|
||||
|
||||
# 2. Extract versions
|
||||
tag_ver="${GITHUB_REF_NAME#rust-v}"
|
||||
cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \
|
||||
| sed -E 's/version *= *"([^"]+)".*/\1/')"
|
||||
|
||||
# 3. Compare
|
||||
[[ "${tag_ver}" == "${cargo_ver}" ]] \
|
||||
|| { echo "❌ Tag ${tag_ver} ≠ Cargo.toml ${cargo_ver}"; exit 1; }
|
||||
|
||||
echo "✅ Tag and Cargo.toml agree (${tag_ver})"
|
||||
echo "::endgroup::"
|
||||
|
||||
build:
|
||||
needs: tag-check
|
||||
name: ${{ matrix.runner }} - ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
working-directory: codex-rs
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
- runner: macos-14
|
||||
target: x86_64-apple-darwin
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
- runner: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/[email protected]
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
${{ github.workspace }}/codex-rs/target/
|
||||
key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}}
|
||||
name: Install musl build tools
|
||||
run: |
|
||||
sudo apt install -y musl-tools pkg-config
|
||||
|
||||
- name: Cargo build
|
||||
run: cargo build --target ${{ matrix.target }} --release --bin codex
|
||||
|
||||
- name: Stage artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
dest="dist/${{ matrix.target }}"
|
||||
mkdir -p "$dest"
|
||||
|
||||
if [[ "${{ matrix.runner }}" == windows* ]]; then
|
||||
cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe"
|
||||
else
|
||||
cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
- if: ${{ matrix.runner == 'windows-11-arm' }}
|
||||
name: Install zstd
|
||||
shell: powershell
|
||||
run: choco install -y zstandard
|
||||
|
||||
- name: Compress artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
# Path that contains the uncompressed binaries for the current
|
||||
# ${{ matrix.target }}
|
||||
dest="dist/${{ matrix.target }}"
|
||||
|
||||
# For compatibility with environments that lack the `zstd` tool we
|
||||
# additionally create a `.tar.gz` for all platforms and `.zip` for
|
||||
# Windows alongside every single binary that we publish. The end result is:
|
||||
# codex-<target>.zst (existing)
|
||||
# codex-<target>.tar.gz (new)
|
||||
# codex-<target>.zip (only for Windows)
|
||||
|
||||
# 1. Produce a .tar.gz for every file in the directory *before* we
|
||||
# run `zstd --rm`, because that flag deletes the original files.
|
||||
for f in "$dest"/*; do
|
||||
base="$(basename "$f")"
|
||||
# Skip files that are already archives (shouldn't happen, but be
|
||||
# safe).
|
||||
if [[ "$base" == *.tar.gz || "$base" == *.zip ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create per-binary tar.gz
|
||||
tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base"
|
||||
|
||||
# Create zip archive for Windows binaries
|
||||
# Must run from inside the dest dir so 7z won't
|
||||
# embed the directory path inside the zip.
|
||||
if [[ "${{ matrix.runner }}" == windows* ]]; then
|
||||
(cd "$dest" && 7z a "${base}.zip" "$base")
|
||||
fi
|
||||
|
||||
# Also create .zst (existing behaviour) *and* remove the original
|
||||
# uncompressed binary to keep the directory small.
|
||||
zstd -T0 -19 --rm "$dest/$base"
|
||||
done
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
# Upload the per-binary .zst files as well as the new .tar.gz
|
||||
# equivalents we generated in the previous step.
|
||||
path: |
|
||||
codex-rs/dist/${{ matrix.target }}/*
|
||||
|
||||
release:
|
||||
needs: build
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: List
|
||||
run: ls -R dist/
|
||||
|
||||
- name: Define release name
|
||||
id: release_name
|
||||
run: |
|
||||
# Extract the version from the tag name, which is in the format
|
||||
# "rust-v0.1.0".
|
||||
version="${GITHUB_REF_NAME#rust-v}"
|
||||
echo "name=${version}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Stage npm package
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TMP_DIR="${RUNNER_TEMP}/npm-stage"
|
||||
python3 codex-cli/scripts/stage_rust_release.py \
|
||||
--release-version "${{ steps.release_name.outputs.name }}" \
|
||||
--tmp "${TMP_DIR}"
|
||||
mkdir -p dist/npm
|
||||
# Produce an npm-ready tarball using `npm pack` and store it in dist/npm.
|
||||
# We then rename it to a stable name used by our publishing script.
|
||||
(cd "$TMP_DIR" && npm pack --pack-destination "${GITHUB_WORKSPACE}/dist/npm")
|
||||
mv "${GITHUB_WORKSPACE}"/dist/npm/*.tgz \
|
||||
"${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ steps.release_name.outputs.name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: dist/**
|
||||
# Mark as prerelease only when the version has a suffix after x.y.z
|
||||
# (e.g. -alpha, -beta). Otherwise publish a normal release.
|
||||
prerelease: ${{ contains(steps.release_name.outputs.name, '-') }}
|
||||
|
||||
- uses: facebook/dotslash-publish-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
config: .github/dotslash-config.json
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"rust-lang.rust-analyzer",
|
||||
"tamasfe.even-better-toml",
|
||||
"vadimcn.vscode-lldb",
|
||||
|
||||
// Useful if touching files in .github/workflows, though most
|
||||
// contributors will not be doing that?
|
||||
// "github.vscode-github-actions",
|
||||
]
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Cargo launch",
|
||||
"cargo": {
|
||||
"cwd": "${workspaceFolder}/codex-rs",
|
||||
"args": ["build", "--bin=codex-tui"]
|
||||
},
|
||||
"args": []
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "attach",
|
||||
"name": "Attach to running codex CLI",
|
||||
"pid": "${command:pickProcess}",
|
||||
"sourceLanguages": ["rust"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"rust-analyzer.checkOnSave": true,
|
||||
"rust-analyzer.check.command": "clippy",
|
||||
"rust-analyzer.check.extraArgs": ["--all-features", "--tests"],
|
||||
"rust-analyzer.rustfmt.extraArgs": ["--config", "imports_granularity=Item"],
|
||||
"[rust]": {
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer",
|
||||
"editor.formatOnSave": true,
|
||||
},
|
||||
"[toml]": {
|
||||
"editor.defaultFormatter": "tamasfe.even-better-toml",
|
||||
"editor.formatOnSave": true,
|
||||
},
|
||||
// Array order for options in ~/.codex/config.toml such as `notify` and the
|
||||
// `args` for an MCP server is significant, so we disable reordering.
|
||||
"evenBetterToml.formatter.reorderArrays": false,
|
||||
"evenBetterToml.formatter.reorderKeys": true,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use crate::protocol::AgentMessageEvent;
|
||||
use crate::protocol::AgentReasoningEvent;
|
||||
use crate::protocol::AgentReasoningRawContentEvent;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::InputMessageKind;
|
||||
use crate::protocol::UserMessageEvent;
|
||||
use crate::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
|
||||
/// Convert a `ResponseItem` into zero or more `EventMsg` values that the UI can render.
|
||||
///
|
||||
/// When `show_raw_agent_reasoning` is false, raw reasoning content events are omitted.
|
||||
pub(crate) fn map_response_item_to_event_messages(
|
||||
item: &ResponseItem,
|
||||
show_raw_agent_reasoning: bool,
|
||||
) -> Vec<EventMsg> {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } => {
|
||||
// Do not surface system messages as user events.
|
||||
if role == "system" {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let events: Vec<EventMsg> = content
|
||||
.iter()
|
||||
.filter_map(|content_item| match content_item {
|
||||
ContentItem::OutputText { text } => {
|
||||
Some(EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: text.clone(),
|
||||
}))
|
||||
}
|
||||
ContentItem::InputText { text } => {
|
||||
let trimmed = text.trim_start();
|
||||
let kind = if trimmed.starts_with("<environment_context>") {
|
||||
Some(InputMessageKind::EnvironmentContext)
|
||||
} else if trimmed.starts_with("<user_instructions>") {
|
||||
Some(InputMessageKind::UserInstructions)
|
||||
} else {
|
||||
Some(InputMessageKind::Plain)
|
||||
};
|
||||
Some(EventMsg::UserMessage(UserMessageEvent {
|
||||
message: text.clone(),
|
||||
kind,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
events
|
||||
}
|
||||
|
||||
ResponseItem::Reasoning {
|
||||
summary, content, ..
|
||||
} => {
|
||||
let mut events = Vec::new();
|
||||
for ReasoningItemReasoningSummary::SummaryText { text } in summary {
|
||||
events.push(EventMsg::AgentReasoning(AgentReasoningEvent {
|
||||
text: text.clone(),
|
||||
}));
|
||||
}
|
||||
if let Some(items) = content.as_ref().filter(|_| show_raw_agent_reasoning) {
|
||||
for c in items {
|
||||
let text = match c {
|
||||
ReasoningItemContent::ReasoningText { text }
|
||||
| ReasoningItemContent::Text { text } => text,
|
||||
};
|
||||
events.push(EventMsg::AgentReasoningRawContent(
|
||||
AgentReasoningRawContentEvent { text: text.clone() },
|
||||
));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
ResponseItem::WebSearchCall { id, action, .. } => match action {
|
||||
WebSearchAction::Search { query } => {
|
||||
let call_id = id.clone().unwrap_or_else(|| "".to_string());
|
||||
vec![EventMsg::WebSearchEnd(WebSearchEndEvent {
|
||||
call_id,
|
||||
query: query.clone(),
|
||||
})]
|
||||
}
|
||||
WebSearchAction::Other => Vec::new(),
|
||||
},
|
||||
|
||||
// Variants that require side effects are handled by higher layers and do not emit events here.
|
||||
ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::Other => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::USER_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::USER_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
/// Wraps user instructions in a tag so the model can classify them easily.
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "user_instructions", rename_all = "snake_case")]
|
||||
pub(crate) struct UserInstructions {
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl UserInstructions {
|
||||
pub fn new<T: Into<String>>(text: T) -> Self {
|
||||
Self { text: text.into() }
|
||||
}
|
||||
|
||||
/// Serializes the user instructions to an XML-like tagged block that starts
|
||||
/// with <user_instructions> so clients can classify it.
|
||||
pub fn serialize_to_xml(self) -> String {
|
||||
format!(
|
||||
"{USER_INSTRUCTIONS_OPEN_TAG}\n\n{}\n\n{USER_INSTRUCTIONS_CLOSE_TAG}",
|
||||
self.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserInstructions> for ResponseItem {
|
||||
fn from(ui: UserInstructions) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: ui.serialize_to_xml(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_protocol::mcp_protocol::ListConversationsParams;
|
||||
use codex_protocol::mcp_protocol::ListConversationsResponse;
|
||||
use codex_protocol::mcp_protocol::NewConversationParams; // reused for overrides shape
|
||||
use codex_protocol::mcp_protocol::ResumeConversationParams;
|
||||
use codex_protocol::mcp_protocol::ResumeConversationResponse;
|
||||
use mcp_test_support::McpProcess;
|
||||
use mcp_test_support::to_response;
|
||||
use mcp_types::JSONRPCNotification;
|
||||
use mcp_types::JSONRPCResponse;
|
||||
use mcp_types::RequestId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn test_list_and_resume_conversations() {
|
||||
// Prepare a temporary CODEX_HOME with a few fake rollout files.
|
||||
let codex_home = TempDir::new().expect("create temp dir");
|
||||
create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-02T12-00-00",
|
||||
"2025-01-02T12:00:00Z",
|
||||
"Hello A",
|
||||
);
|
||||
create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-01T13-00-00",
|
||||
"2025-01-01T13:00:00Z",
|
||||
"Hello B",
|
||||
);
|
||||
create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-01T12-00-00",
|
||||
"2025-01-01T12:00:00Z",
|
||||
"Hello C",
|
||||
);
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
.expect("spawn mcp process");
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
|
||||
.await
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
|
||||
// Request first page with size 2
|
||||
let req_id = mcp
|
||||
.send_list_conversations_request(ListConversationsParams {
|
||||
page_size: Some(2),
|
||||
cursor: None,
|
||||
})
|
||||
.await
|
||||
.expect("send listConversations");
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
|
||||
)
|
||||
.await
|
||||
.expect("listConversations timeout")
|
||||
.expect("listConversations resp");
|
||||
let ListConversationsResponse { items, next_cursor } =
|
||||
to_response::<ListConversationsResponse>(resp).expect("deserialize response");
|
||||
|
||||
assert_eq!(items.len(), 2);
|
||||
// Newest first; preview text should match
|
||||
assert_eq!(items[0].preview, "Hello A");
|
||||
assert_eq!(items[1].preview, "Hello B");
|
||||
assert!(items[0].path.is_absolute());
|
||||
assert!(next_cursor.is_some());
|
||||
|
||||
// Request the next page using the cursor
|
||||
let req_id2 = mcp
|
||||
.send_list_conversations_request(ListConversationsParams {
|
||||
page_size: Some(2),
|
||||
cursor: next_cursor,
|
||||
})
|
||||
.await
|
||||
.expect("send listConversations page 2");
|
||||
let resp2: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(req_id2)),
|
||||
)
|
||||
.await
|
||||
.expect("listConversations page 2 timeout")
|
||||
.expect("listConversations page 2 resp");
|
||||
let ListConversationsResponse {
|
||||
items: items2,
|
||||
next_cursor: next2,
|
||||
..
|
||||
} = to_response::<ListConversationsResponse>(resp2).expect("deserialize response");
|
||||
assert_eq!(items2.len(), 1);
|
||||
assert_eq!(items2[0].preview, "Hello C");
|
||||
assert!(next2.is_some());
|
||||
|
||||
// Now resume one of the sessions and expect a SessionConfigured notification and response.
|
||||
let resume_req_id = mcp
|
||||
.send_resume_conversation_request(ResumeConversationParams {
|
||||
path: items[0].path.clone(),
|
||||
overrides: Some(NewConversationParams {
|
||||
model: Some("o3".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("send resumeConversation");
|
||||
|
||||
// Expect a codex/event notification with msg.type == session_configured
|
||||
let notification: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("codex/event"),
|
||||
)
|
||||
.await
|
||||
.expect("session_configured notification timeout")
|
||||
.expect("session_configured notification");
|
||||
// Basic shape assertion: ensure event type is session_configured
|
||||
let msg_type = notification
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("msg"))
|
||||
.and_then(|m| m.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
assert_eq!(msg_type, "session_configured");
|
||||
|
||||
// Then the response for resumeConversation
|
||||
let resume_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(resume_req_id)),
|
||||
)
|
||||
.await
|
||||
.expect("resumeConversation timeout")
|
||||
.expect("resumeConversation resp");
|
||||
let ResumeConversationResponse {
|
||||
conversation_id, ..
|
||||
} = to_response::<ResumeConversationResponse>(resume_resp)
|
||||
.expect("deserialize resumeConversation response");
|
||||
// conversation id should be a valid UUID
|
||||
let _ = uuid::Uuid::from_bytes(conversation_id.0.into_bytes());
|
||||
}
|
||||
|
||||
fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) {
|
||||
let uuid = Uuid::new_v4();
|
||||
// sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss)
|
||||
let year = &filename_ts[0..4];
|
||||
let month = &filename_ts[5..7];
|
||||
let day = &filename_ts[8..10];
|
||||
let dir = codex_home.join("sessions").join(year).join(month).join(day);
|
||||
fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("create sessions dir: {e}"));
|
||||
|
||||
let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl"));
|
||||
let mut lines = Vec::new();
|
||||
// Meta line with timestamp
|
||||
lines.push(json!({"timestamp": meta_rfc3339}).to_string());
|
||||
// Minimal user message entry as a persisted response item
|
||||
lines.push(
|
||||
json!({
|
||||
"type":"message",
|
||||
"role":"user",
|
||||
"content":[{"type":"input_text","text": preview}]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
fs::write(file_path, lines.join("\n") + "\n")
|
||||
.unwrap_or_else(|e| panic!("write rollout file: {e}"));
|
||||
}
|
||||
Executable
+296
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
REPO = "openai/codex"
|
||||
BRANCH_REF = "heads/main"
|
||||
CARGO_TOML_PATH = "codex-rs/Cargo.toml"
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Publish a tagged Codex release.")
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the version that would be used and exit before making changes.",
|
||||
)
|
||||
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
"--publish-alpha",
|
||||
action="store_true",
|
||||
help="Publish the next alpha release for the upcoming minor version.",
|
||||
)
|
||||
group.add_argument(
|
||||
"--publish-release",
|
||||
action="store_true",
|
||||
help="Publish the next stable release by bumping the minor version.",
|
||||
)
|
||||
return parser.parse_args(argv[1:])
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
version = determine_version(args)
|
||||
print(f"Publishing version {version}")
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
print("Fetching branch head...")
|
||||
base_commit = get_branch_head()
|
||||
print(f"Base commit: {base_commit}")
|
||||
print("Fetching commit tree...")
|
||||
base_tree = get_commit_tree(base_commit)
|
||||
print(f"Base tree: {base_tree}")
|
||||
print("Fetching Cargo.toml...")
|
||||
current_contents = fetch_file_contents(base_commit)
|
||||
print("Updating version...")
|
||||
updated_contents = replace_version(current_contents, version)
|
||||
print("Creating blob...")
|
||||
blob_sha = create_blob(updated_contents)
|
||||
print(f"Blob SHA: {blob_sha}")
|
||||
print("Creating tree...")
|
||||
tree_sha = create_tree(base_tree, blob_sha)
|
||||
print(f"Tree SHA: {tree_sha}")
|
||||
print("Creating commit...")
|
||||
commit_sha = create_commit(version, tree_sha, base_commit)
|
||||
print(f"Commit SHA: {commit_sha}")
|
||||
print("Creating tag...")
|
||||
tag_sha = create_tag(version, commit_sha)
|
||||
print(f"Tag SHA: {tag_sha}")
|
||||
print("Creating tag ref...")
|
||||
create_tag_ref(version, tag_sha)
|
||||
print("Done.")
|
||||
except ReleaseError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_gh_api(endpoint: str, *, method: str = "GET", payload: dict | None = None) -> dict:
|
||||
print(f"Running gh api {method} {endpoint}")
|
||||
command = [
|
||||
"gh",
|
||||
"api",
|
||||
endpoint,
|
||||
"--method",
|
||||
method,
|
||||
"-H",
|
||||
"Accept: application/vnd.github+json",
|
||||
]
|
||||
json_payload = None
|
||||
if payload is not None:
|
||||
json_payload = json.dumps(payload)
|
||||
print(f"Payload: {json_payload}")
|
||||
command.extend(["-H", "Content-Type: application/json", "--input", "-"])
|
||||
result = subprocess.run(command, text=True, capture_output=True, input=json_payload)
|
||||
if result.returncode != 0:
|
||||
message = result.stderr.strip() or result.stdout.strip() or "gh api call failed"
|
||||
raise ReleaseError(message)
|
||||
try:
|
||||
return json.loads(result.stdout or "{}")
|
||||
except json.JSONDecodeError as error:
|
||||
raise ReleaseError("Failed to parse response from gh api.") from error
|
||||
|
||||
|
||||
def get_branch_head() -> str:
|
||||
response = run_gh_api(f"/repos/{REPO}/git/refs/{BRANCH_REF}")
|
||||
try:
|
||||
return response["object"]["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Unable to determine branch head.") from error
|
||||
|
||||
|
||||
def get_commit_tree(commit_sha: str) -> str:
|
||||
response = run_gh_api(f"/repos/{REPO}/git/commits/{commit_sha}")
|
||||
try:
|
||||
return response["tree"]["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Commit response missing tree SHA.") from error
|
||||
|
||||
|
||||
def fetch_file_contents(ref_sha: str) -> str:
|
||||
response = run_gh_api(f"/repos/{REPO}/contents/{CARGO_TOML_PATH}?ref={ref_sha}")
|
||||
try:
|
||||
encoded_content = response["content"].replace("\n", "")
|
||||
encoding = response.get("encoding", "")
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Failed to fetch Cargo.toml contents.") from error
|
||||
|
||||
if encoding != "base64":
|
||||
raise ReleaseError(f"Unexpected Cargo.toml encoding: {encoding}")
|
||||
|
||||
try:
|
||||
return base64.b64decode(encoded_content).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError) as error:
|
||||
raise ReleaseError("Failed to decode Cargo.toml contents.") from error
|
||||
|
||||
|
||||
def replace_version(contents: str, version: str) -> str:
|
||||
updated, matches = re.subn(
|
||||
r'^version = "[^"]+"', f'version = "{version}"', contents, count=1, flags=re.MULTILINE
|
||||
)
|
||||
if matches != 1:
|
||||
raise ReleaseError("Unable to update version in Cargo.toml.")
|
||||
return updated
|
||||
|
||||
|
||||
def create_blob(content: str) -> str:
|
||||
response = run_gh_api(
|
||||
f"/repos/{REPO}/git/blobs",
|
||||
method="POST",
|
||||
payload={"content": content, "encoding": "utf-8"},
|
||||
)
|
||||
try:
|
||||
return response["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Blob creation response missing SHA.") from error
|
||||
|
||||
|
||||
def create_tree(base_tree_sha: str, blob_sha: str) -> str:
|
||||
response = run_gh_api(
|
||||
f"/repos/{REPO}/git/trees",
|
||||
method="POST",
|
||||
payload={
|
||||
"base_tree": base_tree_sha,
|
||||
"tree": [
|
||||
{
|
||||
"path": CARGO_TOML_PATH,
|
||||
"mode": "100644",
|
||||
"type": "blob",
|
||||
"sha": blob_sha,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
try:
|
||||
return response["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Tree creation response missing SHA.") from error
|
||||
|
||||
|
||||
def create_commit(version: str, tree_sha: str, parent_sha: str) -> str:
|
||||
response = run_gh_api(
|
||||
f"/repos/{REPO}/git/commits",
|
||||
method="POST",
|
||||
payload={
|
||||
"message": f"Release {version}",
|
||||
"tree": tree_sha,
|
||||
"parents": [parent_sha],
|
||||
},
|
||||
)
|
||||
try:
|
||||
return response["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Commit creation response missing SHA.") from error
|
||||
|
||||
|
||||
def create_tag(version: str, commit_sha: str) -> str:
|
||||
tag_name = f"rust-v{version}"
|
||||
response = run_gh_api(
|
||||
f"/repos/{REPO}/git/tags",
|
||||
method="POST",
|
||||
payload={
|
||||
"tag": tag_name,
|
||||
"message": f"Release {version}",
|
||||
"object": commit_sha,
|
||||
"type": "commit",
|
||||
},
|
||||
)
|
||||
try:
|
||||
return response["sha"]
|
||||
except KeyError as error:
|
||||
raise ReleaseError("Tag creation response missing SHA.") from error
|
||||
|
||||
|
||||
def create_tag_ref(version: str, tag_sha: str) -> None:
|
||||
tag_ref = f"refs/tags/rust-v{version}"
|
||||
run_gh_api(
|
||||
f"/repos/{REPO}/git/refs",
|
||||
method="POST",
|
||||
payload={"ref": tag_ref, "sha": tag_sha},
|
||||
)
|
||||
|
||||
|
||||
def determine_version(args: argparse.Namespace) -> str:
|
||||
latest_version = get_latest_release_version()
|
||||
major, minor, patch = parse_semver(latest_version)
|
||||
next_minor_version = format_version(major, minor + 1, patch)
|
||||
|
||||
if args.publish_release:
|
||||
return next_minor_version
|
||||
|
||||
alpha_prefix = f"{next_minor_version}-alpha."
|
||||
releases = list_releases()
|
||||
highest_alpha = 0
|
||||
found_alpha = False
|
||||
for release in releases:
|
||||
tag = release.get("tag_name", "")
|
||||
candidate = strip_tag_prefix(tag)
|
||||
if candidate and candidate.startswith(alpha_prefix):
|
||||
suffix = candidate[len(alpha_prefix) :]
|
||||
try:
|
||||
alpha_number = int(suffix)
|
||||
except ValueError:
|
||||
continue
|
||||
highest_alpha = max(highest_alpha, alpha_number)
|
||||
found_alpha = True
|
||||
|
||||
if found_alpha:
|
||||
return f"{alpha_prefix}{highest_alpha + 1}"
|
||||
return f"{alpha_prefix}1"
|
||||
|
||||
|
||||
def get_latest_release_version() -> str:
|
||||
response = run_gh_api(f"/repos/{REPO}/releases/latest")
|
||||
tag = response.get("tag_name")
|
||||
version = strip_tag_prefix(tag)
|
||||
if not version:
|
||||
raise ReleaseError("Latest release tag has unexpected format.")
|
||||
return version
|
||||
|
||||
|
||||
def list_releases() -> list[dict]:
|
||||
response = run_gh_api(f"/repos/{REPO}/releases?per_page=100")
|
||||
if not isinstance(response, list):
|
||||
raise ReleaseError("Unexpected response when listing releases.")
|
||||
return response
|
||||
|
||||
|
||||
def strip_tag_prefix(tag: str | None) -> str | None:
|
||||
if not tag:
|
||||
return None
|
||||
prefix = "rust-v"
|
||||
if not tag.startswith(prefix):
|
||||
return None
|
||||
return tag[len(prefix) :]
|
||||
|
||||
|
||||
def parse_semver(version: str) -> tuple[int, int, int]:
|
||||
parts = version.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ReleaseError(f"Unexpected version format: {version}")
|
||||
try:
|
||||
return int(parts[0]), int(parts[1]), int(parts[2])
|
||||
except ValueError as error:
|
||||
raise ReleaseError(f"Version components must be integers: {version}") from error
|
||||
|
||||
|
||||
def format_version(major: int, minor: int, patch: int) -> str:
|
||||
return f"{major}.{minor}.{patch}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&aborted_long)
|
||||
---
|
||||
✗ You canceled the request to run echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&aborted_multi)
|
||||
---
|
||||
✗ You canceled the request to run echo line1 ...
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&decision)
|
||||
---
|
||||
✔ You approved codex to run echo hello world this time
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&proposed_multi)
|
||||
---
|
||||
• Proposed Command
|
||||
└ echo line1
|
||||
echo line2
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&proposed)
|
||||
---
|
||||
• Proposed Command
|
||||
└ echo hello world
|
||||
@@ -0,0 +1,49 @@
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
use std::fmt::Display;
|
||||
|
||||
#[cfg(test)]
|
||||
const ALT_PREFIX: &str = "⌥";
|
||||
#[cfg(all(not(test), target_os = "macos"))]
|
||||
const ALT_PREFIX: &str = "⌥";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const ALT_PREFIX: &str = "Alt+";
|
||||
|
||||
#[cfg(test)]
|
||||
const CTRL_PREFIX: &str = "⌃";
|
||||
#[cfg(all(not(test), target_os = "macos"))]
|
||||
const CTRL_PREFIX: &str = "⌃";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const CTRL_PREFIX: &str = "Ctrl+";
|
||||
|
||||
#[cfg(test)]
|
||||
const SHIFT_PREFIX: &str = "⇧";
|
||||
#[cfg(all(not(test), target_os = "macos"))]
|
||||
const SHIFT_PREFIX: &str = "⇧";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const SHIFT_PREFIX: &str = "Shift+";
|
||||
|
||||
fn key_hint_style() -> Style {
|
||||
Style::default().fg(Color::Cyan)
|
||||
}
|
||||
|
||||
fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
|
||||
Span::styled(format!("{prefix}{key}"), key_hint_style())
|
||||
}
|
||||
|
||||
pub(crate) fn ctrl(key: impl Display) -> Span<'static> {
|
||||
modifier_span(CTRL_PREFIX, key)
|
||||
}
|
||||
|
||||
pub(crate) fn alt(key: impl Display) -> Span<'static> {
|
||||
modifier_span(ALT_PREFIX, key)
|
||||
}
|
||||
|
||||
pub(crate) fn shift(key: impl Display) -> Span<'static> {
|
||||
modifier_span(SHIFT_PREFIX, key)
|
||||
}
|
||||
|
||||
pub(crate) fn plain(key: impl Display) -> Span<'static> {
|
||||
Span::styled(format!("{key}"), key_hint_style())
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use codex_core::bash::try_parse_bash;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Convert the full bash script into per-line styled content by first
|
||||
/// computing operator-dimmed spans across the entire script, then splitting
|
||||
/// by newlines and dimming heredoc body lines. Performs a single parse and
|
||||
/// reuses it for both highlighting and heredoc detection.
|
||||
pub(crate) fn highlight_bash_to_lines(script: &str) -> Vec<Line<'static>> {
|
||||
// Parse once; use the tree for both highlighting and heredoc body detection.
|
||||
let spans: Vec<Span<'static>> = if let Some(tree) = try_parse_bash(script) {
|
||||
// Single walk: collect operator ranges and heredoc rows.
|
||||
let root = tree.root_node();
|
||||
let mut cursor = root.walk();
|
||||
let mut stack = vec![root];
|
||||
let mut ranges: Vec<(usize, usize)> = Vec::new();
|
||||
while let Some(node) = stack.pop() {
|
||||
if !node.is_named() && !node.is_extra() {
|
||||
let kind = node.kind();
|
||||
let is_quote = matches!(kind, "\"" | "'" | "`");
|
||||
let is_whitespace = kind.trim().is_empty();
|
||||
if !is_quote && !is_whitespace {
|
||||
ranges.push((node.start_byte(), node.end_byte()));
|
||||
}
|
||||
} else if node.kind() == "heredoc_body" {
|
||||
ranges.push((node.start_byte(), node.end_byte()));
|
||||
}
|
||||
for child in node.children(&mut cursor) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
if ranges.is_empty() {
|
||||
ranges.push((script.len(), script.len()));
|
||||
}
|
||||
ranges.sort_by_key(|(st, _)| *st);
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut i = 0usize;
|
||||
for (start, end) in ranges.into_iter() {
|
||||
let dim_start = start.max(i);
|
||||
let dim_end = end;
|
||||
if dim_start < dim_end {
|
||||
if dim_start > i {
|
||||
spans.push(script[i..dim_start].to_string().into());
|
||||
}
|
||||
spans.push(script[dim_start..dim_end].to_string().dim());
|
||||
i = dim_end;
|
||||
}
|
||||
}
|
||||
if i < script.len() {
|
||||
spans.push(script[i..].to_string().into());
|
||||
}
|
||||
spans
|
||||
} else {
|
||||
vec![script.to_string().into()]
|
||||
};
|
||||
// Split spans into lines preserving style boundaries and highlights across newlines.
|
||||
let mut lines: Vec<Line<'static>> = vec![Line::from("")];
|
||||
for sp in spans {
|
||||
let style = sp.style;
|
||||
let text = sp.content.into_owned();
|
||||
for (i, part) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let span = Span {
|
||||
style,
|
||||
content: std::borrow::Cow::Owned(part.to_string()),
|
||||
};
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.spans.push(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
#[test]
|
||||
fn dims_expected_bash_operators() {
|
||||
let s = "echo foo && bar || baz | qux & (echo hi)";
|
||||
let lines = highlight_bash_to_lines(s);
|
||||
let reconstructed: String = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|sp| sp.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_eq!(reconstructed, s);
|
||||
|
||||
fn is_dim(span: &Span<'_>) -> bool {
|
||||
span.style.add_modifier.contains(Modifier::DIM)
|
||||
}
|
||||
let dimmed: Vec<String> = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.filter(|sp| is_dim(sp))
|
||||
.map(|sp| sp.content.clone().into_owned())
|
||||
.collect();
|
||||
assert_eq!(dimmed, vec!["&&", "||", "|", "&", "(", ")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_dim_quotes_but_dims_other_punct() {
|
||||
let s = "echo \"hi\" > out.txt; echo 'ok'";
|
||||
let lines = highlight_bash_to_lines(s);
|
||||
let reconstructed: String = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|sp| sp.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_eq!(reconstructed, s);
|
||||
|
||||
fn is_dim(span: &Span<'_>) -> bool {
|
||||
span.style.add_modifier.contains(Modifier::DIM)
|
||||
}
|
||||
let dimmed: Vec<String> = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
.filter(|sp| is_dim(sp))
|
||||
.map(|sp| sp.content.clone().into_owned())
|
||||
.collect();
|
||||
assert!(dimmed.contains(&">".to_string()));
|
||||
assert!(dimmed.contains(&";".to_string()));
|
||||
assert!(!dimmed.contains(&"\"".to_string()));
|
||||
assert!(!dimmed.contains(&"'".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use codex_core::ConversationItem;
|
||||
use codex_core::ConversationsPage;
|
||||
use codex_core::Cursor;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::protocol::InputMessageKind;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
|
||||
const PAGE_SIZE: usize = 25;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResumeSelection {
|
||||
StartFresh,
|
||||
Resume(PathBuf),
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// Interactive session picker that lists recorded rollout files with simple
|
||||
/// search and pagination. Shows the first user input as the preview, relative
|
||||
/// time (e.g., "5 seconds ago"), and the absolute path.
|
||||
pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<ResumeSelection> {
|
||||
let alt = AltScreenGuard::enter(tui);
|
||||
let mut state = PickerState::new(codex_home.to_path_buf(), alt.tui.frame_requester());
|
||||
state.load_page(None).await?;
|
||||
state.request_frame();
|
||||
|
||||
let mut events = alt.tui.event_stream();
|
||||
while let Some(ev) = events.next().await {
|
||||
match ev {
|
||||
TuiEvent::Key(key) => {
|
||||
if matches!(key.kind, KeyEventKind::Release) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sel) = state.handle_key(key).await? {
|
||||
return Ok(sel);
|
||||
}
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
draw_picker(alt.tui, &state)?;
|
||||
}
|
||||
// Ignore paste and attach-image in picker
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback – treat as cancel/new
|
||||
Ok(ResumeSelection::StartFresh)
|
||||
}
|
||||
|
||||
/// RAII guard that ensures we leave the alt-screen on scope exit.
|
||||
struct AltScreenGuard<'a> {
|
||||
tui: &'a mut Tui,
|
||||
}
|
||||
|
||||
impl<'a> AltScreenGuard<'a> {
|
||||
fn enter(tui: &'a mut Tui) -> Self {
|
||||
let _ = tui.enter_alt_screen();
|
||||
Self { tui }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AltScreenGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.tui.leave_alt_screen();
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerState {
|
||||
codex_home: PathBuf,
|
||||
requester: FrameRequester,
|
||||
// pagination
|
||||
pagination: Pagination,
|
||||
// data
|
||||
all_rows: Vec<Row>, // unfiltered rows for current page
|
||||
filtered_rows: Vec<Row>,
|
||||
selected: usize,
|
||||
// search
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Pagination {
|
||||
current_anchor: Option<Cursor>,
|
||||
backstack: Vec<Option<Cursor>>, // track previous anchors for ←/a
|
||||
next_cursor: Option<Cursor>,
|
||||
page_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Row {
|
||||
path: PathBuf,
|
||||
preview: String,
|
||||
ts: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PickerState {
|
||||
fn new(codex_home: PathBuf, requester: FrameRequester) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
requester,
|
||||
pagination: Pagination {
|
||||
current_anchor: None,
|
||||
backstack: vec![None],
|
||||
next_cursor: None,
|
||||
page_index: 0,
|
||||
},
|
||||
all_rows: Vec::new(),
|
||||
filtered_rows: Vec::new(),
|
||||
selected: 0,
|
||||
query: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_frame(&self) {
|
||||
self.requester.schedule_frame();
|
||||
}
|
||||
|
||||
async fn handle_key(&mut self, key: KeyEvent) -> Result<Option<ResumeSelection>> {
|
||||
match key.code {
|
||||
KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)),
|
||||
KeyCode::Char('c')
|
||||
if key
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL) =>
|
||||
{
|
||||
return Ok(Some(ResumeSelection::Exit));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(row) = self.filtered_rows.get(self.selected) {
|
||||
return Ok(Some(ResumeSelection::Resume(row.path.clone())));
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if self.selected + 1 < self.filtered_rows.len() {
|
||||
self.selected += 1;
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('a') => {
|
||||
self.prev_page().await?;
|
||||
}
|
||||
KeyCode::Right | KeyCode::Char('d') => {
|
||||
self.next_page().await?;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.query.pop();
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// basic text input for search
|
||||
if !key
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL)
|
||||
&& !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
|
||||
{
|
||||
self.query.push(c);
|
||||
self.apply_filter();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn prev_page(&mut self) -> Result<()> {
|
||||
if self.pagination.page_index == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
// current_anchor points to the page we just loaded; backstack[page_index-1] is the anchor to reload
|
||||
if self.pagination.page_index > 0 {
|
||||
self.pagination.page_index -= 1;
|
||||
let anchor = self
|
||||
.pagination
|
||||
.backstack
|
||||
.get(self.pagination.page_index)
|
||||
.cloned()
|
||||
.flatten();
|
||||
self.pagination.current_anchor = anchor.clone();
|
||||
self.load_page(anchor.as_ref()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn next_page(&mut self) -> Result<()> {
|
||||
if let Some(next) = self.pagination.next_cursor.clone() {
|
||||
// Record the anchor for the page we are moving to at index new_index
|
||||
let new_index = self.pagination.page_index + 1;
|
||||
if self.pagination.backstack.len() <= new_index {
|
||||
self.pagination.backstack.resize(new_index + 1, None);
|
||||
}
|
||||
self.pagination.backstack[new_index] = Some(next.clone());
|
||||
self.pagination.current_anchor = Some(next.clone());
|
||||
self.pagination.page_index = new_index;
|
||||
let anchor = self.pagination.current_anchor.clone();
|
||||
self.load_page(anchor.as_ref()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_page(&mut self, anchor: Option<&Cursor>) -> Result<()> {
|
||||
let page = RolloutRecorder::list_conversations(&self.codex_home, PAGE_SIZE, anchor).await?;
|
||||
self.pagination.next_cursor = page.next_cursor.clone();
|
||||
self.all_rows = to_rows(page);
|
||||
self.apply_filter();
|
||||
// reset selection on new page
|
||||
self.selected = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
if self.query.is_empty() {
|
||||
self.filtered_rows = self.all_rows.clone();
|
||||
} else {
|
||||
let q = self.query.to_lowercase();
|
||||
self.filtered_rows = self
|
||||
.all_rows
|
||||
.iter()
|
||||
.filter(|r| r.preview.to_lowercase().contains(&q))
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
if self.selected >= self.filtered_rows.len() {
|
||||
self.selected = self.filtered_rows.len().saturating_sub(1);
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
}
|
||||
|
||||
fn to_rows(page: ConversationsPage) -> Vec<Row> {
|
||||
use std::cmp::Reverse;
|
||||
let mut rows: Vec<Row> = page
|
||||
.items
|
||||
.into_iter()
|
||||
.filter_map(|it| head_to_row(&it))
|
||||
.collect();
|
||||
// Ensure newest-first ordering within the page by timestamp when available.
|
||||
let epoch = Utc.timestamp_opt(0, 0).single().unwrap_or_else(Utc::now);
|
||||
rows.sort_by_key(|r| Reverse(r.ts.unwrap_or(epoch)));
|
||||
rows
|
||||
}
|
||||
|
||||
fn head_to_row(item: &ConversationItem) -> Option<Row> {
|
||||
let mut ts: Option<DateTime<Utc>> = None;
|
||||
if let Some(first) = item.head.first()
|
||||
&& let Some(t) = first.get("timestamp").and_then(|v| v.as_str())
|
||||
&& let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(t)
|
||||
{
|
||||
ts = Some(parsed.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
let preview = find_first_user_text(&item.head)?;
|
||||
let preview = preview.trim().to_string();
|
||||
if preview.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Row {
|
||||
path: item.path.clone(),
|
||||
preview,
|
||||
ts,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the first plain user text from the JSONL `head` of a rollout.
|
||||
///
|
||||
/// Strategy: scan for the first `{ type: "message", role: "user" }` entry and
|
||||
/// then return the first `content` item where `{ type: "input_text" }` that is
|
||||
/// classified as `InputMessageKind::Plain` (i.e., not wrapped in
|
||||
/// `<user_instructions>` or `<environment_context>` tags).
|
||||
fn find_first_user_text(head: &[serde_json::Value]) -> Option<String> {
|
||||
for v in head.iter() {
|
||||
let t = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if t != "message" {
|
||||
continue;
|
||||
}
|
||||
if v.get("role").and_then(|x| x.as_str()) != Some("user") {
|
||||
continue;
|
||||
}
|
||||
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
|
||||
for c in arr.iter() {
|
||||
if let (Some("input_text"), Some(txt)) =
|
||||
(c.get("type").and_then(|t| t.as_str()), c.get("text"))
|
||||
&& let Some(s) = txt.as_str()
|
||||
{
|
||||
// Skip XML-wrapped user_instructions/environment_context blocks and
|
||||
// return the first plain user text we find.
|
||||
if matches!(InputMessageKind::from(("user", s)), InputMessageKind::Plain) {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
// Render full-screen overlay
|
||||
let height = tui.terminal.size()?.height;
|
||||
tui.draw(height, |frame| {
|
||||
let area = frame.area();
|
||||
let [header, search, list, hint] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(area.height.saturating_sub(3)),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
// Header
|
||||
frame.render_widget_ref(
|
||||
Line::from(vec!["Resume a previous session".bold().cyan()]),
|
||||
header,
|
||||
);
|
||||
|
||||
// Search line
|
||||
let q = if state.query.is_empty() {
|
||||
"Type to search".dim().to_string()
|
||||
} else {
|
||||
format!("Search: {}", state.query)
|
||||
};
|
||||
frame.render_widget_ref(Line::from(q), search);
|
||||
|
||||
// List
|
||||
render_list(frame, list, state);
|
||||
|
||||
// Hint line
|
||||
let hint_line: Line = vec![
|
||||
"Enter".bold(),
|
||||
" to resume ".into(),
|
||||
"Esc".bold(),
|
||||
" to start new ".into(),
|
||||
"Ctrl+C".into(),
|
||||
" to quit ".dim(),
|
||||
"←/a".into(),
|
||||
" prev ".dim(),
|
||||
"→/d".into(),
|
||||
" next".dim(),
|
||||
]
|
||||
.into();
|
||||
frame.render_widget_ref(hint_line, hint);
|
||||
})
|
||||
}
|
||||
|
||||
fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) {
|
||||
let rows = &state.filtered_rows;
|
||||
if rows.is_empty() {
|
||||
frame.render_widget_ref(Line::from("No sessions found".italic().dim()), area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute how many rows fit (1 line per item)
|
||||
let capacity = area.height as usize;
|
||||
let start = state.selected.saturating_sub(capacity.saturating_sub(1));
|
||||
let visible = &rows[start..rows.len().min(start + capacity)];
|
||||
|
||||
let mut y = area.y;
|
||||
for (idx, row) in visible.iter().enumerate() {
|
||||
let is_sel = start + idx == state.selected;
|
||||
let marker = if is_sel { "> ".bold() } else { " ".into() };
|
||||
let ts = row
|
||||
.ts
|
||||
.map(human_time_ago)
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.dim();
|
||||
let max_cols = area.width.saturating_sub(6) as usize;
|
||||
let preview = truncate_text(&row.preview, max_cols);
|
||||
|
||||
let line: Line = vec![marker, ts, " ".into(), preview.into()].into();
|
||||
let rect = Rect::new(area.x, y, area.width, 1);
|
||||
frame.render_widget_ref(line, rect);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn human_time_ago(ts: DateTime<Utc>) -> String {
|
||||
let now = Utc::now();
|
||||
let delta = now - ts;
|
||||
let secs = delta.num_seconds();
|
||||
if secs < 60 {
|
||||
let n = secs.max(0);
|
||||
if n == 1 {
|
||||
format!("{n} second ago")
|
||||
} else {
|
||||
format!("{n} seconds ago")
|
||||
}
|
||||
} else if secs < 60 * 60 {
|
||||
let m = secs / 60;
|
||||
if m == 1 {
|
||||
format!("{m} minute ago")
|
||||
} else {
|
||||
format!("{m} minutes ago")
|
||||
}
|
||||
} else if secs < 60 * 60 * 24 {
|
||||
let h = secs / 3600;
|
||||
if h == 1 {
|
||||
format!("{h} hour ago")
|
||||
} else {
|
||||
format!("{h} hours ago")
|
||||
}
|
||||
} else {
|
||||
let d = secs / (60 * 60 * 24);
|
||||
if d == 1 {
|
||||
format!("{d} day ago")
|
||||
} else {
|
||||
format!("{d} days ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn head_with_ts_and_user_text(ts: &str, texts: &[&str]) -> Vec<serde_json::Value> {
|
||||
vec![
|
||||
json!({ "timestamp": ts }),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": texts
|
||||
.iter()
|
||||
.map(|t| json!({ "type": "input_text", "text": *t }))
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_user_instructions_and_env_context() {
|
||||
let head = vec![
|
||||
json!({ "timestamp": "2025-01-01T00:00:00Z" }),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "<user_instructions>hi</user_instructions>" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "<environment_context>cwd</environment_context>" }
|
||||
]
|
||||
}),
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "real question" } ]
|
||||
}),
|
||||
];
|
||||
let first = find_first_user_text(&head);
|
||||
assert_eq!(first.as_deref(), Some("real question"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_rows_sorts_descending_by_timestamp() {
|
||||
// Construct two items with different timestamps and real user text.
|
||||
let a = ConversationItem {
|
||||
path: PathBuf::from("/tmp/a.jsonl"),
|
||||
head: head_with_ts_and_user_text("2025-01-01T00:00:00Z", &["A"]),
|
||||
};
|
||||
let b = ConversationItem {
|
||||
path: PathBuf::from("/tmp/b.jsonl"),
|
||||
head: head_with_ts_and_user_text("2025-01-02T00:00:00Z", &["B"]),
|
||||
};
|
||||
let rows = to_rows(ConversationsPage {
|
||||
items: vec![a, b],
|
||||
next_cursor: None,
|
||||
num_scanned_files: 0,
|
||||
reached_scan_cap: false,
|
||||
});
|
||||
assert_eq!(rows.len(), 2);
|
||||
// Expect the newer timestamp (B) first
|
||||
assert!(rows[0].preview.contains('B'));
|
||||
assert!(rows[1].preview.contains('A'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// The current Codex CLI version as embedded at compile time.
|
||||
pub const CODEX_CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
@@ -0,0 +1,545 @@
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
|
||||
pub(crate) fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
|
||||
where
|
||||
O: Into<Options<'a>>,
|
||||
{
|
||||
let opts = width_or_options.into();
|
||||
let mut lines: Vec<Range<usize>> = Vec::new();
|
||||
for line in textwrap::wrap(text, opts).iter() {
|
||||
match line {
|
||||
std::borrow::Cow::Borrowed(slice) => {
|
||||
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
|
||||
let end = start + slice.len();
|
||||
let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count();
|
||||
lines.push(start..end + trailing_spaces + 1);
|
||||
}
|
||||
std::borrow::Cow::Owned(_) => panic!("wrap_ranges: unexpected owned string"),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Like `wrap_ranges` but returns ranges without trailing whitespace and
|
||||
/// without the sentinel extra byte. Suitable for general wrapping where
|
||||
/// trailing spaces should not be preserved.
|
||||
pub(crate) fn wrap_ranges_trim<'a, O>(text: &str, width_or_options: O) -> Vec<Range<usize>>
|
||||
where
|
||||
O: Into<Options<'a>>,
|
||||
{
|
||||
let opts = width_or_options.into();
|
||||
let mut lines: Vec<Range<usize>> = Vec::new();
|
||||
for line in textwrap::wrap(text, opts).iter() {
|
||||
match line {
|
||||
std::borrow::Cow::Borrowed(slice) => {
|
||||
let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize };
|
||||
let end = start + slice.len();
|
||||
lines.push(start..end);
|
||||
}
|
||||
std::borrow::Cow::Owned(_) => panic!("wrap_ranges_trim: unexpected owned string"),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RtOptions<'a> {
|
||||
/// The width in columns at which the text will be wrapped.
|
||||
pub width: usize,
|
||||
/// Line ending used for breaking lines.
|
||||
pub line_ending: textwrap::LineEnding,
|
||||
/// Indentation used for the first line of output. See the
|
||||
/// [`Options::initial_indent`] method.
|
||||
pub initial_indent: Line<'a>,
|
||||
/// Indentation used for subsequent lines of output. See the
|
||||
/// [`Options::subsequent_indent`] method.
|
||||
pub subsequent_indent: Line<'a>,
|
||||
/// Allow long words to be broken if they cannot fit on a line.
|
||||
/// When set to `false`, some lines may be longer than
|
||||
/// `self.width`. See the [`Options::break_words`] method.
|
||||
pub break_words: bool,
|
||||
/// Wrapping algorithm to use, see the implementations of the
|
||||
/// [`WrapAlgorithm`] trait for details.
|
||||
pub wrap_algorithm: textwrap::WrapAlgorithm,
|
||||
/// The line breaking algorithm to use, see the [`WordSeparator`]
|
||||
/// trait for an overview and possible implementations.
|
||||
pub word_separator: textwrap::WordSeparator,
|
||||
/// The method for splitting words. This can be used to prohibit
|
||||
/// splitting words on hyphens, or it can be used to implement
|
||||
/// language-aware machine hyphenation.
|
||||
pub word_splitter: textwrap::WordSplitter,
|
||||
}
|
||||
impl From<usize> for RtOptions<'_> {
|
||||
fn from(width: usize) -> Self {
|
||||
RtOptions::new(width)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl<'a> RtOptions<'a> {
|
||||
pub fn new(width: usize) -> Self {
|
||||
RtOptions {
|
||||
width,
|
||||
line_ending: textwrap::LineEnding::LF,
|
||||
initial_indent: Line::default(),
|
||||
subsequent_indent: Line::default(),
|
||||
break_words: true,
|
||||
word_separator: textwrap::WordSeparator::new(),
|
||||
wrap_algorithm: textwrap::WrapAlgorithm::new(),
|
||||
word_splitter: textwrap::WordSplitter::HyphenSplitter,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn line_ending(self, line_ending: textwrap::LineEnding) -> Self {
|
||||
RtOptions {
|
||||
line_ending,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(self, width: usize) -> Self {
|
||||
RtOptions { width, ..self }
|
||||
}
|
||||
|
||||
pub fn initial_indent(self, initial_indent: Line<'a>) -> Self {
|
||||
RtOptions {
|
||||
initial_indent,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subsequent_indent(self, subsequent_indent: Line<'a>) -> Self {
|
||||
RtOptions {
|
||||
subsequent_indent,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn break_words(self, break_words: bool) -> Self {
|
||||
RtOptions {
|
||||
break_words,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn word_separator(self, word_separator: textwrap::WordSeparator) -> RtOptions<'a> {
|
||||
RtOptions {
|
||||
word_separator,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wrap_algorithm(self, wrap_algorithm: textwrap::WrapAlgorithm) -> RtOptions<'a> {
|
||||
RtOptions {
|
||||
wrap_algorithm,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn word_splitter(self, word_splitter: textwrap::WordSplitter) -> RtOptions<'a> {
|
||||
RtOptions {
|
||||
word_splitter,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
|
||||
where
|
||||
O: Into<RtOptions<'a>>,
|
||||
{
|
||||
// Flatten the line and record span byte ranges.
|
||||
let mut flat = String::new();
|
||||
let mut span_bounds = Vec::new();
|
||||
let mut acc = 0usize;
|
||||
for s in &line.spans {
|
||||
let text = s.content.as_ref();
|
||||
let start = acc;
|
||||
flat.push_str(text);
|
||||
acc += text.len();
|
||||
span_bounds.push((start..acc, s.style));
|
||||
}
|
||||
|
||||
let rt_opts: RtOptions<'a> = width_or_options.into();
|
||||
let opts = Options::new(rt_opts.width)
|
||||
.line_ending(rt_opts.line_ending)
|
||||
.break_words(rt_opts.break_words)
|
||||
.wrap_algorithm(rt_opts.wrap_algorithm)
|
||||
.word_separator(rt_opts.word_separator)
|
||||
.word_splitter(rt_opts.word_splitter);
|
||||
|
||||
let mut out: Vec<Line<'a>> = Vec::new();
|
||||
|
||||
// Compute first line range with reduced width due to initial indent.
|
||||
let initial_width_available = opts
|
||||
.width
|
||||
.saturating_sub(rt_opts.initial_indent.width())
|
||||
.max(1);
|
||||
let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
|
||||
let Some(first_line_range) = initial_wrapped.first() else {
|
||||
return vec![rt_opts.initial_indent.clone()];
|
||||
};
|
||||
|
||||
// Build first wrapped line with initial indent.
|
||||
let mut first_line = rt_opts.initial_indent.clone();
|
||||
{
|
||||
let mut sliced = slice_line_spans(line, &span_bounds, first_line_range);
|
||||
let mut spans = first_line.spans;
|
||||
spans.append(&mut sliced.spans);
|
||||
first_line.spans = spans;
|
||||
out.push(first_line);
|
||||
}
|
||||
|
||||
// Wrap the remainder using subsequent indent width and map back to original indices.
|
||||
let base = first_line_range.end;
|
||||
let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
|
||||
let base = base + skip_leading_spaces;
|
||||
let subsequent_width_available = opts
|
||||
.width
|
||||
.saturating_sub(rt_opts.subsequent_indent.width())
|
||||
.max(1);
|
||||
let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
|
||||
for r in &remaining_wrapped {
|
||||
if r.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut subsequent_line = rt_opts.subsequent_indent.clone();
|
||||
let offset_range = (r.start + base)..(r.end + base);
|
||||
let mut sliced = slice_line_spans(line, &span_bounds, &offset_range);
|
||||
let mut spans = subsequent_line.spans;
|
||||
spans.append(&mut sliced.spans);
|
||||
subsequent_line.spans = spans;
|
||||
out.push(subsequent_line);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap a sequence of lines, applying the initial indent only to the very first
|
||||
/// output line, and using the subsequent indent for all later wrapped pieces.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn word_wrap_lines<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'static>>
|
||||
where
|
||||
I: IntoIterator<Item = &'a Line<'a>>,
|
||||
O: Into<RtOptions<'a>>,
|
||||
{
|
||||
let base_opts: RtOptions<'a> = width_or_options.into();
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
for (idx, line) in lines.into_iter().enumerate() {
|
||||
let opts = if idx == 0 {
|
||||
base_opts.clone()
|
||||
} else {
|
||||
let mut o = base_opts.clone();
|
||||
let sub = o.subsequent_indent.clone();
|
||||
o = o.initial_indent(sub);
|
||||
o
|
||||
};
|
||||
let wrapped = word_wrap_line(line, opts);
|
||||
push_owned_lines(&wrapped, &mut out);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn word_wrap_lines_borrowed<'a, I, O>(lines: I, width_or_options: O) -> Vec<Line<'a>>
|
||||
where
|
||||
I: IntoIterator<Item = &'a Line<'a>>,
|
||||
O: Into<RtOptions<'a>>,
|
||||
{
|
||||
let base_opts: RtOptions<'a> = width_or_options.into();
|
||||
let mut out: Vec<Line<'a>> = Vec::new();
|
||||
let mut first = true;
|
||||
for line in lines.into_iter() {
|
||||
let opts = if first {
|
||||
base_opts.clone()
|
||||
} else {
|
||||
base_opts
|
||||
.clone()
|
||||
.initial_indent(base_opts.subsequent_indent.clone())
|
||||
};
|
||||
out.extend(word_wrap_line(line, opts));
|
||||
first = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn slice_line_spans<'a>(
|
||||
original: &'a Line<'a>,
|
||||
span_bounds: &[(Range<usize>, ratatui::style::Style)],
|
||||
range: &Range<usize>,
|
||||
) -> Line<'a> {
|
||||
let start_byte = range.start;
|
||||
let end_byte = range.end;
|
||||
let mut acc: Vec<Span<'a>> = Vec::new();
|
||||
for (i, (range, style)) in span_bounds.iter().enumerate() {
|
||||
let s = range.start;
|
||||
let e = range.end;
|
||||
if e <= start_byte {
|
||||
continue;
|
||||
}
|
||||
if s >= end_byte {
|
||||
break;
|
||||
}
|
||||
let seg_start = start_byte.max(s);
|
||||
let seg_end = end_byte.min(e);
|
||||
if seg_end > seg_start {
|
||||
let local_start = seg_start - s;
|
||||
let local_end = seg_end - s;
|
||||
let content = original.spans[i].content.as_ref();
|
||||
let slice = &content[local_start..local_end];
|
||||
acc.push(Span {
|
||||
style: *style,
|
||||
content: std::borrow::Cow::Borrowed(slice),
|
||||
});
|
||||
}
|
||||
if e >= end_byte {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Line {
|
||||
style: original.style,
|
||||
alignment: original.alignment,
|
||||
spans: acc,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use itertools::Itertools as _;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
fn concat_line(line: &Line) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trivial_unstyled_no_indents_wide_width() {
|
||||
let line = Line::from("hello");
|
||||
let out = word_wrap_line(&line, 10);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(concat_line(&out[0]), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_unstyled_wrap_narrow_width() {
|
||||
let line = Line::from("hello world");
|
||||
let out = word_wrap_line(&line, 5);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(concat_line(&out[0]), "hello");
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_styled_wrap_preserves_styles() {
|
||||
let line = Line::from(vec!["hello ".red(), "world".into()]);
|
||||
let out = word_wrap_line(&line, 6);
|
||||
assert_eq!(out.len(), 2);
|
||||
// First line should carry the red style
|
||||
assert_eq!(concat_line(&out[0]), "hello");
|
||||
assert_eq!(out[0].spans.len(), 1);
|
||||
assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
|
||||
// Second line is unstyled
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
assert_eq!(out[1].spans.len(), 1);
|
||||
assert_eq!(out[1].spans[0].style.fg, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_initial_and_subsequent_indents() {
|
||||
let opts = RtOptions::new(8)
|
||||
.initial_indent(Line::from("- "))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
let line = Line::from("hello world foo");
|
||||
let out = word_wrap_line(&line, opts);
|
||||
// Expect three lines with proper prefixes
|
||||
assert!(concat_line(&out[0]).starts_with("- "));
|
||||
assert!(concat_line(&out[1]).starts_with(" "));
|
||||
assert!(concat_line(&out[2]).starts_with(" "));
|
||||
// And content roughly segmented
|
||||
assert_eq!(concat_line(&out[0]), "- hello");
|
||||
assert_eq!(concat_line(&out[1]), " world");
|
||||
assert_eq!(concat_line(&out[2]), " foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_initial_indent_subsequent_spaces() {
|
||||
let opts = RtOptions::new(8)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
let line = Line::from("hello world foobar");
|
||||
let out = word_wrap_line(&line, opts);
|
||||
assert!(concat_line(&out[0]).starts_with("hello"));
|
||||
for l in &out[1..] {
|
||||
assert!(concat_line(l).starts_with(" "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_yields_single_empty_line() {
|
||||
let line = Line::from("");
|
||||
let out = word_wrap_line(&line, 10);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(concat_line(&out[0]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_spaces_preserved_on_first_line() {
|
||||
let line = Line::from(" hello");
|
||||
let out = word_wrap_line(&line, 8);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(concat_line(&out[0]), " hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_spaces_between_words_dont_start_next_line_with_spaces() {
|
||||
let line = Line::from("hello world");
|
||||
let out = word_wrap_line(&line, 8);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(concat_line(&out[0]), "hello");
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_words_false_allows_overflow_for_long_word() {
|
||||
let opts = RtOptions::new(5).break_words(false);
|
||||
let line = Line::from("supercalifragilistic");
|
||||
let out = word_wrap_line(&line, opts);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(concat_line(&out[0]), "supercalifragilistic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyphen_splitter_breaks_at_hyphen() {
|
||||
let line = Line::from("hello-world");
|
||||
let out = word_wrap_line(&line, 7);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(concat_line(&out[0]), "hello-");
|
||||
assert_eq!(concat_line(&out[1]), "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_consumes_width_leaving_one_char_space() {
|
||||
let opts = RtOptions::new(4)
|
||||
.initial_indent(Line::from(">>>>"))
|
||||
.subsequent_indent(Line::from("--"));
|
||||
let line = Line::from("hello");
|
||||
let out = word_wrap_line(&line, opts);
|
||||
assert_eq!(out.len(), 3);
|
||||
assert_eq!(concat_line(&out[0]), ">>>>h");
|
||||
assert_eq!(concat_line(&out[1]), "--el");
|
||||
assert_eq!(concat_line(&out[2]), "--lo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_unicode_wraps_by_display_width() {
|
||||
let line = Line::from("😀😀😀");
|
||||
let out = word_wrap_line(&line, 4);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(concat_line(&out[0]), "😀😀");
|
||||
assert_eq!(concat_line(&out[1]), "😀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_split_within_span_preserves_style() {
|
||||
use ratatui::style::Stylize;
|
||||
let line = Line::from(vec!["abcd".red()]);
|
||||
let out = word_wrap_line(&line, 2);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out[0].spans.len(), 1);
|
||||
assert_eq!(out[1].spans.len(), 1);
|
||||
assert_eq!(out[0].spans[0].style.fg, Some(Color::Red));
|
||||
assert_eq!(out[1].spans[0].style.fg, Some(Color::Red));
|
||||
assert_eq!(concat_line(&out[0]), "ab");
|
||||
assert_eq!(concat_line(&out[1]), "cd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_applies_initial_indent_only_once() {
|
||||
let opts = RtOptions::new(8)
|
||||
.initial_indent(Line::from("- "))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
|
||||
let lines = vec![Line::from("hello world"), Line::from("foo bar baz")];
|
||||
let out = word_wrap_lines(&lines, opts);
|
||||
|
||||
// Expect: first line prefixed with "- ", subsequent wrapped pieces with " "
|
||||
// and for the second input line, there should be no "- " prefix on its first piece
|
||||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||||
assert!(rendered[0].starts_with("- "));
|
||||
for r in rendered.iter().skip(1) {
|
||||
assert!(r.starts_with(" "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_without_indents_is_concat_of_single_wraps() {
|
||||
let lines = vec![Line::from("hello"), Line::from("world!")];
|
||||
let out = word_wrap_lines(&lines, 10);
|
||||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||||
assert_eq!(rendered, vec!["hello", "world!"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_borrowed_applies_initial_indent_only_once() {
|
||||
let opts = RtOptions::new(8)
|
||||
.initial_indent(Line::from("- "))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
|
||||
let lines = [Line::from("hello world"), Line::from("foo bar baz")];
|
||||
let out = word_wrap_lines_borrowed(lines.iter().collect::<Vec<_>>(), opts);
|
||||
|
||||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||||
assert!(rendered.first().unwrap().starts_with("- "));
|
||||
for r in rendered.iter().skip(1) {
|
||||
assert!(r.starts_with(" "));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_borrowed_without_indents_is_concat_of_single_wraps() {
|
||||
let lines = [Line::from("hello"), Line::from("world!")];
|
||||
let out = word_wrap_lines_borrowed(lines.iter().collect::<Vec<_>>(), 10);
|
||||
let rendered: Vec<String> = out.iter().map(concat_line).collect();
|
||||
assert_eq!(rendered, vec!["hello", "world!"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_height_counts_double_width_emoji() {
|
||||
let line = "😀😀😀".into(); // each emoji ~ width 2
|
||||
assert_eq!(word_wrap_line(&line, 4).len(), 2);
|
||||
assert_eq!(word_wrap_line(&line, 2).len(), 3);
|
||||
assert_eq!(word_wrap_line(&line, 6).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_wrap_does_not_split_words_simple_english() {
|
||||
let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
|
||||
let line = Line::from(sample);
|
||||
let lines = [line];
|
||||
// Force small width to exercise wrapping at spaces.
|
||||
let wrapped = word_wrap_lines_borrowed(&lines, 40);
|
||||
let joined: String = wrapped.iter().map(|l| l.to_string()).join("\n");
|
||||
assert_eq!(
|
||||
joined,
|
||||
r#"Years passed, and Willowmere thrived
|
||||
in peace and friendship. Mira’s herb
|
||||
garden flourished with both ordinary and
|
||||
enchanted plants, and travelers spoke
|
||||
of the kindness of the woman who tended
|
||||
them."#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Single integration test binary that aggregates all test modules.
|
||||
// The submodules live in `tests/suite/`.
|
||||
mod suite;
|
||||
+24109
File diff suppressed because one or more lines are too long
+8041
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod status_indicator;
|
||||
mod vt100_history;
|
||||
mod vt100_live_commit;
|
||||
mod vt100_streaming_no_dup;
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Regression test: ensure that `StatusIndicatorWidget` sanitises ANSI escape
|
||||
//! sequences so that no raw `\x1b` bytes are written into the backing
|
||||
//! buffer. Rendering logic is tricky to unit‑test end‑to‑end, therefore we
|
||||
//! verify the *public* contract of `ansi_escape_line()` which the widget now
|
||||
//! relies on.
|
||||
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
|
||||
#[test]
|
||||
fn ansi_escape_line_strips_escape_sequences() {
|
||||
let text_in_ansi_red = "\x1b[31mRED\x1b[0m";
|
||||
|
||||
// The returned line must contain three printable glyphs and **no** raw
|
||||
// escape bytes.
|
||||
let line = ansi_escape_line(text_in_ansi_red);
|
||||
|
||||
let combined: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.to_string())
|
||||
.collect();
|
||||
|
||||
assert_eq!(combined, "RED");
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
#![cfg(feature = "vt100-tests")]
|
||||
#![expect(clippy::expect_used)]
|
||||
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
// Small helper macro to assert a collection contains an item with a clearer
|
||||
// failure message.
|
||||
macro_rules! assert_contains {
|
||||
($collection:expr, $item:expr $(,)?) => {
|
||||
assert!(
|
||||
$collection.contains(&$item),
|
||||
"Expected {:?} to contain {:?}",
|
||||
$collection,
|
||||
$item
|
||||
);
|
||||
};
|
||||
($collection:expr, $item:expr, $($arg:tt)+) => {
|
||||
assert!($collection.contains(&$item), $($arg)+);
|
||||
};
|
||||
}
|
||||
|
||||
struct TestScenario {
|
||||
width: u16,
|
||||
height: u16,
|
||||
term: codex_tui::custom_terminal::Terminal<TestBackend>,
|
||||
}
|
||||
|
||||
impl TestScenario {
|
||||
fn new(width: u16, height: u16, viewport: Rect) -> Self {
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
|
||||
.expect("failed to construct terminal");
|
||||
term.set_viewport_area(viewport);
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
term,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_insert(&mut self, lines: Vec<Line<'static>>) -> Vec<u8> {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
codex_tui::insert_history::insert_history_lines_to_writer(&mut self.term, &mut buf, lines);
|
||||
buf
|
||||
}
|
||||
|
||||
fn screen_rows_from_bytes(&self, bytes: &[u8]) -> Vec<String> {
|
||||
let mut parser = vt100::Parser::new(self.height, self.width, 0);
|
||||
parser.process(bytes);
|
||||
let screen = parser.screen();
|
||||
|
||||
let mut rows: Vec<String> = Vec::with_capacity(self.height as usize);
|
||||
for row in 0..self.height {
|
||||
let mut s = String::with_capacity(self.width as usize);
|
||||
for col in 0..self.width {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
if let Some(ch) = cell.contents().chars().next() {
|
||||
s.push(ch);
|
||||
} else {
|
||||
s.push(' ');
|
||||
}
|
||||
} else {
|
||||
s.push(' ');
|
||||
}
|
||||
}
|
||||
rows.push(s.trim_end().to_string());
|
||||
}
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_insertion_no_wrap() {
|
||||
// Screen of 20x6; viewport is the last row (height=1 at y=5)
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let lines = vec!["first".into(), "second".into()];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let rows = scenario.screen_rows_from_bytes(&buf);
|
||||
assert_contains!(rows, String::from("first"));
|
||||
assert_contains!(rows, String::from("second"));
|
||||
let first_idx = rows
|
||||
.iter()
|
||||
.position(|r| r == "first")
|
||||
.expect("expected 'first' row to be present");
|
||||
let second_idx = rows
|
||||
.iter()
|
||||
.position(|r| r == "second")
|
||||
.expect("expected 'second' row to be present");
|
||||
assert_eq!(second_idx, first_idx + 1, "rows should be adjacent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_token_wraps() {
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let long = "A".repeat(45); // > 2 lines at width 20
|
||||
let lines = vec![long.clone().into()];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let mut parser = vt100::Parser::new(6, 20, 0);
|
||||
parser.process(&buf);
|
||||
let screen = parser.screen();
|
||||
|
||||
// Count total A's on the screen
|
||||
let mut count_a = 0usize;
|
||||
for row in 0..6 {
|
||||
for col in 0..20 {
|
||||
if let Some(cell) = screen.cell(row, col)
|
||||
&& let Some(ch) = cell.contents().chars().next()
|
||||
&& ch == 'A'
|
||||
{
|
||||
count_a += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
count_a,
|
||||
long.len(),
|
||||
"wrapped content did not preserve all characters"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emoji_and_cjk() {
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let text = String::from("😀😀😀😀😀 你好世界");
|
||||
let lines = vec![text.clone().into()];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let rows = scenario.screen_rows_from_bytes(&buf);
|
||||
let reconstructed: String = rows.join("").chars().filter(|c| *c != ' ').collect();
|
||||
for ch in text.chars().filter(|c| !c.is_whitespace()) {
|
||||
assert!(
|
||||
reconstructed.contains(ch),
|
||||
"missing character {ch:?} in reconstructed screen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_ansi_spans() {
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let line = vec!["red".red(), "+plain".into()].into();
|
||||
let buf = scenario.run_insert(vec![line]);
|
||||
let rows = scenario.screen_rows_from_bytes(&buf);
|
||||
assert_contains!(rows, String::from("red+plain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_restoration() {
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let lines = vec!["x".into()];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let s = String::from_utf8_lossy(&buf);
|
||||
// CUP to 1;1 (ANSI: ESC[1;1H)
|
||||
assert!(
|
||||
s.contains("\u{1b}[1;1H"),
|
||||
"expected final CUP to 1;1 in output, got: {s:?}"
|
||||
);
|
||||
// Reset scroll region
|
||||
assert!(
|
||||
s.contains("\u{1b}[r"),
|
||||
"expected reset scroll region in output, got: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_wrap_no_mid_word_split() {
|
||||
// Screen of 40x10; viewport is the last row
|
||||
let area = Rect::new(0, 9, 40, 1);
|
||||
let mut scenario = TestScenario::new(40, 10, area);
|
||||
|
||||
let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
|
||||
let buf = scenario.run_insert(vec![sample.into()]);
|
||||
let rows = scenario.screen_rows_from_bytes(&buf);
|
||||
let joined = rows.join("\n");
|
||||
assert!(
|
||||
!joined.contains("bo\nth"),
|
||||
"word 'both' should not be split across lines:\n{joined}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn em_dash_and_space_word_wrap() {
|
||||
// Repro from report: ensure we break before "inside", not mid-word.
|
||||
let area = Rect::new(0, 9, 40, 1);
|
||||
let mut scenario = TestScenario::new(40, 10, area);
|
||||
|
||||
let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed.";
|
||||
let buf = scenario.run_insert(vec![sample.into()]);
|
||||
let rows = scenario.screen_rows_from_bytes(&buf);
|
||||
let joined = rows.join("\n");
|
||||
assert!(
|
||||
!joined.contains("insi\nde"),
|
||||
"word 'inside' should not be split across lines:\n{joined}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_scroll_region_down() {
|
||||
// Viewport not at bottom: y=3 (0-based), height=1
|
||||
let area = Rect::new(0, 3, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let lines = vec!["first".into(), "second".into()];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let s = String::from_utf8_lossy(&buf);
|
||||
// Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based)
|
||||
assert!(
|
||||
s.contains("\u{1b}[4;6r"),
|
||||
"expected pre-scroll SetScrollRegion 4..6, got: {s:?}"
|
||||
);
|
||||
// Expect we moved cursor to top of that region: row 3 (0-based) => CUP 4;1H
|
||||
assert!(
|
||||
s.contains("\u{1b}[4;1H"),
|
||||
"expected cursor at top of pre-scroll region, got: {s:?}"
|
||||
);
|
||||
// Expect at least two Reverse Index commands (ESC M) for two inserted lines
|
||||
let ri_count = s.matches("\u{1b}M").count();
|
||||
assert!(
|
||||
ri_count >= 1,
|
||||
"expected at least one RI (ESC M), got: {s:?}"
|
||||
);
|
||||
// After pre-scroll, we set insertion scroll region to [1 .. new_top] => [1 .. 5]
|
||||
assert!(
|
||||
s.contains("\u{1b}[1;5r"),
|
||||
"expected insertion SetScrollRegion 1..5, got: {s:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#![cfg(feature = "vt100-tests")]
|
||||
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
|
||||
#[test]
|
||||
fn live_001_commit_on_overflow() {
|
||||
let backend = TestBackend::new(20, 6);
|
||||
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("failed to construct terminal: {e}"),
|
||||
};
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
term.set_viewport_area(area);
|
||||
|
||||
// Build 5 explicit rows at width 20.
|
||||
let mut rb = codex_tui::live_wrap::RowBuilder::new(20);
|
||||
rb.push_fragment("one\n");
|
||||
rb.push_fragment("two\n");
|
||||
rb.push_fragment("three\n");
|
||||
rb.push_fragment("four\n");
|
||||
rb.push_fragment("five\n");
|
||||
|
||||
// Keep the last 3 in the live ring; commit the first 2.
|
||||
let commit_rows = rb.drain_commit_ready(3);
|
||||
let lines: Vec<Line<'static>> = commit_rows.into_iter().map(|r| r.text.into()).collect();
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
|
||||
|
||||
let mut parser = vt100::Parser::new(6, 20, 0);
|
||||
parser.process(&buf);
|
||||
let screen = parser.screen();
|
||||
|
||||
// The words "one" and "two" should appear above the viewport.
|
||||
let mut joined = String::new();
|
||||
for row in 0..6 {
|
||||
for col in 0..20 {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
if let Some(ch) = cell.contents().chars().next() {
|
||||
joined.push(ch);
|
||||
} else {
|
||||
joined.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
joined.push('\n');
|
||||
}
|
||||
assert!(
|
||||
joined.contains("one"),
|
||||
"expected committed 'one' to be visible\n{joined}"
|
||||
);
|
||||
assert!(
|
||||
joined.contains("two"),
|
||||
"expected committed 'two' to be visible\n{joined}"
|
||||
);
|
||||
// The last three (three,four,five) remain in the live ring, not committed here.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_002_pre_scroll_and_commit() {
|
||||
let backend = TestBackend::new(20, 6);
|
||||
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("failed to construct terminal: {e}"),
|
||||
};
|
||||
// Viewport not at bottom: y=3
|
||||
let area = Rect::new(0, 3, 20, 1);
|
||||
term.set_viewport_area(area);
|
||||
|
||||
let mut rb = codex_tui::live_wrap::RowBuilder::new(20);
|
||||
rb.push_fragment("alpha\n");
|
||||
rb.push_fragment("beta\n");
|
||||
rb.push_fragment("gamma\n");
|
||||
rb.push_fragment("delta\n");
|
||||
|
||||
// Keep 3, commit 1.
|
||||
let commit_rows = rb.drain_commit_ready(3);
|
||||
let lines: Vec<Line<'static>> = commit_rows.into_iter().map(|r| r.text.into()).collect();
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
|
||||
let s = String::from_utf8_lossy(&buf);
|
||||
|
||||
// Expect a SetScrollRegion to [area.top()+1 .. screen_height] and a cursor move to top of that region.
|
||||
assert!(
|
||||
s.contains("\u{1b}[4;6r"),
|
||||
"expected pre-scroll region 4..6, got: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
s.contains("\u{1b}[4;1H"),
|
||||
"expected cursor CUP 4;1H, got: {s:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#![cfg(feature = "vt100-tests")]
|
||||
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
fn term(viewport: Rect) -> codex_tui::custom_terminal::Terminal<TestBackend> {
|
||||
let backend = TestBackend::new(20, 6);
|
||||
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
|
||||
.unwrap_or_else(|e| panic!("failed to construct terminal: {e}"));
|
||||
term.set_viewport_area(viewport);
|
||||
term
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_commit_trickle_no_duplication() {
|
||||
// Viewport is the last row (height=1 at y=5)
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut t = term(area);
|
||||
|
||||
// Step 1: commit first row
|
||||
let mut out1 = Vec::new();
|
||||
codex_tui::insert_history::insert_history_lines_to_writer(
|
||||
&mut t,
|
||||
&mut out1,
|
||||
vec!["one".into()],
|
||||
);
|
||||
|
||||
// Step 2: later commit next row
|
||||
let mut out2 = Vec::new();
|
||||
codex_tui::insert_history::insert_history_lines_to_writer(
|
||||
&mut t,
|
||||
&mut out2,
|
||||
vec!["two".into()],
|
||||
);
|
||||
|
||||
let combined = [out1, out2].concat();
|
||||
let s = String::from_utf8_lossy(&combined);
|
||||
assert_eq!(
|
||||
s.matches("one").count(),
|
||||
1,
|
||||
"history line duplicated: {s:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
s.matches("two").count(),
|
||||
1,
|
||||
"history line duplicated: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
!s.contains("three"),
|
||||
"live-only content leaked into history: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ring_rows_not_inserted_into_history() {
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut t = term(area);
|
||||
|
||||
// Commit two rows to history.
|
||||
let mut buf = Vec::new();
|
||||
codex_tui::insert_history::insert_history_lines_to_writer(
|
||||
&mut t,
|
||||
&mut buf,
|
||||
vec!["one".into(), "two".into()],
|
||||
);
|
||||
|
||||
// The live ring might display tail+head rows like ["two", "three"],
|
||||
// but only committed rows should be present in the history ANSI stream.
|
||||
let s = String::from_utf8_lossy(&buf);
|
||||
assert!(s.contains("one"));
|
||||
assert!(s.contains("two"));
|
||||
assert!(
|
||||
!s.contains("three"),
|
||||
"uncommitted live-ring content should not be inserted into history: {s:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user