diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 00000000..5f02d6a4
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,20 @@
+#!/bin/bash
+
+echo "Checking Rust code format ..."
+has_issues=0
+edition=2021
+
+for file in $(git diff --name-only --staged | grep '\.rs$'); do
+ if [ -f "${file}" ] && ! rustfmt --edition ${edition} --check --color auto "${file}"; then
+ echo ""
+ has_issues=1
+ rustfmt --edition ${edition} "${file}"
+ fi
+done
+
+if [ ${has_issues} -eq 0 ]; then
+ exit 0
+fi
+
+echo "Your code contains formatting issues and has been corrected. Please run \`git add\` to add them and commit them."
+exit 1
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100755
index 00000000..3a22f3f2
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,127 @@
+#!/bin/bash
+
+# pre-push hook: check version consistency and key values before pushing tags
+# Validates Cargo.toml, Cargo.lock, and cli/mod.rs against the tag being pushed
+
+set -e
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+CARGO_TOML="src-tauri/Cargo.toml"
+CARGO_LOCK="src-tauri/Cargo.lock"
+CLI_MOD="src-tauri/src/cli/mod.rs"
+PACKAGE_NAME="cc-switch-tui"
+REPO_URL="https://github.com/handy-sun/cc-switch-tui"
+
+APPS="Claude Codex Gemini OpenCode OpenClaw Hermes"
+
+while read -r local_ref local_sha remote_ref remote_sha; do
+ if [[ "$remote_ref" != refs/tags/* ]]; then
+ continue
+ fi
+
+ tag_name="${remote_ref#refs/tags/}"
+ tag_version="${tag_name#v}"
+
+ if [[ ! "$tag_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo -e "${YELLOW}Warning: Tag '$tag_name' doesn't follow semver (vX.Y.Z), skipping checks${NC}"
+ continue
+ fi
+
+ echo "=== Pre-push checks for tag: $tag_name ==="
+ errors=0
+
+ ## Cargo.toml version
+ if [ -f "$CARGO_TOML" ]; then
+ cargo_version=$(grep -E '^version\s*=\s*"[0-9]+\.[0-9]+\.[0-9]+"' "$CARGO_TOML" | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/')
+ if [ -z "$cargo_version" ]; then
+ echo -e "${RED} ✗ Could not extract version from $CARGO_TOML${NC}"
+ errors=$((errors + 1))
+ elif [ "$cargo_version" != "$tag_version" ]; then
+ echo -e "${RED} ✗ $CARGO_TOML version ($cargo_version) != tag ($tag_version)${NC}"
+ errors=$((errors + 1))
+ else
+ echo -e "${GREEN} ✓ $CARGO_TOML version: $cargo_version${NC}"
+ fi
+ else
+ echo -e "${RED} ✗ $CARGO_TOML not found${NC}"
+ errors=$((errors + 1))
+ fi
+
+ ## Cargo.lock version
+ if [ -f "$CARGO_LOCK" ]; then
+ lock_version=$(awk '/^\[\[package\]\]/{found=0} /name = "'"${PACKAGE_NAME}"'"/{found=1} found && /^version = /{print; exit}' "$CARGO_LOCK" | sed 's/.*"\([0-9.]*\)".*/\1/')
+ if [ -z "$lock_version" ]; then
+ echo -e "${RED} ✗ Could not extract $PACKAGE_NAME version from $CARGO_LOCK${NC}"
+ errors=$((errors + 1))
+ elif [ "$lock_version" != "$tag_version" ]; then
+ echo -e "${RED} ✗ $CARGO_LOCK version ($lock_version) != tag ($tag_version)${NC}"
+ echo -e "${YELLOW} Hint: run 'cd src-tauri && cargo check' to sync Cargo.lock${NC}"
+ errors=$((errors + 1))
+ else
+ echo -e "${GREEN} ✓ $CARGO_LOCK version: $lock_version${NC}"
+ fi
+ else
+ echo -e "${RED} ✗ $CARGO_LOCK not found${NC}"
+ errors=$((errors + 1))
+ fi
+
+ ## Cargo.toml description includes all apps
+ if [ -f "$CARGO_TOML" ]; then
+ desc=$(grep '^description' "$CARGO_TOML" || true)
+ desc_errors=0
+ for app in $APPS; do
+ if ! echo "$desc" | grep -q "$app"; then
+ echo -e "${RED} ✗ $CARGO_TOML description missing '$app'${NC}"
+ desc_errors=$((desc_errors + 1))
+ fi
+ done
+ errors=$((errors + desc_errors))
+ if [ $desc_errors -eq 0 ]; then
+ echo -e "${GREEN} ✓ $CARGO_TOML description includes all apps${NC}"
+ fi
+ fi
+
+ ## cli/mod.rs about text includes all apps
+ if [ -f "$CLI_MOD" ]; then
+ about=$(grep 'about = "' "$CLI_MOD" || true)
+ about_errors=0
+ for app in $APPS; do
+ if ! echo "$about" | grep -q "$app"; then
+ echo -e "${RED} ✗ $CLI_MOD about text missing '$app'${NC}"
+ about_errors=$((about_errors + 1))
+ fi
+ done
+ errors=$((errors + about_errors))
+ if [ $about_errors -eq 0 ]; then
+ echo -e "${GREEN} ✓ $CLI_MOD about text includes all apps${NC}"
+ fi
+ else
+ echo -e "${RED} ✗ $CLI_MOD not found${NC}"
+ errors=$((errors + 1))
+ fi
+
+ ## Repository URL
+ if [ -f "$CARGO_TOML" ]; then
+ repo=$(grep '^repository' "$CARGO_TOML" | head -1 | sed 's/.*= *"//;s/"$//')
+ if [ "$repo" != "$REPO_URL" ]; then
+ echo -e "${RED} ✗ $CARGO_TOML repository URL ($repo) != expected ($REPO_URL)${NC}"
+ errors=$((errors + 1))
+ else
+ echo -e "${GREEN} ✓ $CARGO_TOML repository: $repo${NC}"
+ fi
+ fi
+
+ if [ $errors -gt 0 ]; then
+ echo ""
+ echo -e "${RED}Pre-push checks failed ($errors errors)! Fix the issues above before pushing '$tag_name'.${NC}"
+ exit 1
+ fi
+
+ echo -e "${GREEN}All pre-push checks passed for $tag_name${NC}"
+done
+
+exit 0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index afa00a1d..3bf7adbe 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,7 @@ permissions:
contents: write
env:
- RUST_VERSION: 1.91.1
+ RUST_VERSION: 1.94.0
concurrency:
group: release-${{ github.ref_name }}
@@ -27,40 +27,40 @@ jobs:
- os: macos-14
platform: darwin-arm64
target: aarch64-apple-darwin
- binary: cc-switch
+ binary: cc-switch-tui
# macOS x86_64
- os: macos-14
platform: darwin-x64
target: x86_64-apple-darwin
- binary: cc-switch
+ binary: cc-switch-tui
# Windows x86_64
- os: windows-2022
platform: windows-x64
target: x86_64-pc-windows-msvc
- binary: cc-switch.exe
+ binary: cc-switch-tui.exe
# Linux x86_64 - MUSL (recommended)
- os: ubuntu-22.04
platform: linux-x64-musl
target: x86_64-unknown-linux-musl
- binary: cc-switch
+ binary: cc-switch-tui
use_cross: true
# Linux x86_64 - GLIBC (fallback)
- os: ubuntu-22.04
platform: linux-x64
target: x86_64-unknown-linux-gnu
- binary: cc-switch
+ binary: cc-switch-tui
use_cross: false
# Linux ARM64 - MUSL (recommended)
- os: ubuntu-22.04
platform: linux-arm64-musl
target: aarch64-unknown-linux-musl
- binary: cc-switch
+ binary: cc-switch-tui
use_cross: true
# Linux ARM64 - GLIBC (fallback)
- os: ubuntu-22.04
platform: linux-arm64
target: aarch64-unknown-linux-gnu
- binary: cc-switch
+ binary: cc-switch-tui
use_cross: true
steps:
@@ -73,6 +73,16 @@ jobs:
toolchain: ${{ env.RUST_VERSION }}
targets: ${{ matrix.target }}
+ # Keep target installation explicit for native cross-target jobs.
+ # This is idempotent and ensures the target exists on the exact
+ # toolchain selected by src-tauri/rust-toolchain.toml so builds such as
+ # darwin-x64 on macos-14 arm64 can find core/std.
+ - name: Ensure target on pinned rust-toolchain.toml toolchain
+ if: matrix.use_cross != true
+ shell: bash
+ working-directory: src-tauri
+ run: rustup target add ${{ matrix.target }}
+
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
@@ -111,7 +121,7 @@ jobs:
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
- name: cc-switch-cli-${{ matrix.platform }}
+ name: cc-switch-tui-${{ matrix.platform }}
path: dist/${{ matrix.binary }}
if-no-files-found: error
@@ -124,33 +134,122 @@ jobs:
- name: Download ARM64 binary
uses: actions/download-artifact@v4
with:
- name: cc-switch-cli-darwin-arm64
+ name: cc-switch-tui-darwin-arm64
path: arm64
- name: Download x64 binary
uses: actions/download-artifact@v4
with:
- name: cc-switch-cli-darwin-x64
+ name: cc-switch-tui-darwin-x64
path: x64
- name: Create universal binary
run: |
mkdir -p universal
- lipo -create -output universal/cc-switch arm64/cc-switch x64/cc-switch
- chmod +x universal/cc-switch
- file universal/cc-switch
+ lipo -create -output universal/cc-switch-tui arm64/cc-switch-tui x64/cc-switch-tui
+ chmod +x universal/cc-switch-tui
+ file universal/cc-switch-tui
- name: Upload universal artifact
uses: actions/upload-artifact@v4
with:
- name: cc-switch-cli-darwin-universal
- path: universal/cc-switch
+ name: cc-switch-tui-darwin-universal
+ path: universal/cc-switch-tui
if-no-files-found: error
+ publish-crate:
+ name: Publish crate to crates.io
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: ${{ env.RUST_VERSION }}
+
+ - name: Setup Rust cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: src-tauri
+ key: publish-crate
+
+ - name: Validate tag matches crate version
+ id: crate-version
+ shell: bash
+ working-directory: src-tauri
+ run: |
+ set -euo pipefail
+ tag_version="${GITHUB_REF_NAME#v}"
+ crate_version="$(
+ cargo metadata --no-deps --format-version 1 \
+ | jq -r '.packages[] | select(.name == "cc-switch-tui") | .version'
+ )"
+
+ if [ -z "${crate_version}" ] || [ "${crate_version}" = "null" ]; then
+ echo "Could not read cc-switch-tui version from Cargo metadata" >&2
+ exit 1
+ fi
+
+ if [ "${crate_version}" != "${tag_version}" ]; then
+ echo "Tag version (${tag_version}) does not match Cargo.toml version (${crate_version})" >&2
+ exit 1
+ fi
+
+ echo "version=${crate_version}" >> "${GITHUB_OUTPUT}"
+
+ - name: Check whether crate version is already published
+ id: crates-io
+ shell: bash
+ run: |
+ set -euo pipefail
+ status="$(
+ curl -sS \
+ -o /tmp/cc-switch-tui-crate-version.json \
+ -w "%{http_code}" \
+ "https://crates.io/api/v1/crates/cc-switch-tui/${{ steps.crate-version.outputs.version }}"
+ )"
+
+ if [ "${status}" = "200" ]; then
+ echo "cc-switch-tui ${{ steps.crate-version.outputs.version }} is already published; skipping cargo publish."
+ echo "published=true" >> "${GITHUB_OUTPUT}"
+ elif [ "${status}" = "404" ]; then
+ echo "published=false" >> "${GITHUB_OUTPUT}"
+ else
+ echo "Unexpected crates.io response: HTTP ${status}" >&2
+ cat /tmp/cc-switch-tui-crate-version.json >&2 || true
+ exit 1
+ fi
+
+ - name: Verify crates.io token is configured
+ if: steps.crates-io.outputs.published != 'true'
+ shell: bash
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+ run: |
+ set -euo pipefail
+ if [ -z "${CARGO_REGISTRY_TOKEN}" ]; then
+ echo "CARGO_REGISTRY_TOKEN secret is not configured" >&2
+ exit 1
+ fi
+
+ - name: Dry-run cargo publish
+ if: steps.crates-io.outputs.published != 'true'
+ working-directory: src-tauri
+ run: cargo publish --locked --dry-run
+
+ - name: Publish to crates.io
+ if: steps.crates-io.outputs.published != 'true'
+ working-directory: src-tauri
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+ run: cargo publish --locked
+
release:
name: Create Release
runs-on: ubuntu-22.04
- needs: [build, universal-macos]
+ needs: [build, universal-macos, publish-crate]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -194,68 +293,52 @@ jobs:
VERSION="${GITHUB_REF_NAME}"
# macOS Universal
- if [ -f "artifacts/cc-switch-cli-darwin-universal/cc-switch" ]; then
- tar -czf release-assets/cc-switch-cli-${VERSION}-darwin-universal.tar.gz \
- -C artifacts/cc-switch-cli-darwin-universal cc-switch
- cp release-assets/cc-switch-cli-${VERSION}-darwin-universal.tar.gz \
- release-assets/cc-switch-cli-darwin-universal.tar.gz
+ if [ -f "artifacts/cc-switch-tui-darwin-universal/cc-switch-tui" ]; then
+ tar -czf release-assets/cc-switch-tui-${VERSION}-darwin-universal.tar.gz \
+ -C artifacts/cc-switch-tui-darwin-universal cc-switch-tui
fi
# macOS ARM64
- if [ -f "artifacts/cc-switch-cli-darwin-arm64/cc-switch" ]; then
- tar -czf release-assets/cc-switch-cli-${VERSION}-darwin-arm64.tar.gz \
- -C artifacts/cc-switch-cli-darwin-arm64 cc-switch
- cp release-assets/cc-switch-cli-${VERSION}-darwin-arm64.tar.gz \
- release-assets/cc-switch-cli-darwin-arm64.tar.gz
+ if [ -f "artifacts/cc-switch-tui-darwin-arm64/cc-switch-tui" ]; then
+ tar -czf release-assets/cc-switch-tui-${VERSION}-darwin-arm64.tar.gz \
+ -C artifacts/cc-switch-tui-darwin-arm64 cc-switch-tui
fi
# macOS x64
- if [ -f "artifacts/cc-switch-cli-darwin-x64/cc-switch" ]; then
- tar -czf release-assets/cc-switch-cli-${VERSION}-darwin-x64.tar.gz \
- -C artifacts/cc-switch-cli-darwin-x64 cc-switch
- cp release-assets/cc-switch-cli-${VERSION}-darwin-x64.tar.gz \
- release-assets/cc-switch-cli-darwin-x64.tar.gz
+ if [ -f "artifacts/cc-switch-tui-darwin-x64/cc-switch-tui" ]; then
+ tar -czf release-assets/cc-switch-tui-${VERSION}-darwin-x64.tar.gz \
+ -C artifacts/cc-switch-tui-darwin-x64 cc-switch-tui
fi
# Windows
- if [ -f "artifacts/cc-switch-cli-windows-x64/cc-switch.exe" ]; then
- cd artifacts/cc-switch-cli-windows-x64
- zip ../../release-assets/cc-switch-cli-${VERSION}-windows-x64.zip cc-switch.exe
+ if [ -f "artifacts/cc-switch-tui-windows-x64/cc-switch-tui.exe" ]; then
+ cd artifacts/cc-switch-tui-windows-x64
+ zip ../../release-assets/cc-switch-tui-${VERSION}-windows-x64.zip cc-switch-tui.exe
cd ../..
- cp release-assets/cc-switch-cli-${VERSION}-windows-x64.zip \
- release-assets/cc-switch-cli-windows-x64.zip
fi
# Linux x64 - MUSL
- if [ -f "artifacts/cc-switch-cli-linux-x64-musl/cc-switch" ]; then
- tar -czf release-assets/cc-switch-cli-${VERSION}-linux-x64-musl.tar.gz \
- -C artifacts/cc-switch-cli-linux-x64-musl cc-switch
- cp release-assets/cc-switch-cli-${VERSION}-linux-x64-musl.tar.gz \
- release-assets/cc-switch-cli-linux-x64-musl.tar.gz
+ if [ -f "artifacts/cc-switch-tui-linux-x64-musl/cc-switch-tui" ]; then
+ tar -czf release-assets/cc-switch-tui-${VERSION}-linux-x64-musl.tar.gz \
+ -C artifacts/cc-switch-tui-linux-x64-musl cc-switch-tui
fi
# Linux x64 - GLIBC
- if [ -f "artifacts/cc-switch-cli-linux-x64/cc-switch" ]; then
- tar -czf release-assets/cc-switch-cli-${VERSION}-linux-x64.tar.gz \
- -C artifacts/cc-switch-cli-linux-x64 cc-switch
- cp release-assets/cc-switch-cli-${VERSION}-linux-x64.tar.gz \
- release-assets/cc-switch-cli-linux-x64.tar.gz
+ if [ -f "artifacts/cc-switch-tui-linux-x64/cc-switch-tui" ]; then
+ tar -czf release-assets/cc-switch-tui-${VERSION}-linux-x64.tar.gz \
+ -C artifacts/cc-switch-tui-linux-x64 cc-switch-tui
fi
# Linux ARM64 - MUSL
- if [ -f "artifacts/cc-switch-cli-linux-arm64-musl/cc-switch" ]; then
- tar -czf release-assets/cc-switch-cli-${VERSION}-linux-arm64-musl.tar.gz \
- -C artifacts/cc-switch-cli-linux-arm64-musl cc-switch
- cp release-assets/cc-switch-cli-${VERSION}-linux-arm64-musl.tar.gz \
- release-assets/cc-switch-cli-linux-arm64-musl.tar.gz
+ if [ -f "artifacts/cc-switch-tui-linux-arm64-musl/cc-switch-tui" ]; then
+ tar -czf release-assets/cc-switch-tui-${VERSION}-linux-arm64-musl.tar.gz \
+ -C artifacts/cc-switch-tui-linux-arm64-musl cc-switch-tui
fi
# Linux ARM64 - GLIBC
- if [ -f "artifacts/cc-switch-cli-linux-arm64/cc-switch" ]; then
- tar -czf release-assets/cc-switch-cli-${VERSION}-linux-arm64.tar.gz \
- -C artifacts/cc-switch-cli-linux-arm64 cc-switch
- cp release-assets/cc-switch-cli-${VERSION}-linux-arm64.tar.gz \
- release-assets/cc-switch-cli-linux-arm64.tar.gz
+ if [ -f "artifacts/cc-switch-tui-linux-arm64/cc-switch-tui" ]; then
+ tar -czf release-assets/cc-switch-tui-${VERSION}-linux-arm64.tar.gz \
+ -C artifacts/cc-switch-tui-linux-arm64 cc-switch-tui
fi
# Include install script
@@ -280,15 +363,15 @@ jobs:
chmod 600 "${key_file}"
shopt -s nullglob
- for asset in release-assets/cc-switch-cli-*.tar.gz release-assets/cc-switch-cli-*.zip; do
+ for asset in release-assets/cc-switch-tui-*.tar.gz release-assets/cc-switch-tui-*.zip; do
minisign -S \
-s "${key_file}" \
-m "${asset}" \
-x "${asset}.minisig" \
- -t "cc-switch-cli ${GITHUB_REF_NAME}"
+ -t "cc-switch-tui ${GITHUB_REF_NAME}"
done
- for asset in release-assets/cc-switch-cli-*.tar.gz release-assets/cc-switch-cli-*.zip; do
+ for asset in release-assets/cc-switch-tui-*.tar.gz release-assets/cc-switch-tui-*.zip; do
minisign -Vm "${asset}" \
-p src-tauri/updater/minisign.pub \
-x "${asset}.minisig"
@@ -298,8 +381,8 @@ jobs:
release-assets \
"${GITHUB_REF_NAME}" \
"$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
- "https://github.com/SaladDay/cc-switch-cli/releases/download/${GITHUB_REF_NAME}" \
- "CC Switch CLI ${GITHUB_REF_NAME}"
+ "https://github.com/handy-sun/cc-switch-tui/releases/download/${GITHUB_REF_NAME}" \
+ "CC Switch TUI ${GITHUB_REF_NAME}"
rm -f release-assets/*.minisig
@@ -313,13 +396,13 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
- name: CC Switch CLI ${{ github.ref_name }}
+ name: ${{ github.ref_name }}
draft: false
prerelease: false
body: |
- ## CC Switch CLI ${{ github.ref_name }}
+ ## CC Switch TUI ${{ github.ref_name }}
- All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode & OpenClaw
+ All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode, OpenClaw & Hermes
See `CHANGELOG.md` and the README in this tag for the latest release notes and upgrade highlights.
@@ -327,26 +410,26 @@ jobs:
| 平台 Platform | 文件 File |
|---------------|-----------|
- | **macOS** (Universal) | `cc-switch-cli-${{ github.ref_name }}-darwin-universal.tar.gz` |
- | **Windows** (x64) | `cc-switch-cli-${{ github.ref_name }}-windows-x64.zip` |
- | **Linux** (x64 musl) | `cc-switch-cli-${{ github.ref_name }}-linux-x64-musl.tar.gz` |
- | **Linux** (x64 glibc) | `cc-switch-cli-${{ github.ref_name }}-linux-x64.tar.gz` |
- | **Linux** (ARM64 musl) | `cc-switch-cli-${{ github.ref_name }}-linux-arm64-musl.tar.gz` |
- | **Linux** (ARM64 glibc) | `cc-switch-cli-${{ github.ref_name }}-linux-arm64.tar.gz` |
+ | **macOS** (Universal) | `cc-switch-tui-${{ github.ref_name }}-darwin-universal.tar.gz` |
+ | **Windows** (x64) | `cc-switch-tui-${{ github.ref_name }}-windows-x64.zip` |
+ | **Linux** (x64 musl) | `cc-switch-tui-${{ github.ref_name }}-linux-x64-musl.tar.gz` |
+ | **Linux** (x64 glibc) | `cc-switch-tui-${{ github.ref_name }}-linux-x64.tar.gz` |
+ | **Linux** (ARM64 musl) | `cc-switch-tui-${{ github.ref_name }}-linux-arm64-musl.tar.gz` |
+ | **Linux** (ARM64 glibc) | `cc-switch-tui-${{ github.ref_name }}-linux-arm64.tar.gz` |
### 🚀 快速安装 / Quick Install
**macOS / Linux (one-liner):**
```bash
- curl -fsSL https://github.com/SaladDay/cc-switch-cli/releases/latest/download/install.sh | bash
+ curl -fsSL https://github.com/handy-sun/cc-switch-tui/releases/latest/download/install.sh | bash
```
**Windows:**
```powershell
- # 下载 zip 后将 cc-switch.exe 移动到 PATH 目录或直接运行
+ # 下载 zip 后将 cc-switch-tui.exe 移动到 PATH 目录或直接运行
```
- 💡 **macOS 提示**: 如遇 "无法验证开发者",执行:`xattr -cr ~/.local/bin/cc-switch`
+ 💡 **macOS 提示**: 如遇 "无法验证开发者",执行:`xattr -cr ~/.local/bin/cc-switch-tui`
💡 **Linux 用户建议优先使用 `-musl` 版本(静态链接,无系统库依赖,兼容所有发行版)**
files: release-assets/*
diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml
index 18b65d62..c974a670 100644
--- a/.github/workflows/rust-ci.yml
+++ b/.github/workflows/rust-ci.yml
@@ -2,50 +2,148 @@ name: Rust CI
on:
push:
- branches:
- - main
+ branches: [main, "feat/**"]
paths:
- "src-tauri/**"
- ".github/workflows/rust-ci.yml"
- - ".github/workflows/release.yml"
- pull_request_target:
+ pull_request:
paths:
- "src-tauri/**"
- ".github/workflows/rust-ci.yml"
- - ".github/workflows/release.yml"
permissions:
contents: read
concurrency:
- group: rust-ci-${{ github.workflow }}-${{ github.ref }}
+ group: rust-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
- fmt:
- name: cargo fmt --check
- runs-on: ubuntu-22.04
+ build:
+ name: build (${{ matrix.target }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Linux x86_64 GLIBC (native)
+ - os: ubuntu-22.04
+ target: x86_64-unknown-linux-gnu
+ use_cross: false
+ # Linux x86_64 MUSL (cross)
+ - os: ubuntu-22.04
+ target: x86_64-unknown-linux-musl
+ use_cross: true
+ # Linux ARM64 MUSL (cross)
+ - os: ubuntu-22.04
+ target: aarch64-unknown-linux-musl
+ use_cross: true
+ # macOS ARM64 (native)
+ - os: macos-latest
+ target: aarch64-apple-darwin
+ use_cross: false
+ # macOS x86_64 (cross-compile on ARM64 runner)
+ - os: macos-latest
+ target: x86_64-apple-darwin
+ use_cross: false
+ runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
+
+ - name: Install Linux system deps
+ if: runner.os == 'Linux' && matrix.use_cross == false
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev
+ sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev
+ sudo apt-get install -y --no-install-recommends libsoup-3.0-dev || sudo apt-get install -y --no-install-recommends libsoup2.4-dev
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: "1.94.0"
+ targets: ${{ matrix.target }}
+ components: clippy
+
+ ## Keep target installation explicit for native cross-target jobs.
+ ## This is idempotent and ensures the target exists on the exact
+ ## toolchain selected by src-tauri/rust-toolchain.toml.
+ - name: Ensure target on pinned rust-toolchain.toml toolchain
+ if: matrix.use_cross == false
+ shell: bash
+ working-directory: src-tauri
+ run: rustup target add ${{ matrix.target }}
+
+ - name: Setup Rust cache
+ uses: Swatinem/rust-cache@v2
with:
- ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ workspaces: src-tauri
+ key: build-${{ matrix.target }}
+
+ - name: Install cross
+ if: matrix.use_cross == true
+ run: cargo install cross --git https://github.com/cross-rs/cross
+
+ - name: Create frontend dist placeholder
+ run: mkdir -p dist
+ shell: bash
+
+ # - name: Run Clippy
+ # working-directory: src-tauri
+ # continue-on-error: true
+ # run: cargo clippy -- -D warnings
+
+ - name: Build with cross
+ if: matrix.use_cross == true
+ working-directory: src-tauri
+ run: cross build --release --target ${{ matrix.target }}
+
+ - name: Build with cargo
+ if: matrix.use_cross == false
+ working-directory: src-tauri
+ run: cargo build --release --target ${{ matrix.target }}
+
+ - name: Upload binary
+ uses: actions/upload-artifact@v4
+ with:
+ name: cc-switch-tui-${{ matrix.target }}
+ path: src-tauri/target/${{ matrix.target }}/release/cc-switch-tui
+ if-no-files-found: error
+
+ test:
+ name: test (${{ matrix.os }})
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-22.04, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Linux system deps
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev
+ sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev
+ sudo apt-get install -y --no-install-recommends libsoup-3.0-dev || sudo apt-get install -y --no-install-recommends libsoup2.4-dev
- name: Setup Rust
uses: dtolnay/rust-toolchain@master
with:
- toolchain: 1.91.1
- components: rustfmt
+ toolchain: "1.94.0"
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- key: fmt-check
+ key: test-${{ matrix.os }}
- - name: Check formatting
+ - name: Run tests
working-directory: src-tauri
- run: cargo fmt --check
+ continue-on-error: true
+ run: cargo test
failover-e2e:
name: failover E2E test
@@ -54,10 +152,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ - name: Install Linux system deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev
+ sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev
+ sudo apt-get install -y --no-install-recommends libsoup-3.0-dev || sudo apt-get install -y --no-install-recommends libsoup2.4-dev
+
- name: Setup Rust
uses: dtolnay/rust-toolchain@master
with:
- toolchain: 1.91.1
+ toolchain: "1.94.0"
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
@@ -65,11 +170,14 @@ jobs:
workspaces: src-tauri
key: failover-e2e
+ - name: Create frontend dist placeholder
+ run: mkdir -p dist
+
- name: Run failover E2E test
working-directory: src-tauri
run: |
sandbox_home="$(mktemp -d)"
export HOME="$sandbox_home"
export USERPROFILE="$sandbox_home"
- export CC_SWITCH_CONFIG_DIR="$sandbox_home/.cc-switch"
+ export CC_SWITCH_TUI_CONFIG_DIR="$sandbox_home/.cc-switch"
cargo test --test proxy_claude_forwarder_alignment proxy_claude_auto_failover_uses_activated_queue_providers -- --exact --nocapture
diff --git a/.gitignore b/.gitignore
index ccf947ea..17e54944 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@ scripts/*
.agent/
.agents/
+.remember/
.worktrees/
docs/superpowers/
.omx/
diff --git a/AGENT.md b/AGENT.md
new file mode 100644
index 00000000..b4725796
--- /dev/null
+++ b/AGENT.md
@@ -0,0 +1,181 @@
+1|# Hermes Agent 支持计划 - cc-switch-tui
+
+## 背景
+
+三个 cc-switch 相关项目的定位和 Hermes 支持现状:
+
+| 项目 | 定位 | Hermes 支持 | 设置同步 |
+|---|---|---|---|
+| cc-switch | Tauri 桌面 GUI (v3.14.1) | 完整一等公民支持 | WebDAV 云同步 |
+| cc-switch-tui | TUI 管理器 (v0.0.1) | 一等公民支持 | WebDAV 云同步 |
+| cc-switch-web | Web 版 (v0.10.2-1) | 完全没有 | 无 |
+
+用户在无桌面的 Linux 环境下使用,需要在 CLI 端添加 Hermes Agent 的完整支持(包括设置同步功能)。
+
+## 决策:加在 CLI 端
+
+理由:
+
+1. **WebDAV 云同步已就绪** — CLI 已有完整的 WebDAV 基础设施(上传/下载/自动同步/坚果云预设),Web 端完全没有
+2. **数据层已有 30-40% 基础** — `enabled_hermes` 数据库列、`McpApps.hermes` 字段已存在,只需补齐逻辑
+3. **与桌面版数据兼容** — CLI 使用 SQLite + WebDAV 协议与桌面版一致,同步数据可无缝互通
+4. **桌面版代码可大量复用** — `hermes_config.rs`(1947行)和 `mcp/hermes.rs`(574行)可直接移植,同为 Rust 后端
+5. **TUI 足够应对无桌面场景** — 交互式终端界面,SSH 中也能用
+
+## Hermes Agent 配置结构
+
+Hermes 使用 YAML 格式配置文件 `~/.hermes/config.yaml`:
+
+```yaml
+model:
+ default: "anthropic/claude-opus-4-7"
+ provider: "openrouter"
+ base_url: "https://openrouter.ai/api/v1"
+
+agent:
+ max_turns: 50
+ reasoning_effort: "high"
+
+custom_providers:
+ - name: openrouter
+ base_url: https://openrouter.ai/api/v1
+ api_key: sk-or-...
+ model: anthropic/claude-opus-4-7
+ models:
+ anthropic/claude-opus-4-7:
+ context_length: 200000
+
+mcp_servers:
+ filesystem:
+ command: npx
+ args: ["-y", "@modelcontextprotocol/server-filesystem"]
+```
+
+关键特征:
+- **累加式供应商管理**(additive mode),所有供应商共存于同一配置文件
+- MCP 无显式 `type` 字段,通过 `command`(stdio)vs `url`(HTTP)推断
+- MCP 有 Hermes 专有字段:`enabled`, `timeout`, `connect_timeout`, `tools`, `sampling`, `roots`, `auth`
+- Memory 文件:`~/.hermes/memories/MEMORY.md` 和 `~/.hermes/memories/USER.md`
+- Web UI:`http://127.0.0.1:9119`,或 `hermes dashboard` 命令
+
+## 实现计划
+
+### Tier 1: 核心 AppType 添加(必做)
+
+| 变更 | 文件 | 工作量 |
+|---|---|---|
+| 添加 `AppType::Hermes` 枚举变体 | `app_config.rs` | 小 |
+| 添加 Hermes match 臂(as_str, is_additive_mode, all, FromStr) | `app_config.rs` | 小 |
+| `McpApps::is_enabled_for/set_enabled_for/enabled_apps` 补 Hermes 臂 | `app_config.rs` | 小 |
+| `SkillApps` 添加 `hermes` 字段 + 方法臂 | `app_config.rs` | 小 |
+| `VisibleApps` 添加 `hermes` 字段 + `app_order()` 更新 | `settings.rs` | 小 |
+| `AppSettings` 添加 `hermes_config_dir` + `current_provider_hermes` | `settings.rs` | 中 |
+| `CommonConfigSnippets` 添加 `hermes` 字段 | `app_config.rs` | 小 |
+| `PromptRoot` 添加 `hermes` 字段 | `app_config.rs` | 小 |
+| `MultiAppConfig::default()` 插入 hermes app | `app_config.rs` | 小 |
+| `sync_policy::should_sync_live()` 补 Hermes 臂 | `sync_policy.rs` | 小 |
+| `prompt_file_path()` 补 Hermes 臂 | `prompt_files.rs` | 小 |
+
+### Tier 2: Hermes 配置模块(核心工作,可从桌面版移植)
+
+| 变更 | 文件 | 工作量 |
+|---|---|---|
+| 新建 `hermes_config.rs` — 配置目录/路径/读写函数 | 新文件,从桌面版移植(1947行) | 大 |
+| 新建 `mcp/hermes.rs` — MCP 格式转换与同步 | 新文件,从桌面版移植(574行) | 中 |
+| `ProviderService::write_live_snapshot()` 补 Hermes 臂 | `services/provider/mod.rs` | 大 |
+| `ProviderService::refresh_provider_snapshot()` 补 Hermes 臂 | `services/provider/mod.rs` | 中 |
+| `ProviderService::import_default_config()` 补 Hermes 臂 | `services/provider/mod.rs` | 中 |
+| `ProviderService::read_live_settings()` 补 Hermes 臂 | `services/provider/mod.rs` | 中 |
+| `McpService::sync_server_to_app_internal()` 补 Hermes | `services/mcp.rs` | 小 |
+| `McpService::remove_server_from_app()` 补 Hermes | `services/mcp.rs` | 小 |
+| `import_from_hermes()` 导入函数 | `mcp.rs` | 中 |
+
+### Tier 3: TUI 集成
+
+| 变更 | 文件 | 工作量 |
+|---|---|---|
+| TUI app state / tab 切换添加 Hermes | `tui/app/app_state.rs` | 小 |
+| Hermes 路由和导航(如需自定义页面) | `tui/route.rs` | 可变 |
+| `cc-switch start hermes` 命令(可选) | `cli/commands/start.rs` | 中 |
+
+### Tier 4: 数据库 / WebDAV
+
+| 变更 | 文件 | 工作量 |
+|---|---|---|
+| 如需新 schema 变更,添加 v10→v11 迁移 | `schema.rs` | 小 |
+| WebDAV DB_COMPAT_VERSION 可能需 bump | `webdav_sync/mod.rs` | 小 |
+| 更新 `McpApps { ..., hermes: false }` 字面量 | `mcp.rs` 等 | 小 |
+
+## 桌面版可复用代码
+
+| 文件 | 行数 | 用途 |
+|---|---|---|
+| `cc-switch/src-tauri/src/hermes_config.rs` | 1947 | 配置读写、provider CRUD、model 管理、memory 文件 |
+| `cc-switch/src-tauri/src/mcp/hermes.rs` | 574 | MCP 格式转换(stdio/HTTP ↔ Hermes YAML)、merge-on-write |
+| `cc-switch/src-tauri/src/commands/hermes.rs` | 143 | Tauri IPC 命令(CLI 不需要,但逻辑可参考) |
+
+## MCP 格式映射
+
+| CC Switch 统一格式 (JSON) | Hermes config.yaml (YAML) |
+|---|---|
+| `{"type":"stdio","command":"npx","args":[...],"env":{}}` | `command: npx`, `args: [...]`, `env: {}` |
+| `{"type":"sse"/"http","url":"...","headers":{}}` | `url: "..."`, `headers: {}` |
+
+差异:
+- Hermes 无显式 `type` 字段
+- Hermes 有专有字段:`enabled`, `timeout`, `connect_timeout`, `tools`, `sampling`, `roots`, `auth`
+- 写入时保留 Hermes 专有字段(merge-on-write),导入时剥离
+
+## 开发方式
+
+在 cc-switch-tui 项目中开发时先检查 `git status`,不要假设工作区干净;若存在用户或其他 agent 的未提交改动,必须保留并在其基础上继续。
+
+- 远程:`git@github.com:handy-sun/cc-switch-tui.git`
+- 默认分支:`main`
+
+## 配置目录迁移规则(`~/.cc-switch` → 当前配置目录)
+
+启动入口在 `src-tauri/src/main.rs`。程序启动时会先调用 `prompt_legacy_config_migration()`,再初始化 `AppState`;真正复制逻辑在 `src-tauri/src/config.rs` 的 `migrate_legacy_config_dir_if_needed()`。
+
+迁移目标不是固定 `~/.cc-switch-tui`,而是“当前应用配置目录”:
+
+| 场景 | 迁移目标 | 行为 |
+|---|---|---|
+| 未设置配置目录环境变量 | `~/.cc-switch-tui` | 若旧目录存在且目标不存在/为空,启动前提示确认 |
+| 设置 `CC_SWITCH_TUI_CONFIG_DIR=~/.config/cc-switch-tui` | `~/.config/cc-switch-tui` | 若旧目录存在且目标不存在/为空,启动前提示确认并迁移到该目录 |
+| 设置旧变量 `CC_SWITCH_CONFIG_DIR` | 不迁移 | 兼容旧覆盖变量,避免把旧路径误当成迁移目标 |
+| 目标目录已有内容 | 不迁移 | 避免覆盖当前程序已有配置 |
+| 目标目录存在 `.migrated-from-cc-switch` marker | 不迁移 | 已迁移或用户拒绝迁移后不再提示 |
+
+确认提示目前是进入 TUI 前的终端提示,不是 TUI overlay。用户选择默认 `Y` 时,后续 `get_app_config_dir()` 触发非破坏性复制;旧目录保留。用户选择 `N` 时写入 `.migrated-from-cc-switch` marker,后续不再提示。
+
+迁移复制策略:
+- 跳过软链接
+- 普通文件直接复制
+- 普通目录递归复制
+- `backups/` 只复制最近 3 个条目
+
+相关测试集中在 `src-tauri/src/config.rs` 的 `config::tests::migration_*`,重点覆盖:
+- 默认迁移到 `~/.cc-switch-tui`
+- `CC_SWITCH_TUI_CONFIG_DIR` 作为迁移目标
+- 旧变量 `CC_SWITCH_CONFIG_DIR` 跳过迁移
+- 目标目录已有内容时跳过
+- marker 防止重复迁移
+- 旧目录保留
+- backups 只复制最近 3 个
+
+## Picker 架构(导航边界与 AppType 映射)
+
+三个 const 切片定义各 picker overlay 的 app 子集(`src-tauri/src/app_config.rs`):
+
+| Const | 用途 | 包含的 App |
+|---|---|---|
+| `MCP_PICKER_APPS` | MCP server toggle picker | Claude, Codex, Gemini, OpenCode, Hermes |
+| `VISIBLE_PICKER_APPS` | Settings "Visible Apps" picker | 全部 6 个 |
+| `SKILLS_PICKER_APPS` | Skills app toggle picker | Claude, Codex, Gemini, OpenCode, Hermes |
+
+Handler(`overlay_handlers/pickers.rs`)使用 `CONST.len() - 1` 作为导航上界,`CONST[*selected]` 做 index→AppType 映射。Render 函数(`ui/overlay/pickers.rs`)引用同一组 const。添加新 AppType 时只需更新 const 数组,无需修改 handler/render 逻辑。
+
+`AppType` 已 derive `Copy`,可按值使用。
+
+`four_app_picker_index()` 用于 MCP/Skills picker 初始光标定位,内部引用 `MCP_PICKER_APPS.len() - 1` 做 clamp。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd28e041..1ff5d7d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,104 @@
# Changelog
-All notable changes to CC Switch CLI will be documented in this file.
+All notable changes to CC Switch TUI will be documented in this file.
**Note:** This is a CLI fork of the original [CC-Switch](https://github.com/farion1231/cc-switch) project, maintained by [saladday](https://github.com/saladday).
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Changed
+
+- **Rename**: Project renamed from cc-switch-cli to cc-switch-tui. Binary, docs, CI artifacts,
+ and config directory (`~/.cc-switch/` → `~/.cc-switch-tui/`) all follow the new name.
+- **Config env var**: New `CC_SWITCH_TUI_CONFIG_DIR` replaces `CC_SWITCH_CONFIG_DIR`.
+ The old name still works but prints a deprecation warning.
+
+### Added
+
+- **Auto-migration**: On first run, old `~/.cc-switch/` data (db, settings, skills,
+ recent backups) is automatically copied to `~/.cc-switch-tui/`. Old directory is
+ left untouched. Migration is one-shot, guarded by a marker file, and non-blocking.
+
+## [5.5.1] - 2026-05-09
+
+### Changed
+
+- **Updater / Minisign**: Replace minisign public key with own keypair for self-hosted update signing.
+- **CI / Cross Target**: Ensure cross-compilation targets are installed on the rust-toolchain.toml stable toolchain.
+
+### Commits (since v5.5.0)
+
+- cc39894 chore(updater): replace minisign public key with own keypair
+- 70f6937 fix(ci): ensure cross target is installed on stable toolchain
+
+## [5.5.0] - 2026-05-09
+
+### Added
+
+- **Hermes Agent**: Full support as the 6th AppType in additive mode — provider config via YAML read/write (`~/.hermes/config.yaml`), TUI form with templates and JSON builder, CLI commands, MCP server sync, skills management, and AGENTS.md prompt file management.
+- **Prompts / CLI+TUI**: Add create and rename flows for prompt presets.
+- **TUI / Failover**: Add failover controls for proxy failover management.
+- **Nix / Flake**: Add Nix flake packaging with cargo-zigbuild cross-compilation via `nix develop`.
+- **Proxy / Anthropic**: Strip Anthropic billing header from OpenAI-compatible proxy prompts.
+- **Provider / Live Import**: Import live providers on startup from app config directories.
+- **TUI / Claude**: Add hide attribution toggle for Claude Code.
+
+### Changed
+
+- **CI**: Add multi-platform clippy and test matrix, merge rust-ci configs, use `feat/**` branch pattern for CI triggers.
+- **Picker / TUI**: Replace hardcoded picker bounds with const app arrays for the 6-app layout.
+
+### Fixed
+
+- **Proxy / Streaming**: Emit valid tool stream events without usage fields so downstream clients don't choke on malformed events.
+- **Proxy / Failover**: Fix failover status output formatting.
+- **Codex / Auth**: Persist official temp auth snapshots across restarts.
+- **Codex / History**: Keep conversation history stable across provider switches.
+- **Claude / Config**: Respect `CLAUDE_CONFIG_DIR` env var for Claude Code config discovery.
+- **Test**: Harden flaky env-guard concurrency tests and fix db-vs-memory assertions in import tests.
+
+### Commits (since v5.4.0)
+
+- 2cdd94d refactor(picker): replace hardcoded bounds with const app arrays
+- b888738 fix(hermes): fix TUI picker navigation bounds for 6-app layout
+- 353f040 feat(hermes): wire Hermes into all hardcoded app iteration lists
+- 49e0e7b feat(hermes): add enabled_hermes column to skills DAO queries
+- a928194 feat(hermes): implement Tier 2 TUI form, templates, JSON builder, and CLI commands
+- b344f84 feat(hermes): make Hermes visible by default in app switcher
+- 9e07c70 fix(test): check db instead of in-memory config after openclaw import
+- e78a32b fix(test): harden flaky env-guard tests and mark as ignored
+- a378b25 Merge branch 'feat/hermes-agent'
+- 4697d0c ci: merge rust-ci configs, add unsafe blocks, and simplify test step
+- 39c4021 feat: add cargo-zigbuild cross-compilation via nix develop
+- 6980e73 fix tests: concurrency, Windows, and flaky test improvements
+- 62e5509 fix(i18n): update help text assertion to match actual prompt format
+- 7b3c8d8 ci: remove -D warnings from clippy to avoid blocking on existing warnings
+- 3cab839 ci: add clippy and test jobs with multi-platform matrix
+- 1a05dae fix(hermes): add hermes field to test VisibleApps/SkillApps struct literals
+- 4d730e5 feat(hermes): implement Tier 2 config module and service wiring
+- f1f6a37 fix(ci): use feat/** branch pattern to match slash in branch names
+- 27e4745 chore(ci): move rustfmt check from CI to pre-commit hook
+- 5976886 add rust-ci
+- e70d2c9 feat(app-config): wire AppType::Hermes through remaining CLI, TUI, and service match arms
+- 0ef8192 feat(app-config): wire AppType::Hermes through provider, config, and CLI match arms
+- 4b5d472 feat(app-config): add AppType::Hermes variant and wire through core types
+- 84495e3 fix(proxy): fix failover status output (#144)
+- 6aebff3 feat: add Nix flake packaging (#156)
+- 103f341 fix(codex): persist official temp auth snapshots (#159)
+- f2daf4e feat(prompts): add create and rename flows for CLI and TUI (#160)
+- c0f5cb5 feat(tui): add failover controls (#155)
+- 54ae40e style(config): fix cargo fmt formatting
+- 0f8c638 ci(workflow): trigger CI on fork PRs via pull_request_target
+- 27a1c12 feat: respect CLAUDE_CONFIG_DIR env var for Claude Code (#152)
+- 5a809aa feat: import live providers on startup
+- 8018bba feat(tui): add Claude hide attribution toggle
+- 49b7142 feat(proxy): strip Anthropic billing header from OpenAI prompts (#149)
+- bccd85a fix(codex): keep history stable across provider switches
+- ca1a76b fix(proxy): emit valid tool stream events without usage (#146)
+
## [5.4.0] - 2026-04-29
### Added
@@ -587,7 +679,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Providers (TUI)**: Add sponsor provider presets (PackyCode) for Claude/Codex/Gemini in the "Add Provider" form.
-- **Docs**: Add PackyCode sponsor section to README (EN/ZH), including website, registration link, and promo code `cc-switch-cli` (10% off).
+- **Docs**: Add PackyCode sponsor section to README (EN/ZH), including website, registration link, and promo code `cc-switch-tui` (10% off).
## [4.6.0] - 2026-02-05
diff --git a/README.md b/README.md
index 1679af8e..194ce1eb 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
-# CC-Switch CLI
+# CC-Switch TUI
-[](https://github.com/saladday/cc-switch-cli/releases)
-[](https://github.com/saladday/cc-switch-cli/releases)
+[](https://github.com/handy-sun/cc-switch-tui/releases)
+[](https://github.com/handy-sun/cc-switch-tui/releases)
[](https://www.rust-lang.org/)
[](LICENSE)
-
+
**Command-Line Management Tool for Claude Code, Codex, Gemini, OpenCode & OpenClaw**
@@ -21,64 +21,14 @@ English | [中文](README_ZH.md)
## 📖 About
-This project is a **CLI fork** of [CC-Switch](https://github.com/farion1231/cc-switch).
+This project is a **TUI fork** whose upstream repository is [SaladDay/cc-switch-cli](https://github.com/SaladDay/cc-switch-cli).
🔄 The WebDAV sync feature is fully compatible with the upstream project.
**Credits:** Original architecture and core functionality from [farion1231/cc-switch](https://github.com/farion1231/cc-switch)
-**Changelog:** [CHANGELOG.md](CHANGELOG.md)
-
----
-
-## ❤️ Sponsor
-
-
-
-
-
-
-
-
-
- Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more.
- PackyCode provides special discounts for our software users: register via this link and use promo code cc-switch-cli when recharging to get 10% off .
-
-
-
-
-
-
-
-
-
- Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for cc-switch-cli users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off !
-
-
-
-
-
-
-
-
-
- Thanks to RightCode for sponsoring this project! RightCode reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and supports quota rollovers—unused quota from one day can be carried over and used the next day.
- RightCode offers a special deal for CC-Switch CLI users: register via this link and get 25% bonus pay-as-you-go credits on every top-up!
-
-
-
-
-
-
-
-
-
- Thanks to DDS for sponsoring this project! DDS Hub is a reliable and high-performance Claude API proxy service. DDS Hub provides cost-effective domestic Claude direct acceleration services for both individual and enterprise users. We offer stable and low-latency Claude Max number pools, with full support for Claude Haiku, Opus, Sonnet and other flagship models. Invoices are available for recharges of 1000 RMB or more. Enterprise customers can also enjoy customized grouping and dedicated technical support services.
- Exclusive benefit for CC-Switch CLI users: register via this link and enjoy an extra 10% credit on your first recharge (please contact the group admin to claim after recharging)!
-
-
-
+**Changelog:** [docs/cc-switch-tui/CHANGELOG.md](docs/cc-switch-tui/CHANGELOG.md)
---
@@ -112,20 +62,20 @@ cc-switch
**Command-Line Mode**
```bash
-cc-switch provider list # List providers
-cc-switch provider switch
# Switch provider
-cc-switch provider export # Export a Claude provider to a standalone settings file
-cc-switch provider stream-check # Check provider stream health
-cc-switch config webdav show # Inspect WebDAV sync settings
-cc-switch env tools # Check local CLI tools
-cc-switch mcp sync # Sync MCP servers
-cc-switch proxy show # Inspect proxy routes and status
+cc-switch-tui provider list # List providers
+cc-switch-tui provider switch # Switch provider
+cc-switch-tui provider export # Export a Claude provider to a standalone settings file
+cc-switch-tui provider stream-check # Check provider stream health
+cc-switch-tui config webdav show # Inspect WebDAV sync settings
+cc-switch-tui env tools # Check local CLI tools
+cc-switch-tui mcp sync # Sync MCP servers
+cc-switch-tui proxy show # Inspect proxy routes and status
# Use the global `--app` flag to target specific applications:
-cc-switch --app claude provider list # Manage Claude providers
-cc-switch --app codex mcp sync # Sync Codex MCP servers
-cc-switch --app gemini prompts list # List Gemini prompts
-cc-switch --app openclaw provider list # Manage OpenClaw providers
+cc-switch-tui --app claude provider list # Manage Claude providers
+cc-switch-tui --app codex mcp sync # Sync Codex MCP servers
+cc-switch-tui --app gemini prompts list # List Gemini prompts
+cc-switch-tui --app openclaw provider list # Manage OpenClaw providers
# Supported apps: `claude` (default), `codex`, `gemini`, `opencode`, `openclaw`
```
@@ -141,10 +91,10 @@ See the "Features" section for full command list.
> Windows users: see Manual Installation below.
```bash
-curl -fsSL https://github.com/SaladDay/cc-switch-cli/releases/latest/download/install.sh | bash
+curl -fsSL https://github.com/handy-sun/cc-switch-tui/releases/latest/download/install.sh | bash
```
-This installs `cc-switch` to `~/.local/bin`. Set `CC_SWITCH_INSTALL_DIR` to change the target directory.
+This installs to `~/.local/bin`. Set `CC_SWITCH_INSTALL_DIR` to change the target directory.
- If the target already exists, the installer prompts in TTY and refuses to overwrite in non-interactive shells unless `CC_SWITCH_FORCE=1` is set.
- On Linux, set `CC_SWITCH_LINUX_LIBC=glibc` if you need the glibc build.
@@ -156,71 +106,93 @@ This installs `cc-switch` to `~/.local/bin`. Set `CC_SWITCH_INSTALL_DIR` to chan
```bash
# Download Universal Binary (recommended, supports Apple Silicon + Intel)
-curl -LO https://github.com/saladday/cc-switch-cli/releases/latest/download/cc-switch-cli-darwin-universal.tar.gz
+VERSION="$(curl -fsSL https://github.com/handy-sun/cc-switch-tui/releases/latest/download/latest.json | sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n 1)"
+curl -LO "https://github.com/handy-sun/cc-switch-tui/releases/download/${VERSION}/cc-switch-tui-${VERSION}-darwin-universal.tar.gz"
# Extract
-tar -xzf cc-switch-cli-darwin-universal.tar.gz
+tar -xzf "cc-switch-tui-${VERSION}-darwin-universal.tar.gz"
# Add execute permission
-chmod +x cc-switch
+chmod +x cc-switch-tui
# Move to PATH
-sudo mv cc-switch /usr/local/bin/
+sudo mv cc-switch-tui /usr/local/bin/
# If you encounter "cannot be verified" warning
-xattr -cr /usr/local/bin/cc-switch
+xattr -cr /usr/local/bin/cc-switch-tui
```
#### Linux (x64)
```bash
# Download
-curl -LO https://github.com/saladday/cc-switch-cli/releases/latest/download/cc-switch-cli-linux-x64-musl.tar.gz
+VERSION="$(curl -fsSL https://github.com/handy-sun/cc-switch-tui/releases/latest/download/latest.json | sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n 1)"
+curl -LO "https://github.com/handy-sun/cc-switch-tui/releases/download/${VERSION}/cc-switch-tui-${VERSION}-linux-x64-musl.tar.gz"
# Extract
-tar -xzf cc-switch-cli-linux-x64-musl.tar.gz
+tar -xzf "cc-switch-tui-${VERSION}-linux-x64-musl.tar.gz"
# Add execute permission
-chmod +x cc-switch
+chmod +x cc-switch-tui
# Move to PATH
-sudo mv cc-switch /usr/local/bin/
+sudo mv cc-switch-tui /usr/local/bin/
```
#### Linux (ARM64)
```bash
# For Raspberry Pi or ARM servers
-curl -LO https://github.com/saladday/cc-switch-cli/releases/latest/download/cc-switch-cli-linux-arm64-musl.tar.gz
-tar -xzf cc-switch-cli-linux-arm64-musl.tar.gz
-chmod +x cc-switch
-sudo mv cc-switch /usr/local/bin/
+VERSION="$(curl -fsSL https://github.com/handy-sun/cc-switch-tui/releases/latest/download/latest.json | sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n 1)"
+curl -LO "https://github.com/handy-sun/cc-switch-tui/releases/download/${VERSION}/cc-switch-tui-${VERSION}-linux-arm64-musl.tar.gz"
+tar -xzf "cc-switch-tui-${VERSION}-linux-arm64-musl.tar.gz"
+chmod +x cc-switch-tui
+sudo mv cc-switch-tui /usr/local/bin/
```
#### Windows
```powershell
# Download the zip file
-# https://github.com/saladday/cc-switch-cli/releases/latest/download/cc-switch-cli-windows-x64.zip
+# https://github.com/handy-sun/cc-switch-tui/releases/download/vX.Y.Z/cc-switch-tui-vX.Y.Z-windows-x64.zip
-# After extracting, move cc-switch.exe to a PATH directory, e.g.:
-move cc-switch.exe C:\Windows\System32\
+# After extracting, move cc-switch-tui.exe to a PATH directory, e.g.:
+move cc-switch-tui.exe C:\Windows\System32\
# Or run directly
-.\cc-switch.exe
+.\cc-switch-tui.exe
```
-### Method 2: Build from Source
+### Method 2: Nix Flake
+
+Use the package from this repository in another flake:
+
+```nix
+{
+ inputs.cc-switch-tui = {
+ url = "github:handy-sun/cc-switch-tui";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+}
+```
+
+`inputs.nixpkgs.follows = "nixpkgs"` is recommended for normal NixOS or Home Manager setups. The package is built with `pkgs.rustPlatform.buildRustPackage`, so following your top-level `nixpkgs` means the Rust compiler, Cargo, linker inputs, and system libraries come from the same nixpkgs revision as the rest of your system.
+
+If you omit `follows`, this project uses the nixpkgs revision pinned in its own `flake.lock`, which can be useful when you want to reproduce the upstream build environment exactly. In either case, Rust crate dependency versions still come from `src-tauri/Cargo.lock`; `follows` changes the Nix toolchain and package set, not the locked Cargo dependency graph.
+
+If a build only fails with `follows` enabled, try removing it to check whether the issue is caused by a nixpkgs version difference.
+
+### Method 3: Build from Source
**Prerequisites:**
-- Rust 1.85+ ([install via rustup](https://rustup.rs/))
+- Rust 1.94+ ([install via rustup](https://rustup.rs/))
**Build:**
```bash
-git clone https://github.com/saladday/cc-switch-cli.git
-cd cc-switch-cli/src-tauri
+git clone https://github.com/handy-sun/cc-switch-tui.git
+cd cc-switch-tui/src-tauri
cargo build --release
# Binary location: ./target/release/cc-switch
@@ -229,10 +201,10 @@ cargo build --release
**Install to System:**
```bash
# macOS/Linux
-sudo cp target/release/cc-switch /usr/local/bin/
+sudo cp target/release/cc-switch-tui /usr/local/bin/
# Windows
-copy target\release\cc-switch.exe C:\Windows\System32\
+copy target\release\cc-switch-tui.exe C:\Windows\System32\
```
---
@@ -246,18 +218,18 @@ Manage API configurations for **Claude Code**, **Codex**, **Gemini**, **OpenCode
**Features:** One-click switching, standalone Claude settings export, multi-endpoint support, API key management, remote model discovery, and per-app diagnostics such as speed testing or stream health checks where supported.
```bash
-cc-switch provider list # List all providers
-cc-switch provider current # Show current provider
-cc-switch provider switch # Switch provider
-cc-switch provider add # Add new provider
-cc-switch provider edit # Edit existing provider
-cc-switch provider duplicate # Duplicate a provider
-cc-switch provider delete # Delete provider
-cc-switch provider export # Export to ./.claude/settings.local.json for Claude auto-load
-cc-switch provider speedtest # Test API latency
-cc-switch provider stream-check # Run stream health check
-cc-switch provider fetch-models # Fetch remote model list
-cc-switch provider export --output ~/.claude/settings-demo.json # Custom settings file path
+cc-switch-tui provider list # List all providers
+cc-switch-tui provider current # Show current provider
+cc-switch-tui provider switch # Switch provider
+cc-switch-tui provider add # Add new provider
+cc-switch-tui provider edit # Edit existing provider
+cc-switch-tui provider duplicate # Duplicate a provider
+cc-switch-tui provider delete # Delete provider
+cc-switch-tui provider export # Export to ./.claude/settings.local.json for Claude auto-load
+cc-switch-tui provider speedtest # Test API latency
+cc-switch-tui provider stream-check # Run stream health check
+cc-switch-tui provider fetch-models # Fetch remote model list
+cc-switch-tui provider export --output ~/.claude/settings-demo.json # Custom settings file path
```
### 🛠️ MCP Server Management
@@ -267,15 +239,15 @@ Manage Model Context Protocol servers across Claude, Codex, Gemini, and OpenCode
**Features:** Unified management, multi-app support, three transport types (stdio/http/sse), automatic sync, and live-config adapters for TOML and JSON targets.
```bash
-cc-switch mcp list # List all MCP servers
-cc-switch mcp add # Add new MCP server (interactive)
-cc-switch mcp edit # Edit MCP server
-cc-switch mcp delete # Delete MCP server
-cc-switch mcp enable --app claude # Enable for specific app
-cc-switch mcp disable --app claude # Disable for specific app
-cc-switch mcp validate # Validate command in PATH
-cc-switch mcp sync # Sync to live files
-cc-switch mcp import --app claude # Import from live config
+cc-switch-tui mcp list # List all MCP servers
+cc-switch-tui mcp add # Add new MCP server (interactive)
+cc-switch-tui mcp edit # Edit MCP server
+cc-switch-tui mcp delete # Delete MCP server
+cc-switch-tui mcp enable --app claude # Enable for specific app
+cc-switch-tui mcp disable --app claude # Disable for specific app
+cc-switch-tui mcp validate # Validate command in PATH
+cc-switch-tui mcp sync # Sync to live files
+cc-switch-tui mcp import --app claude # Import from live config
```
### 💬 Prompts Management
@@ -285,15 +257,15 @@ Manage system prompt presets for AI coding assistants.
**Cross-app support:** Claude (`CLAUDE.md`), Codex (`AGENTS.md`), Gemini (`GEMINI.md`), OpenCode (`AGENTS.md`), OpenClaw (`AGENTS.md`).
```bash
-cc-switch prompts list # List prompt presets
-cc-switch prompts current # Show current active prompt
-cc-switch prompts activate # Activate prompt
-cc-switch prompts deactivate # Deactivate current active prompt
-cc-switch prompts create [name] # Create a prompt preset, optionally naming it up front
-cc-switch prompts rename [name] # Rename prompt preset, interactive if name is omitted
-cc-switch prompts edit # Edit prompt preset
-cc-switch prompts show # Display full content
-cc-switch prompts delete # Delete prompt
+cc-switch-tui prompts list # List prompt presets
+cc-switch-tui prompts current # Show current active prompt
+cc-switch-tui prompts activate # Activate prompt
+cc-switch-tui prompts deactivate # Deactivate current active prompt
+cc-switch-tui prompts create [name] # Create a prompt preset, optionally naming it up front
+cc-switch-tui prompts rename [name] # Rename prompt preset, interactive if name is omitted
+cc-switch-tui prompts edit # Edit prompt preset
+cc-switch-tui prompts show # Display full content
+cc-switch-tui prompts delete # Delete prompt
```
### 🎯 Skills Management
@@ -303,22 +275,22 @@ Manage and extend Claude Code/Codex/Gemini/OpenCode capabilities with community
**Features:** SSOT-based skills store, multi-app enable/disable, sync to app directories, unmanaged scan/import, repo discovery.
```bash
-cc-switch skills list # List installed skills
-cc-switch skills discover # Discover available skills (alias: search)
-cc-switch skills install # Install a skill
-cc-switch skills uninstall # Uninstall a skill
-cc-switch skills enable # Enable for current app (--app)
-cc-switch skills disable # Disable for current app (--app)
-cc-switch skills info # Show skill information
-cc-switch skills sync # Sync enabled skills to app dirs
-cc-switch skills sync-method [m] # Show/set sync method (auto|symlink|copy)
-cc-switch skills scan-unmanaged # Scan unmanaged skills in app dirs
-cc-switch skills import-from-apps # Import unmanaged skills into SSOT
-cc-switch skills repos list # List skill repositories
-cc-switch skills repos add # Add repo (owner/name[@branch] or GitHub URL)
-cc-switch skills repos remove # Remove repo (owner/name or GitHub URL)
-cc-switch skills repos enable # Enable repo without changing branch
-cc-switch skills repos disable # Disable repo without changing branch
+cc-switch-tui skills list # List installed skills
+cc-switch-tui skills discover # Discover available skills (alias: search)
+cc-switch-tui skills install # Install a skill
+cc-switch-tui skills uninstall # Uninstall a skill
+cc-switch-tui skills enable # Enable for current app (--app)
+cc-switch-tui skills disable # Disable for current app (--app)
+cc-switch-tui skills info # Show skill information
+cc-switch-tui skills sync # Sync enabled skills to app dirs
+cc-switch-tui skills sync-method [m] # Show/set sync method (auto|symlink|copy)
+cc-switch-tui skills scan-unmanaged # Scan unmanaged skills in app dirs
+cc-switch-tui skills import-from-apps # Import unmanaged skills into SSOT
+cc-switch-tui skills repos list # List skill repositories
+cc-switch-tui skills repos add # Add repo (owner/name[@branch] or GitHub URL)
+cc-switch-tui skills repos remove # Remove repo (owner/name or GitHub URL)
+cc-switch-tui skills repos enable # Enable repo without changing branch
+cc-switch-tui skills repos disable # Disable repo without changing branch
```
### ⚙️ Configuration Management
@@ -328,39 +300,39 @@ Manage configuration backups, imports, and exports.
**Features:** Custom backup naming, interactive backup selection, automatic rotation (keep 10), import/export, common snippets, WebDAV sync.
```bash
-cc-switch config show # Display configuration
-cc-switch config path # Show config file paths
-cc-switch config validate # Validate config file
+cc-switch-tui config show # Display configuration
+cc-switch-tui config path # Show config file paths
+cc-switch-tui config validate # Validate config file
# Common snippet (shared settings across providers)
# Tries to refresh live config when applicable (`--apply` is kept only as a compatibility flag)
-cc-switch --app claude config common show
-cc-switch --app claude config common set --snippet '{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}'
-cc-switch --app claude config common clear
+cc-switch-tui --app claude config common show
+cc-switch-tui --app claude config common set --snippet '{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}'
+cc-switch-tui --app claude config common clear
# Backup
-cc-switch config backup # Create backup (auto-named)
-cc-switch config backup --name my-backup # Create backup with custom name
+cc-switch-tui config backup # Create backup (auto-named)
+cc-switch-tui config backup --name my-backup # Create backup with custom name
# Restore
-cc-switch config restore # Interactive: select from backup list
-cc-switch config restore --backup # Restore specific backup by ID
-cc-switch config restore --file # Restore from external file
+cc-switch-tui config restore # Interactive: select from backup list
+cc-switch-tui config restore --backup # Restore specific backup by ID
+cc-switch-tui config restore --file # Restore from external file
# Import/Export
-cc-switch config export # Export to external file
-cc-switch config import # Import from external file
+cc-switch-tui config export # Export to external file
+cc-switch-tui config import # Import from external file
# WebDAV sync
-cc-switch config webdav show
-cc-switch config webdav set --base-url --username --password --enable
-cc-switch config webdav jianguoyun --username --password
-cc-switch config webdav check-connection
-cc-switch config webdav upload
-cc-switch config webdav download
-cc-switch config webdav migrate-v1-to-v2
-
-cc-switch config reset # Reset to default configuration
+cc-switch-tui config webdav show
+cc-switch-tui config webdav set --base-url --username --password --enable
+cc-switch-tui config webdav jianguoyun --username --password
+cc-switch-tui config webdav check-connection
+cc-switch-tui config webdav upload
+cc-switch-tui config webdav download
+cc-switch-tui config webdav migrate-v1-to-v2
+
+cc-switch-tui config reset # Reset to default configuration
```
### 🌉 Proxy Management
@@ -370,10 +342,10 @@ Inspect and control the local multi-app proxy used by supported apps.
**Features:** Persisted enable/disable switch, current route inspection, dashboard telemetry, and foreground serve mode for debugging.
```bash
-cc-switch proxy show # Show proxy configuration and routes
-cc-switch proxy enable # Enable the persisted proxy switch
-cc-switch proxy disable # Disable the persisted proxy switch
-cc-switch proxy serve # Run the proxy in foreground
+cc-switch-tui proxy show # Show proxy configuration and routes
+cc-switch-tui proxy enable # Enable the persisted proxy switch
+cc-switch-tui proxy disable # Disable the persisted proxy switch
+cc-switch-tui proxy serve # Run the proxy in foreground
```
### 🧪 Environment & Local Tools
@@ -381,9 +353,9 @@ cc-switch proxy serve # Run the proxy in foreground
Inspect environment conflicts and whether required local CLIs are installed.
```bash
-cc-switch env check # Check environment conflicts
-cc-switch env list # List relevant environment variables
-cc-switch env tools # Check Claude/Codex/Gemini/OpenCode CLIs
+cc-switch-tui env check # Check environment conflicts
+cc-switch-tui env list # List relevant environment variables
+cc-switch-tui env tools # Check Claude/Codex/Gemini/OpenCode CLIs
```
### 🌐 Multi-language Support
@@ -399,23 +371,23 @@ Shell completions, environment management, and other utilities.
```bash
# Shell completions
-cc-switch completions install --activate # Recommended: install + activate for bash/zsh
-cc-switch completions install # Conservative: install only, no rc edits
-cc-switch completions status # Inspect managed completion status
-cc-switch completions uninstall # Remove managed completion assets
-cc-switch completions bash # Compatibility raw generator path
-cc-switch completions fish # Raw generation still works for non-managed shells
+cc-switch-tui completions install --activate # Recommended: install + activate for bash/zsh
+cc-switch-tui completions install # Conservative: install only, no rc edits
+cc-switch-tui completions status # Inspect managed completion status
+cc-switch-tui completions uninstall # Remove managed completion assets
+cc-switch-tui completions bash # Compatibility raw generator path
+cc-switch-tui completions fish # Raw generation still works for non-managed shells
# Environment management
-cc-switch env check # Check for environment conflicts
-cc-switch env list # List environment variables
+cc-switch-tui env check # Check for environment conflicts
+cc-switch-tui env list # List environment variables
# Self-update
-cc-switch update # Update to latest release
-cc-switch update --version vX.Y.Z # Update to a specific version
+cc-switch-tui update # Update to latest release
+cc-switch-tui update --version vX.Y.Z # Update to a specific version
```
-Automated install/activation currently targets `bash` and `zsh` only. Other shells remain available through the raw generator path, for example `cc-switch completions fish`.
+Automated install/activation currently targets `bash` and `zsh` only. Other shells remain available through the raw generator path, for example `cc-switch-tui completions fish`.
---
@@ -423,8 +395,8 @@ Automated install/activation currently targets `bash` and `zsh` only. Other shel
### Core Design
-- **SQLite-backed state**: Core data lives in `~/.cc-switch/cc-switch.db` by default (or under `$CC_SWITCH_CONFIG_DIR/` when set); legacy `config.json` is kept only for older import and migration paths
-- **Skills SSOT**: Skill source files live in `~/.cc-switch/skills/` by default (or under `$CC_SWITCH_CONFIG_DIR/skills/` when set), while install state and app enablement stay in the database
+- **SQLite-backed state**: Core data lives in `~/.cc-switch-tui/cc-switch.db` by default (or under `$CC_SWITCH_TUI_CONFIG_DIR/` when set); legacy `config.json` is kept only for older import and migration paths
+- **Skills SSOT**: Skill source files live in `~/.cc-switch-tui/skills/` by default (or under `$CC_SWITCH_TUI_CONFIG_DIR/skills/` when set), while install state and app enablement stay in the database
- **Safe Live Sync (Default)**: Skip writing live files for apps that haven't been initialized yet (prevents creating `~/.claude`, `~/.codex`, `~/.gemini`, `~/.config/opencode`, or `~/.openclaw` unexpectedly)
- **Atomic Writes**: Temp file + rename pattern prevents corruption
- **Service Layer Reuse**: 100% reused from original GUI version
@@ -432,14 +404,14 @@ Automated install/activation currently targets `bash` and `zsh` only. Other shel
### Configuration Files
-**CC-Switch Storage** (default: `~/.cc-switch`, override: `CC_SWITCH_CONFIG_DIR`):
-- `~/.cc-switch/cc-switch.db` - Main database for providers, MCP, prompts, and app state
-- `~/.cc-switch/settings.json` - Settings
-- `~/.cc-switch/skills/` - Installed skill sources (SSOT)
-- `~/.cc-switch/backups/` - Auto-rotation (keep 10)
-- `~/.cc-switch/config.json` - Legacy JSON kept for compatibility and import flows
+**CC-Switch Storage** (default: `~/.cc-switch-tui`, override: `CC_SWITCH_TUI_CONFIG_DIR`):
+- `~/.cc-switch-tui/cc-switch.db` - Main database for providers, MCP, prompts, and app state
+- `~/.cc-switch-tui/settings.json` - Settings
+- `~/.cc-switch-tui/skills/` - Installed skill sources (SSOT)
+- `~/.cc-switch-tui/backups/` - Auto-rotation (keep 10)
+- `~/.cc-switch-tui/config.json` - Legacy JSON kept for compatibility and import flows
-When `CC_SWITCH_CONFIG_DIR` is set, CC-Switch uses that directory as its config root; existing data under `~/.cc-switch` is not migrated automatically.
+When `CC_SWITCH_TUI_CONFIG_DIR` is set, CC-Switch uses that directory as its config root; existing data under `~/.cc-switch-tui` is not migrated automatically.
**Live Configs:**
- Claude: `~/.claude/settings.json` (provider/common config), `~/.claude.json` (MCP), `~/.claude/CLAUDE.md` (prompts)
@@ -465,12 +437,12 @@ This is usually caused by **environment variable conflicts**. If you have API ke
1. Check for conflicts:
```bash
- cc-switch env check --app claude
+ cc-switch-tui env check --app claude
```
2. List all related environment variables:
```bash
- cc-switch env list --app claude
+ cc-switch-tui env list --app claude
```
3. If conflicts are found, manually remove them:
@@ -500,7 +472,7 @@ CC-Switch currently supports five AI coding assistants:
Use the global `--app` flag to specify which app to manage:
```bash
-cc-switch --app codex provider list
+cc-switch-tui --app codex provider list
```
@@ -510,7 +482,7 @@ cc-switch --app codex provider list
-Please open an issue on our [GitHub Issues](https://github.com/saladday/cc-switch-cli/issues) page with:
+Please open an issue on our [GitHub Issues](https://github.com/handy-sun/cc-switch-tui/issues) page with:
- Detailed description of the problem or feature request
- Steps to reproduce (for bugs)
- Your system information (OS, version)
@@ -524,7 +496,7 @@ Please open an issue on our [GitHub Issues](https://github.com/saladday/cc-switc
### Requirements
-- **Rust**: 1.85+ ([rustup](https://rustup.rs/))
+- **Rust**: 1.94+ ([rustup](https://rustup.rs/))
- **Cargo**: Bundled with Rust
### Commands
diff --git a/README_ZH.md b/README_ZH.md
index 8e5803fc..f70f1391 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -1,9 +1,9 @@
-# CC-Switch CLI
+# CC-Switch TUI
-[](https://github.com/saladday/cc-switch-cli/releases)
-[](https://github.com/saladday/cc-switch-cli/releases)
+[](https://github.com/handy-sun/cc-switch-tui/releases)
+[](https://github.com/handy-sun/cc-switch-tui/releases)
[](https://www.rust-lang.org/)
[](LICENSE)
@@ -19,67 +19,12 @@
## 📖 关于本项目
-本项目是原版 [CC-Switch](https://github.com/farion1231/cc-switch) 的 **CLI 分支**。🔄 WebDAV 同步功能与上游项目完全兼容。
+本项目是以上游仓库 [SaladDay/cc-switch-cli](https://github.com/SaladDay/cc-switch-cli) 为基础的 **TUI 分支**。🔄 WebDAV 同步功能与上游项目完全兼容。
**致谢:** 原始架构和核心功能来自 [farion1231/cc-switch](https://github.com/farion1231/cc-switch)
-**更新日志:** [CHANGELOG.md](CHANGELOG.md)
-
----
-
-## ❤️赞助商
-
-
-
-
-
-
-
-
-
- 感谢 PackyCode 赞助本项目!
- 官网:https://www.packyapi.com
- CC-Switch CLI 专属优惠:通过
- 此链接
- 注册,并在充值时填写优惠码 cc-switch-cli,即可享受 9 折优惠 。
-
-
-
-
-
-
-
-
-
- 感谢 AICodeMirror 赞助本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级并发、快速开票与 7x24 专属技术支持。Claude Code / Codex / Gemini 官方通道价格低至原价的 38% / 2% / 9% ,充值另有折上折!
- AICodeMirror 为 cc-switch-cli 用户提供专属福利:通过此链接 注册,首充可享 8 折 ,即 20% off ,企业客户最高可享 75 折 ,即 25% off 。
-
-
-
-
-
-
-
-
-
- 感谢 RightCode 赞助本项目!
- RightCode 为 Claude Code、Codex、Gemini 等模型提供稳定的路由服务,拥有高性价比的 Codex 月付方案,且支持额度滚存——当天未用完的额度可顺延至次日使用。
- RightCode 为 CC-Switch CLI 用户提供了特别优惠:通过此链接 注册,每次充值均可获得实付金额 25% 的按量额度!
-
-
-
-
-
-
-
-
-
- 感谢 DDS 赞助本项目!呆呆兽是一家专注 Claude 的可靠高效 API 中转站,为个人和企业用户提供极具性价比的国内 Claude 直连加速服务。支持 Claude Haiku / Opus / Sonnet 等满血模型 。充值满 1000 元即可开具发票,企业客户更可享受定制化分组和技术支持服务。
- CC-Switch CLI 用户专属福利:通过此链接 注册后,首单充值可额外赠送 10% 额度 (充值后请联系群主领取)!
-
-
-
+**更新日志:** [docs/cc-switch-tui/CHANGELOG.md](docs/cc-switch-tui/CHANGELOG.md)
---
@@ -142,7 +87,7 @@ cc-switch --app openclaw provider list # 管理 OpenClaw 供应商
> Windows 用户请参考下方手动安装。
```bash
-curl -fsSL https://github.com/SaladDay/cc-switch-cli/releases/latest/download/install.sh | bash
+curl -fsSL https://github.com/handy-sun/cc-switch-tui/releases/latest/download/install.sh | bash
```
默认安装到 `~/.local/bin`。设置 `CC_SWITCH_INSTALL_DIR` 可自定义安装目录。
@@ -157,71 +102,93 @@ curl -fsSL https://github.com/SaladDay/cc-switch-cli/releases/latest/download/in
```bash
# 下载 Universal Binary(推荐,支持 Apple Silicon + Intel)
-curl -LO https://github.com/saladday/cc-switch-cli/releases/latest/download/cc-switch-cli-darwin-universal.tar.gz
+VERSION="$(curl -fsSL https://github.com/handy-sun/cc-switch-tui/releases/latest/download/latest.json | sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n 1)"
+curl -LO "https://github.com/handy-sun/cc-switch-tui/releases/download/${VERSION}/cc-switch-tui-${VERSION}-darwin-universal.tar.gz"
# 解压
-tar -xzf cc-switch-cli-darwin-universal.tar.gz
+tar -xzf "cc-switch-tui-${VERSION}-darwin-universal.tar.gz"
# 添加执行权限
-chmod +x cc-switch
+chmod +x cc-switch-tui
# 移动到 PATH
-sudo mv cc-switch /usr/local/bin/
+sudo mv cc-switch-tui /usr/local/bin/
# 如遇 "无法验证开发者" 提示
-xattr -cr /usr/local/bin/cc-switch
+xattr -cr /usr/local/bin/cc-switch-tui
```
#### Linux (x64)
```bash
# 下载
-curl -LO https://github.com/saladday/cc-switch-cli/releases/latest/download/cc-switch-cli-linux-x64-musl.tar.gz
+VERSION="$(curl -fsSL https://github.com/handy-sun/cc-switch-tui/releases/latest/download/latest.json | sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n 1)"
+curl -LO "https://github.com/handy-sun/cc-switch-tui/releases/download/${VERSION}/cc-switch-tui-${VERSION}-linux-x64-musl.tar.gz"
# 解压
-tar -xzf cc-switch-cli-linux-x64-musl.tar.gz
+tar -xzf "cc-switch-tui-${VERSION}-linux-x64-musl.tar.gz"
# 添加执行权限
-chmod +x cc-switch
+chmod +x cc-switch-tui
# 移动到 PATH
-sudo mv cc-switch /usr/local/bin/
+sudo mv cc-switch-tui /usr/local/bin/
```
#### Linux (ARM64)
```bash
# 适用于树莓派或 ARM 服务器
-curl -LO https://github.com/saladday/cc-switch-cli/releases/latest/download/cc-switch-cli-linux-arm64-musl.tar.gz
-tar -xzf cc-switch-cli-linux-arm64-musl.tar.gz
-chmod +x cc-switch
-sudo mv cc-switch /usr/local/bin/
+VERSION="$(curl -fsSL https://github.com/handy-sun/cc-switch-tui/releases/latest/download/latest.json | sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' | head -n 1)"
+curl -LO "https://github.com/handy-sun/cc-switch-tui/releases/download/${VERSION}/cc-switch-tui-${VERSION}-linux-arm64-musl.tar.gz"
+tar -xzf "cc-switch-tui-${VERSION}-linux-arm64-musl.tar.gz"
+chmod +x cc-switch-tui
+sudo mv cc-switch-tui /usr/local/bin/
```
#### Windows
```powershell
# 下载 zip 文件
-# https://github.com/saladday/cc-switch-cli/releases/latest/download/cc-switch-cli-windows-x64.zip
+# https://github.com/handy-sun/cc-switch-tui/releases/download/vX.Y.Z/cc-switch-tui-vX.Y.Z-windows-x64.zip
-# 解压后将 cc-switch.exe 移动到 PATH 目录,例如:
-move cc-switch.exe C:\Windows\System32\
+# 解压后将 cc-switch-tui.exe 移动到 PATH 目录,例如:
+move cc-switch-tui.exe C:\Windows\System32\
# 或者直接运行
-.\cc-switch.exe
+.\cc-switch-tui.exe
```
-### 方法 2:从源码构建
+### 方法 2:Nix Flake
+
+在其他 flake 中使用本仓库提供的包:
+
+```nix
+{
+ inputs.cc-switch-tui = {
+ url = "github:handy-sun/cc-switch-tui";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+}
+```
+
+普通 NixOS 或 Home Manager 配置建议加上 `inputs.nixpkgs.follows = "nixpkgs"`。本仓库的包通过 `pkgs.rustPlatform.buildRustPackage` 构建,因此跟随顶层 `nixpkgs` 后,Rust 编译器、Cargo、链接输入和系统库都会来自你系统配置里的同一份 nixpkgs。
+
+如果不加 `follows`,cc-switch-tui 会使用本仓库 `flake.lock` 锁定的 nixpkgs,这适合需要尽量复现上游构建环境的场景。无论是否加 `follows`,Rust crate 依赖版本仍由 `src-tauri/Cargo.lock` 锁定;`follows` 改变的是 Nix 工具链和包集合,不会改 Cargo 依赖图。
+
+如果只有加了 `follows` 才构建失败,可以先去掉它验证问题是否来自 nixpkgs 版本差异。
+
+### 方法 3:从源码构建
**前提条件:**
-- Rust 1.85+([通过 rustup 安装](https://rustup.rs/))
+- Rust 1.94+([通过 rustup 安装](https://rustup.rs/))
**构建:**
```bash
-git clone https://github.com/saladday/cc-switch-cli.git
-cd cc-switch-cli/src-tauri
+git clone https://github.com/handy-sun/cc-switch-tui.git
+cd cc-switch-tui/src-tauri
cargo build --release
# 二进制位置:./target/release/cc-switch
@@ -230,10 +197,10 @@ cargo build --release
**安装到系统:**
```bash
# macOS/Linux
-sudo cp target/release/cc-switch /usr/local/bin/
+sudo cp target/release/cc-switch-tui /usr/local/bin/
# Windows
-copy target\release\cc-switch.exe C:\Windows\System32\
+copy target\release\cc-switch-tui.exe C:\Windows\System32\
```
---
@@ -400,23 +367,23 @@ Shell 补全、环境管理等实用功能。
```bash
# Shell 补全
-cc-switch completions install --activate # 推荐:为 bash/zsh 安装并激活
-cc-switch completions install # 保守模式:只安装,不改 rc
-cc-switch completions status # 查看受管补全状态
-cc-switch completions uninstall # 移除受管补全文件和激活块
-cc-switch completions bash # 兼容保留的 raw generator 路径
-cc-switch completions fish # 其他 shell 继续走 raw generate
+cc-switch-tui completions install --activate # 推荐:为 bash/zsh 安装并激活
+cc-switch-tui completions install # 保守模式:只安装,不改 rc
+cc-switch-tui completions status # 查看受管补全状态
+cc-switch-tui completions uninstall # 移除受管补全文件和激活块
+cc-switch-tui completions bash # 兼容保留的 raw generator 路径
+cc-switch-tui completions fish # 其他 shell 继续走 raw generate
# 环境管理
cc-switch env check # 检查环境冲突
cc-switch env list # 列出环境变量
# 自更新
-cc-switch update # 更新到最新版本
-cc-switch update --version vX.Y.Z # 更新到指定版本
+cc-switch-tui update # 更新到最新版本
+cc-switch-tui update --version vX.Y.Z # 更新到指定版本
```
-自动安装 / 激活当前只支持 `bash` 和 `zsh`。其他 shell 仍然可以通过 raw generator 路径使用,例如 `cc-switch completions fish`。
+自动安装 / 激活当前只支持 `bash` 和 `zsh`。其他 shell 仍然可以通过 raw generator 路径使用,例如 `cc-switch-tui completions fish`。
---
@@ -424,8 +391,8 @@ cc-switch update --version vX.Y.Z # 更新到指定版本
### 核心设计
-- **SQLite 持久化**:核心数据默认存放在 `~/.cc-switch/cc-switch.db`(若设置 `CC_SWITCH_CONFIG_DIR` 则改为该目录下);旧版 `config.json` 仅保留给兼容与迁移路径使用
-- **Skills SSOT**:技能源文件默认保存在 `~/.cc-switch/skills/`(若设置 `CC_SWITCH_CONFIG_DIR` 则改为 `$CC_SWITCH_CONFIG_DIR/skills/`),安装状态和启用状态由数据库统一记录
+- **SQLite 持久化**:核心数据默认存放在 `~/.cc-switch-tui/cc-switch.db`(若设置 `CC_SWITCH_TUI_CONFIG_DIR` 则改为该目录下);旧版 `config.json` 仅保留给兼容与迁移路径使用
+- **Skills SSOT**:技能源文件默认保存在 `~/.cc-switch-tui/skills/`(若设置 `CC_SWITCH_TUI_CONFIG_DIR` 则改为 `$CC_SWITCH_TUI_CONFIG_DIR/skills/`),安装状态和启用状态由数据库统一记录
- **安全 Live 同步(默认)**:若目标应用尚未初始化,将跳过写入 live 文件(避免意外创建 `~/.claude`、`~/.codex`、`~/.gemini`、`~/.config/opencode` 或 `~/.openclaw`)
- **原子写入**:临时文件 + 重命名模式防止损坏
- **服务层复用**:100% 复用原 GUI 版本
@@ -433,14 +400,14 @@ cc-switch update --version vX.Y.Z # 更新到指定版本
### 配置文件
-**CC-Switch 存储**(默认:`~/.cc-switch`,可用 `CC_SWITCH_CONFIG_DIR` 覆盖):
-- `~/.cc-switch/cc-switch.db` - 供应商、MCP、提示词和应用状态的主数据库
-- `~/.cc-switch/settings.json` - 设置
-- `~/.cc-switch/skills/` - 已安装技能源码(SSOT)
-- `~/.cc-switch/backups/` - 自动轮换(保留 10 个)
-- `~/.cc-switch/config.json` - 为兼容与导入流程保留的旧版 JSON
+**CC-Switch 存储**(默认:`~/.cc-switch`,可用 `CC_SWITCH_TUI_CONFIG_DIR` 覆盖):
+- `~/.cc-switch-tui/cc-switch.db` - 供应商、MCP、提示词和应用状态的主数据库
+- `~/.cc-switch-tui/settings.json` - 设置
+- `~/.cc-switch-tui/skills/` - 已安装技能源码(SSOT)
+- `~/.cc-switch-tui/backups/` - 自动轮换(保留 10 个)
+- `~/.cc-switch-tui/config.json` - 为兼容与导入流程保留的旧版 JSON
-设置 `CC_SWITCH_CONFIG_DIR` 后,CC-Switch 会改用该目录作为配置根目录;这不会自动迁移 `~/.cc-switch` 中的现有数据。
+设置 `CC_SWITCH_TUI_CONFIG_DIR` 后,CC-Switch 会改用该目录作为配置根目录;这不会自动迁移 `~/.cc-switch` 中的现有数据。
**实时配置:**
- Claude: `~/.claude/settings.json`(供应商 / 通用配置), `~/.claude.json`(MCP), `~/.claude/CLAUDE.md`(提示词)
@@ -511,7 +478,7 @@ cc-switch --app codex provider list
-请在我们的 [GitHub Issues](https://github.com/saladday/cc-switch-cli/issues) 页面提交问题,并包含:
+请在我们的 [GitHub Issues](https://github.com/handy-sun/cc-switch-tui/issues) 页面提交问题,并包含:
- 问题或功能请求的详细描述
- 复现步骤(针对 bug)
- 你的系统信息(操作系统、版本)
@@ -525,7 +492,7 @@ cc-switch --app codex provider list
### 环境要求
-- **Rust**:1.85+([rustup](https://rustup.rs/))
+- **Rust**:1.94+([rustup](https://rustup.rs/))
- **Cargo**:与 Rust 捆绑
### 开发命令
diff --git a/assets/partners/banners/glm-en.jpg b/assets/partners/banners/glm-en.jpg
deleted file mode 100644
index 479b3e8a..00000000
Binary files a/assets/partners/banners/glm-en.jpg and /dev/null differ
diff --git a/assets/partners/banners/glm-zh.jpg b/assets/partners/banners/glm-zh.jpg
deleted file mode 100644
index db318bf2..00000000
Binary files a/assets/partners/banners/glm-zh.jpg and /dev/null differ
diff --git a/assets/partners/logos/DDSHub.png b/assets/partners/logos/DDSHub.png
deleted file mode 100644
index 09785f6b..00000000
Binary files a/assets/partners/logos/DDSHub.png and /dev/null differ
diff --git a/assets/partners/logos/aicodemirror.png b/assets/partners/logos/aicodemirror.png
deleted file mode 100644
index 9c1bbaa2..00000000
Binary files a/assets/partners/logos/aicodemirror.png and /dev/null differ
diff --git a/assets/partners/logos/packycode.png b/assets/partners/logos/packycode.png
deleted file mode 100644
index 4fc7eecc..00000000
Binary files a/assets/partners/logos/packycode.png and /dev/null differ
diff --git a/assets/partners/logos/rightcode.jpg b/assets/partners/logos/rightcode.jpg
deleted file mode 100644
index 06ac3a63..00000000
Binary files a/assets/partners/logos/rightcode.jpg and /dev/null differ
diff --git a/assets/partners/logos/sds-en.png b/assets/partners/logos/sds-en.png
deleted file mode 100644
index ad39c829..00000000
Binary files a/assets/partners/logos/sds-en.png and /dev/null differ
diff --git a/assets/partners/logos/sds-zh.png b/assets/partners/logos/sds-zh.png
deleted file mode 100644
index c4452189..00000000
Binary files a/assets/partners/logos/sds-zh.png and /dev/null differ
diff --git a/docs/CODEX_MCP_RAW_TOML_PLAN.md b/docs/CODEX_MCP_RAW_TOML_PLAN.md
index 0e99efa3..fcf2fcac 100644
--- a/docs/CODEX_MCP_RAW_TOML_PLAN.md
+++ b/docs/CODEX_MCP_RAW_TOML_PLAN.md
@@ -1285,7 +1285,7 @@ pnpm add @codemirror/lang-toml
- [Codex 官方 MCP 文档](https://codex.dev/docs/mcp)
- [TOML 规范](https://toml.io/en/)
- [CodeMirror 6 文档](https://codemirror.net/docs/)
-- [项目 CLAUDE.md](../CLAUDE.md)
+- [项目 README](../README_ZH.md)
---
diff --git a/docs/cc-switch-tui/CHANGELOG.md b/docs/cc-switch-tui/CHANGELOG.md
new file mode 100644
index 00000000..c6d4bd5d
--- /dev/null
+++ b/docs/cc-switch-tui/CHANGELOG.md
@@ -0,0 +1,87 @@
+# Changelog
+
+## [0.1.3] - 2026-05-18
+
+### Added
+
+- Codex provider catalog import: press `i` on the Providers page to read live providers from `~/.codex/config.toml`, merge by stable catalog key, and create new saved providers for unrecognized entries.
+- Auto-repair conflicting custom provider keys: detect duplicate `custom` keys in `[model_providers]` before sync and rewrite them to unique keys derived from provider id/name.
+- Provider key rewrite primitives for config snapshots: rename a provider table key, rewrite profile references, and update root model_provider.
+- Skill sync method setting exposed in the TUI.
+
+### Fixed
+
+- Honor `CODEX_HOME` for MCP live sync instead of assuming the default path.
+- Preserve migrated user settings during config migration.
+- Keep tests off real config directories and isolate `cargo test` home by default.
+
+### Changed
+
+- Optimize Codex provider catalog import and sync: keep TUI-managed custom providers mirrored into the live `config.toml` `[model_providers.*]` table; tolerate broken legacy snapshots instead of aborting unrelated operations.
+- Update Rust toolchain baseline.
+
+### Removed
+
+- Remove unused TUI actions, provider proxy code, and config helpers.
+
+## [0.1.2] - 2026-05-13
+
+### Added
+
+- Add OpenClaw MCP management support across the CLI/TUI app model.
+- Show the installed OpenClaw CLI version in the TUI home local environment check.
+- Add visual selection mode for skills management.
+- Add OpenClaw skill support and align agent app columns.
+
+### Fixed
+
+- Keep OpenClaw and Hermes app switches persisted in TUI state.
+- Prune stale OpenClaw agent model catalog entries when providers are removed.
+- Align the OpenClaw current provider marker and default provider keyboard handling.
+- Reconcile live app skill enablement and skip managed or bundled skills during agent import.
+- Adapt upstream sync changes for cc-switch-tui.
+
+## [0.1.1] — 2026-05-11
+
+### Added
+
+- Publish the Rust crate to crates.io during tagged release workflows.
+
+### Fixed
+
+- Fix OpenClaw provider switching and default model writes when valid upstream config uses flexible default model shapes or empty object values.
+- Keep TUI app switching responsive during startup and accept localized app switch hotkey labels.
+- Run legacy config directory migration before startup database initialization.
+
+## [0.1.0] — 2026-05-10
+
+Initial release of the renamed cc-switch-tui fork.
+
+### Added
+
+- CC_SWITCH_TUI_CONFIG_DIR env var to override config directory (with `~` expansion)
+- Auto-migration from legacy `~/.cc-switch/` to `~/.cc-switch-tui/`
+- Hermes support: provider management, MCP, skills, prompts, proxy
+- OpenClaw support: provider management, MCP, prompts, proxy
+- Interactive prompt for legacy config directory migration
+
+### Changed
+
+- Rename project from cc-switch-cli to cc-switch-tui (package, binaries, config paths)
+- Repository URL updated to github.com/handy-sun/cc-switch-tui
+- Description updated to include Hermes and OpenClaw
+
+### Fixed
+
+- Embedded line numbers in flake.nix and generate_latest_json.py
+- MCP table rendering for Hermes column
+- TUI picker navigation bounds for 6-app layout
+
+### Removed
+
+- Sponsor section from README files and partner assets
+
+[0.1.3]: https://github.com/handy-sun/cc-switch-tui/releases/tag/v0.1.3
+[0.1.2]: https://github.com/handy-sun/cc-switch-tui/releases/tag/v0.1.2
+[0.1.1]: https://github.com/handy-sun/cc-switch-tui/releases/tag/v0.1.1
+[0.1.0]: https://github.com/handy-sun/cc-switch-tui/releases/tag/v0.1.0
diff --git a/docs/cc-switch-tui/agent-installed-skill-import.zh.md b/docs/cc-switch-tui/agent-installed-skill-import.zh.md
new file mode 100644
index 00000000..9aa6ecbf
--- /dev/null
+++ b/docs/cc-switch-tui/agent-installed-skill-import.zh.md
@@ -0,0 +1,204 @@
+# Agent 已安装 Skill 导入流程
+
+最后更新:2026-05-13
+
+本文档记录 Skills 页面新增的 `s` 导入流程:从当前 agent 自己已经安装的
+skills 中读取可导入项,弹出选择对话框,确认后同步到 cc-switch-tui 管理的
+skill 单一来源目录和数据库记录中。本文用于后续维护;如果行为变化,以源码为最终依据。
+
+## 使用方式
+
+入口:TUI 的 Skills 页面。
+
+操作流程:
+
+1. 进入 Skills 页面。
+2. 按 `s`,打开 `Import Agent Skills` 对话框。
+3. 对话框会列出当前 agent skill 目录中发现、且尚未被 cc-switch-tui 管理的 skills。
+4. 默认会全选所有可导入项。
+5. 用 `Up` / `Down` 移动选中行。
+6. 用 `Space` 或 `x` 切换当前行是否导入。
+7. 按 `i` 或 `Enter` 确认导入。
+8. 按 `Esc` 取消。
+9. 在对话框中按 `r` 重新扫描 agent 已安装 skills。
+
+如果没有找到可导入项,会关闭对话框并显示提示:
+
+- agent skill 目录不存在;
+- agent skill 目录为空;
+- skill 已经被 cc-switch-tui 管理;
+- skill 目录名以 `.` 开头,被视为隐藏目录。
+
+导入完成后,skills 会出现在 cc-switch-tui 的已安装 skill 列表里。对于从具体
+agent 工具目录发现的 skill,导入会同时保留它已经安装到该工具的事实,例如
+`~/.hermes/skills/foo` 会让 `foo` 的 Hermes 启用状态变为开启。对于通用
+`~/.agents/skills` 来源,如果没有同时在某个具体工具目录中发现同名 skill,则只纳入
+cc-switch-tui 管理,不会自动启用到某个 app。
+
+## 扫描来源
+
+源码入口:`src-tauri/src/services/skill.rs`
+
+`SkillService::scan_agent_installed()` 扫描当前支持 skills 的 agent 工具目录,以及
+通用 agent skill 目录。
+
+当前扫描来源按优先级为:
+
+1. `~/.agents/skills`
+2. 已支持 app 的 skill 目录:
+ - Claude:优先 `$CLAUDE_CONFIG_DIR/skills`,没有可扫描 skills 目录时回退到
+ cc-switch settings override 或 `~/.claude/skills`
+ - Codex:优先 `$CODEX_HOME/skills`,没有可扫描 skills 目录时回退到
+ cc-switch settings override 或 `~/.codex/skills`
+ - Hermes:优先 `$HERMES_HOME/skills`,没有可扫描 skills 目录时回退到
+ cc-switch settings override 或 `~/.hermes/skills`
+ - `~/.gemini/skills`
+ - `~/.config/opencode/skills`
+ - `~/.openclaw/skills`
+
+如果两个来源路径相同,只保留一个来源,避免重复扫描。
+
+每个来源下只读取一层子目录。一个子目录会被当作 skill 候选项,目录中的
+`SKILL.md` 用于读取展示名称和描述;如果读取不到元数据,则使用目录名和空描述作为
+fallback。
+
+扫描结果按 skill 名称排序。多个来源中出现同名目录时会去重为一条记录,并把来源标签
+合并到 `found_in` 中。
+
+来源标签:
+
+- `agents`:来自 `~/.agents/skills`
+- `claude`、`codex`、`gemini`、`opencode`、`openclaw`、`hermes`:来自对应 app 的
+ skill 目录
+
+## 过滤规则
+
+扫描时会先读取 cc-switch-tui 当前管理的 skill index。
+
+下列目录不会出现在导入对话框中:
+
+- 非目录条目;
+- 目录名以 `.` 开头的隐藏目录;
+- 根目录下没有 `SKILL.md` 的目录,例如 Hermes 的分类目录;
+- Hermes `.bundled_manifest` 中声明的内置技能;
+- 目录名已经存在于 cc-switch-tui 管理记录中的 skill;
+- 读取目录失败的条目。
+
+这里的去重和过滤以目录名为 key。也就是说,同名目录会合并成一条导入候选,并把发现
+来源合并到 `found_in`。如果 cc-switch-tui 已经管理了同名 directory,agent 导入不会再
+提示或重复导入它。
+
+## 导入逻辑
+
+源码入口:
+
+- TUI action:`Action::SkillsOpenAgentImport`
+- 打开对话框:`open_agent_skills_import_picker()`
+- 确认导入:`Action::SkillsImportFromAgent`
+- 服务层导入:`SkillService::import_from_agent()`
+
+确认导入后,流程如下:
+
+1. 重新读取 cc-switch-tui skill index。
+2. 解析 `~/.agents/.skill-lock.json`,得到可用的 GitHub repo 元数据。
+3. 确保 SSOT 目录存在。
+4. 按 agent 来源优先级查找每个被选中的 directory。
+5. 合并所有发现来源对应的 app 启用状态。
+6. 找到来源目录后,把它复制到 cc-switch-tui 的 SSOT skill 目录。
+7. 如果 directory 已经在 index 中,跳过,不重复导入、不补齐 app 启用状态。
+8. 如果 directory 还没有管理记录,从目标目录的 `SKILL.md` 读取 name 和 description,并生成
+ `InstalledSkill` 管理记录。
+9. 保存 skill index。
+10. 重新加载 TUI 数据,并刷新 agent 导入扫描结果缓存。
+
+SSOT 目录由 `SkillService::get_ssot_dir()` 决定,位于当前应用配置目录下的
+skills 目录。应用配置目录不要硬编码为 `~/.cc-switch-tui`;它受
+`CC_SWITCH_TUI_CONFIG_DIR` 等配置目录解析逻辑影响。
+
+导入复制规则:
+
+- 只在 SSOT 目标目录不存在时复制;
+- 复制的是整个 skill 目录;
+- 如果 SSOT 目标目录已存在但 index 中没有记录,会复用已有 SSOT 内容读取元数据并创建
+ 管理记录;
+- 导入不会删除 agent 原目录;
+- 导入不会把通用 `~/.agents/skills` 来源自动启用到某个 app;
+- 导入会把具体 app-local skill 目录反映到对应 app 的启用状态。
+
+## Metadata 与仓库信息
+
+只有 `~/.agents/skills` 来源会使用 `~/.agents/.skill-lock.json` 的元数据。
+
+当 `.skill-lock.json` 中对应 skill 满足以下条件时,会从 lock file 填充 repo 信息:
+
+- `source_type` 是 `github`;
+- `source` 形如 `owner/repo`;
+- 可选的 `branch`、`source_branch` 或 `source_url` 能解析出分支;
+- 可选的 `skill_path` 用于构造 README URL。
+
+导入时还会把 lock file 中涉及的 GitHub repos 合并到 cc-switch-tui 的 skill repo
+列表中,便于后续发现、展示和维护。
+
+来自具体 app 目录或 app 环境变量技能目录的 skill 不读取
+`~/.agents/.skill-lock.json`。这类导入会使用本地记录:
+
+- `id = local:{directory}`
+- `repo_owner = None`
+- `repo_name = None`
+- `repo_branch = None`
+- `readme_url = None`
+
+## 启用状态
+
+Agent 导入会区分通用 agent 来源和具体工具来源。
+
+如果 skill 只存在于 `~/.agents/skills`,新建记录的 `apps` 使用默认值,也就是所有 app
+都未启用。
+
+如果新导入的 skill 存在于具体工具目录中,新建记录会启用对应 app。例如:
+
+- `~/.hermes/skills/foo` 会设置 `foo.apps.hermes = true`
+- `~/.claude/skills/foo` 会设置 `foo.apps.claude = true`
+- `$CODEX_HOME/skills/foo` 会设置 `foo.apps.codex = true`
+- `$CLAUDE_CONFIG_DIR/skills/foo` 会设置 `foo.apps.claude = true`
+- `$HERMES_HOME/skills/foo` 会设置 `foo.apps.hermes = true`
+
+如果 skill 已经在 cc-switch-tui 管理记录中,`S` 不会重复导入;但 Skills 列表加载时会
+检查已管理 skill 是否实际存在于具体 app 的 skills 目录,若存在则回填对应 app 启用状态,
+避免目录已经存在但界面未打勾。
+
+后续仍可由用户显式调整:
+
+- 在 Skills 页面按 `m` 选择 app;
+- 或使用已有的 skill toggle / sync 命令。
+
+## 与“导入已有”流程的区别
+
+Skills 页面原有的 `i` 是导入已有 skills,主要用于把 app-local 或 SSOT 中尚未管理的
+skills 纳入管理。
+
+新增的 `s` 是导入 agent 已安装 skills,范围更窄:
+
+- 扫描 agent 工具自己的安装目录和通用 agent skill 目录;
+- 使用独立对话框和独立空结果提示;
+- 与 `i` 共用选择、全选、切换、确认的交互形态;
+- 导入后会保留具体工具目录所代表的 app 启用状态;
+- 当 `~/.agents/skills` 和具体工具目录中存在同名目录时,优先使用
+ `~/.agents/skills` 作为复制内容,同时合并具体工具目录的启用状态。
+
+## 维护注意事项
+
+- `scan_agent_installed()` 的职责是扫描 agent 工具目录;如果新增支持 skills 的 app,需要
+ 确认它是否应加入 `supported_skill_apps()`。
+- 不要把通用 `~/.agents/skills` 直接映射成某个 app 的启用状态;只有具体工具目录才能设置
+ 对应 app flag。
+- 修改来源优先级时,需要同步更新同名目录冲突场景的测试。
+- 修改 `.skill-lock.json` 解析时,需要保持非 GitHub source 被忽略的行为。
+- 修改对话框按键时,需要同时更新 TUI handler、渲染提示和 UI 测试。
+
+相关测试:
+
+- `src-tauri/tests/skills_service.rs`
+- `src-tauri/src/cli/tui/tests.rs`
+- `src-tauri/src/cli/tui/app/tests.rs`
+- `src-tauri/src/cli/tui/ui/tests.rs`
diff --git a/docs/cc-switch-tui/codex-provider-catalog.zh.md b/docs/cc-switch-tui/codex-provider-catalog.zh.md
new file mode 100644
index 00000000..c9d242a1
--- /dev/null
+++ b/docs/cc-switch-tui/codex-provider-catalog.zh.md
@@ -0,0 +1,252 @@
+# Codex Provider Catalog 设计说明
+
+最后更新:2026-05-17
+
+本文档记录 cc-switch-tui 对 Codex provider 的 catalog 管理逻辑。这里的
+catalog 指的是 Codex live `config.toml` 里的 `[model_providers.*]` 表,而不是
+cc-switch-tui 自己的 SQLite provider 列表。本文用于解释这次 Codex-only 优化后的
+数据流、边界和维护约束;如果行为变化,以源码为最终依据。
+
+## 目标
+
+这次调整只针对 Codex,不改变 Claude、Gemini、OpenCode、OpenClaw、Hermes 的
+provider 行为。
+
+目标有三条:
+
+- TUI 后台保存的所有 Codex 自定义 provider,都应同步出现在 live
+ `~/.codex/config.toml` 的 `[model_providers.*]` 中。
+- 在 Codex 的 Providers 页面按 `i` 时,应导入当前 live config 里的所有可识别
+ provider,而不是只导入一个 `default` 快照。
+- 导入行为只负责合并或新增 provider,不负责切换当前 provider。
+
+## 相关源码
+
+- 服务层主逻辑:`src-tauri/src/services/provider/codex.rs`
+- provider 提交后置动作:`src-tauri/src/services/provider/mod.rs`
+- 配置保存后的 live 同步:`src-tauri/src/services/config.rs`
+- TUI 导入动作:`src-tauri/src/cli/tui/runtime_actions/providers.rs`
+- TUI 按键入口:`src-tauri/src/cli/tui/app/content_entities.rs`
+- TUI 文案和提示:`src-tauri/src/cli/tui/ui/providers.rs`
+
+## 数据模型
+
+cc-switch-tui 现在为 Codex provider 增加了一个额外 metadata 字段:
+
+- `ProviderMeta.codexModelProviderKey`
+
+它表示该 provider 在 Codex live config 中对应的稳定外部 key,也就是
+`[model_providers.
]` 里的 ``。
+
+这个字段的作用是把两个层面分开:
+
+- cc-switch-tui 内部 provider id:SQLite / JSON 里的 provider 主键。
+- Codex live catalog key:写进 `config.toml` 的外部 key。
+
+这两者不再要求相同,也不应该相互覆盖。
+
+## 写回 live config
+
+### 写回目标
+
+Codex live config 里的 `[model_providers.*]` 现在被视为两部分叠加结果:
+
+- 当前 provider 切换后写入的活动配置;
+- TUI 保存的全部 Codex 自定义 provider catalog。
+
+也就是说,当前 provider 负责决定 live 顶层的:
+
+- `model_provider`
+- `model`
+- 以及当前生效 provider 的其他顶层配置
+
+而 catalog 同步负责保证:
+
+- 所有 TUI 管理的自定义 provider 都存在于 `[model_providers.*]`
+
+### 触发时机
+
+下列路径会触发 Codex catalog 写回:
+
+- 新增 Codex provider
+- 更新 Codex provider
+- 删除 Codex provider
+- 切换当前 Codex provider
+- 保存整体配置
+
+对应实现是 `PostCommitAction` 新增了:
+
+- `write_live_snapshot`
+- `sync_codex_catalog`
+- `stale_codex_catalog_keys`
+
+这样可以把“是否重写当前 live snapshot”和“是否补写 catalog”拆开处理。
+
+## catalog 来源
+
+写回 live config 时,catalog 来源只取 cc-switch-tui 当前保存的 Codex provider。
+
+过滤规则:
+
+- 官方 Codex provider 不参与 catalog 写回。
+- 没有可解析 `config` 的 provider 跳过。
+- 坏掉的旧 snapshot 不会中断整个写回流程,只会记录 warning 并跳过该项。
+
+这里专门做了容错,是因为历史数据库里可能存在不可解析的旧 Codex snapshot;如果因为
+一个坏快照就让“设置 common snippet”、“切换 provider”或“保存配置”全部失败,代价太大。
+
+## key 解析规则
+
+单个 provider 写回 catalog 时,key 的来源按顺序是:
+
+1. `meta.codexModelProviderKey`
+2. provider 自身 `settings_config.config` 中可解析的 `model_provider`
+3. 如果 snapshot 只包含一个 `[model_providers.*]` 项,则回退到那个唯一 key
+
+只要拿到 key,就会把对应的 `[model_providers.]` 整项写回 live config。
+
+## 导入 live config
+
+### 入口
+
+Codex 的 Providers 页面新增了 `i`:
+
+- 空列表时,空态说明会明确提示它会导入当前 `config.toml` 里的全部可识别 provider
+- 非空列表时,底部 key bar 也会显示 `i import current config`
+
+运行时入口是 `Action::ProviderImportLiveConfig`,在 Codex 下会调用
+`ProviderService::import_codex_providers_from_live()`。
+
+### 导入范围
+
+导入会读取:
+
+- `~/.codex/config.toml`
+- `~/.codex/auth.json`
+
+然后枚举当前 live config 里的全部 `[model_providers.*]`。
+
+每个 catalog entry 会被转换成一个临时 provider snapshot,形态是:
+
+- `auth`
+- `config`
+
+其中:
+
+- 当前活跃 provider 会带上 live `auth.json`
+- 非当前 provider 默认没有 auth,只会得到空对象
+
+这是有意设计。因为 Codex live config 本身只保存当前会话正在使用的 auth,不能凭空推断
+其他 catalog 项的凭证。
+
+### 合并规则
+
+导入时按下面顺序查找目标 provider:
+
+1. 先按 `codexModelProviderKey` 精确匹配
+2. 如果没有唯一 key 匹配,再按 provider 名称精确匹配
+3. 两者都没有时,新建 provider
+
+结果统计在 `CodexImportReport` 中:
+
+- `created`
+- `merged_by_key`
+- `merged_by_name`
+- `needs_auth`
+- `conflicts`
+- `used_default_fallback`
+
+### 同名和冲突
+
+如果按 key 或按 name 匹配时出现多个候选,导入不会猜测,直接计入 `conflicts` 并跳过。
+
+同 key 只允许由一个 provider 持有。写回 live catalog 时如果发现两个 provider 想写同一个
+key,会直接报错。
+
+## 当前 provider 语义
+
+导入 catalog 不会自动切换当前 provider。
+
+也就是说:
+
+- live config 当前正在使用哪个 provider,不会因为按了 `i` 而改变
+- cc-switch-tui 当前 provider 记录,也不会因为导入 catalog 而改写
+
+这是本次设计里最重要的边界之一。导入动作只同步“可选 provider 集合”,不改变“当前选择”。
+
+## stable alias 去重
+
+Codex 现有逻辑为了保持 resume/history 的连续性,可能会把 live 当前 provider 的
+`model_provider` 规范化成一个稳定 alias。
+
+这会带来一个问题:
+
+- live config 里可能同时存在“当前稳定 alias”与“真实 provider key”
+- 但这两个条目内容其实是同一个 provider
+
+导入时如果不处理,会把同一个 provider 导入两遍。
+
+现在的处理方式是:
+
+- 先找当前活跃 key 对应的 provider table
+- 再扫描所有 `[model_providers.*]`
+- 如果发现另一个 table 内容完全相同,就把它视为同一个 provider 的 canonical key
+- 当前活跃的 stable alias 会被折叠掉,不再单独导入
+
+这样可以保证:
+
+- 当前 provider 只导入一次
+- 保存到 TUI 的 key 仍然保持真实 catalog key,而不是把稳定 alias 反写回数据库
+
+## 删除和改 key
+
+Codex provider 更新或删除时,需要处理旧 key 残留问题。
+
+为此提交后置动作里额外维护了 `stale_codex_catalog_keys`:
+
+- 删除 provider 时,把它旧的 catalog key 从 live config 删掉
+- 更新 provider 且 key 发生变化时,也会移除旧 key
+
+这样能避免 live config 里一直残留已经失效的 `[model_providers.old_key]`。
+
+## 异常和回退
+
+### 没有 catalog 时
+
+如果 live `config.toml` 里没有任何 `[model_providers.*]`,Codex 导入会回退到旧行为:
+
+- `import_default_config()`
+
+这保证老用户只有单一 live 配置时,仍然能导入成 `default` provider。
+
+### 坏 snapshot 时
+
+catalog 写回会跳过坏掉的旧 provider snapshot,不阻塞其他正常 provider。
+
+这是专门为了兼容历史脏数据。否则下面这些已有能力都会被一个旧坏数据拖死:
+
+- 设置或清理 Codex common config snippet
+- 切换 Codex provider
+- 保存配置
+
+## 维护约束
+
+- 这套逻辑是 Codex-only。不要把 `i` 的新行为直接扩散到其他 app。
+- `codexModelProviderKey` 是外部 key,不是内部主键;不要拿它替代 provider id。
+- 导入动作不能自动写 current provider。
+- 如果以后再改 Codex stable provider alias 逻辑,必须同时检查 import 去重逻辑是否仍成立。
+- 如果以后允许非当前 provider 保存独立 auth,导入逻辑里“非当前 provider auth 为空”的约束也要一起重审。
+
+## 相关测试
+
+服务层:
+
+- `codex_switch_syncs_all_managed_provider_catalog_entries_into_live_config`
+- `import_codex_providers_from_live_merges_catalog_and_skips_active_alias_duplicate`
+- 原有的 Codex common snippet / broken snapshot 回归测试
+
+TUI:
+
+- `codex_providers_i_key_imports_current_config`
+- `codex_providers_empty_state_shows_catalog_import_copy_and_i_hint`
+- `codex_provider_list_key_bar_shows_import_current_config_hint`
diff --git a/docs/cc-switch-tui/mcp-live-drift-detection.zh.md b/docs/cc-switch-tui/mcp-live-drift-detection.zh.md
new file mode 100644
index 00000000..d955cc58
--- /dev/null
+++ b/docs/cc-switch-tui/mcp-live-drift-detection.zh.md
@@ -0,0 +1,494 @@
+# MCP Live Drift Detection Implementation Plan
+
+> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
+
+**Goal:** 在 MCP 管理界面中暴露 live 配置与 cc-switch 数据库之间的差异,让用户能看到并显式处理 Codex CLI 或手工编辑造成的 MCP 配置漂移。
+
+**Architecture:** 保持 cc-switch 数据库仍是托管 MCP 的默认写入来源,但新增只读 diff 层读取 live 配置并与数据库快照比较。UI 只提示差异,不默认自动导入或覆盖;用户通过显式动作选择“从 live 更新 cc-switch”或“用 cc-switch 覆盖 live”。先修正 Codex provider switch 仍触发全量 MCP sync 的风险,再接入 drift 检测。
+
+**Tech Stack:** Rust、现有 `McpService` / TUI `UiData` / `toml_edit` / `serde_json`,测试使用 `src-tauri/tests/*.rs` 和 TUI 单元测试。
+
+---
+
+最后更新:2026-05-19
+
+本文档记录 cc-switch-tui MCP live drift 检测与显式解决流程的建议方案。这里的
+drift 指同一个 app 的 live MCP 配置和 cc-switch 后台保存的 MCP 配置不一致,例如用户
+直接编辑了 `~/.codex/config.toml` 的 `[mcp_servers]`,但没有在 cc-switch 里导入。
+
+如果实现行为变化,以源码为最终依据。
+
+## 背景
+
+当前 MCP 数据流主要是单向托管:
+
+- 正常写入:cc-switch 数据库 -> live app 配置。
+- 反向导入:live app 配置 -> cc-switch 数据库,需要用户在 TUI 或 CLI 手动触发。
+
+以 Codex 为例:
+
+- 单项写入路径是 `McpService::toggle_app()` / `McpService::upsert_server()` ->
+ `sync_single_server_to_codex()`。
+- 手动导入路径是 `McpService::import_from_codex()` -> `mcp::import_from_codex()`。
+- MCP 页面读取的是 cc-switch 后台保存的 `McpServer` 列表,不会实时读取 live
+ `config.toml`。
+
+这会造成一个 UX 问题:如果用户或 Codex CLI 直接改了 `[mcp_servers]`,cc-switch MCP
+页面仍显示旧值;用户继续在 cc-switch 里编辑或 toggle 同一个 server 时,会用数据库里的旧
+server spec 覆盖 live 中的新内容。
+
+另一个重要前置问题是:当前普通 provider switch 的 post-commit action 仍会执行
+`McpService::sync_all_enabled()`。Codex live 写入本身会 merge 并跳过 `[mcp_servers]`,
+但后续全量 MCP sync 仍可能覆盖或删除 live 中与 cc-switch 数据库不一致的 MCP server。
+因此 drift 检测之前,建议先收窄或关闭 Codex provider switch 的 MCP 全量同步。
+
+## 非目标
+
+本方案不建议立即做自动双向同步。
+
+明确非目标:
+
+- 启动时自动把 live 配置导入数据库。
+- 启动时自动用数据库覆盖 live。
+- 静默合并同 ID 的复杂字段冲突。
+- 在第一版支持所有 app 的完整字段级 diff UI。
+- 保证 live 文件里的注释、排序、空白能被导入到数据库;数据库只保存规范化后的 server
+ spec。
+
+原因是 live MCP 配置可能是用户临时修改、注释禁用、Codex CLI 自动生成或外部工具写入的
+结果。静默同步会把“不知道谁是权威”的冲突变成不可见的数据破坏。
+
+## 目标体验
+
+第一版目标是“看见差异,并能显式解决”。
+
+用户进入 MCP 页面时:
+
+- 如果当前 app 的 live 配置与 cc-switch 数据库一致,界面不额外打扰。
+- 如果 live 有 cc-switch 没有的 server,列表显示 `live only`。
+- 如果 cc-switch 有启用到当前 app 的 server,但 live 没有,列表显示 `missing live`。
+- 如果同 ID server 两边都有但 spec 不一致,列表显示 `live changed`。
+- 如果 live MCP 配置无法解析,显示整体 warning,不阻断数据库列表展示。
+
+用户可以对有 drift 的条目执行显式动作:
+
+- `import live`:把 live 的 server spec 写回 cc-switch 数据库。
+- `push db`:把 cc-switch 数据库里的 server spec 写回 live。
+- `ignore`:暂不处理,只保留标记。
+
+后续可以扩展 `view diff`,但第一版可以先只显示状态和摘要。
+
+## 数据语义
+
+### 权威来源
+
+默认权威来源仍是 cc-switch 数据库。只有用户选择 `import live` 时,live 才会成为该 server
+本次操作的来源。
+
+### 比较粒度
+
+第一版按 server id 比较。
+
+状态建议使用:
+
+- `InSync`:DB 与 live 都存在,且规范化 spec 一致。
+- `LiveOnly`:live 中存在,DB 中不存在,或 DB 中没有启用当前 app。
+- `DbOnly`:DB 中存在且启用当前 app,但 live 中不存在。
+- `Changed`:DB 与 live 都存在且当前 app 启用,但规范化 spec 不一致。
+- `LiveInvalid`:live 文件或 live MCP section 无法解析。
+- `Unknown`:当前 app 没有 live 读取器,或未初始化导致跳过检测。
+
+### 规范化
+
+比较时不要直接比较 TOML 文本,应比较规范化后的 JSON spec。
+
+Codex 示例:
+
+- 读取 `[mcp_servers]` 和兼容读取历史错误位置 `[mcp.servers]`。
+- 将 TOML table 转换为 cc-switch 内部使用的 `serde_json::Value`。
+- 对 Codex 远端 MCP 做现有导入语义一致的推断:有 `url` 且无 `type` 时视为 `http`。
+- `http_headers` 和旧 `headers` 统一到内部 `headers`。
+- stdio 的 `env`、`cwd`、`args` 按现有导入逻辑转换。
+
+比较前可以递归排序 JSON object key,避免 map 顺序影响结果。数组顺序保持有意义,不排序。
+
+## 导入和覆盖语义
+
+### import live
+
+`import live` 面向单个 app 和单个 server id。
+
+行为:
+
+- 如果 DB 中没有该 server:创建 `McpServer`,`id` 和 `name` 默认使用 live id,当前 app
+ enabled,其它 app disabled。
+- 如果 DB 中已有该 server:只覆盖 `server` spec,并把当前 app enabled。
+- 保留已有 metadata:`name`、`description`、`homepage`、`docs`、`tags` 不因覆盖 spec
+ 而丢失。
+- 不自动同步到其它 app。
+- 操作完成后保存数据库,并重新计算 drift。
+
+这与现有 `import_from_codex()` 不同。现有导入对已存在 server 只启用 Codex,不覆盖
+`server` spec;drift resolve 需要一个更明确的“用 live 覆盖 DB spec”动作。
+
+### push db
+
+`push db` 面向单个 app 和单个 server id。
+
+行为:
+
+- 如果 DB 中该 server 对当前 app 已启用:调用现有单项 sync 写回 live。
+- 如果 DB 中该 server 对当前 app 未启用:不应写入,提示该 app 未启用。
+- 如果 live 有同 id server:被 DB spec 覆盖,这是用户显式选择。
+- 操作完成后重新计算 drift。
+
+### delete / disable 的关系
+
+第一版不新增“从 live 删除 live-only server”的快捷动作。live-only 代表 cc-switch 尚未管理,
+贸然删除风险较高。
+
+已有 disable 或 delete 行为保持:
+
+- 对 DB 管理的 server disable 当前 app,继续从对应 live 配置移除该 server。
+- 对 DB 管理的 server delete,继续从所有启用 app 的 live 配置移除该 server。
+
+## UI 设计
+
+### MCP 列表
+
+当前 MCP 表格列是:
+
+- Name
+- Claude
+- Codex
+- Gemini
+- OpenCode
+- OpenClaw
+- Hermes
+
+第一版建议新增一个窄列 `Live`,放在 `Name` 后面:
+
+- 空白:无 drift 或 app 不支持检测。
+- `~`:同 ID spec 不一致。
+- `+`:live only。
+- `-`:DB only / missing live。
+- `!`:live invalid。
+
+底部 key bar 增加:
+
+- `r resolve` 或 `l live`
+
+如果空间紧张,也可以先不加列,在 `Name` 后追加短文本,例如 `server-name [live changed]`。
+不过新增列更容易测试,也不污染 server name。
+
+### 摘要栏
+
+摘要栏可以在原有各 app enabled 数量后追加 drift 汇总:
+
+- `Live drift: 2 changed, 1 live-only`
+
+如果没有 drift,不显示额外文本。
+
+### Resolve overlay
+
+选中有 drift 的行后按 `r`,打开 overlay:
+
+- 标题:`Resolve MCP Live Drift`
+- 展示 app、server id、状态。
+- 操作项:
+ - `Import live into cc-switch`
+ - `Push cc-switch to live`
+ - `Cancel`
+
+第一版不需要做完整 diff viewer,但建议显示一行摘要:
+
+- `DB command: old-command`
+- `Live command: new-command`
+
+如果是 HTTP server,则显示 URL。
+
+### live-only 行如何展示
+
+如果 live 有 DB 没有的 server,MCP 列表需要能展示一行虚拟 row,否则用户看不到它。
+
+建议 `McpRow` 扩展:
+
+- `id`
+- `server: Option`
+- `live_status`
+- `live_app`
+- `live_spec_summary`
+
+但这会影响现有大量代码。更小的第一版改法:
+
+- 保持 `McpRow.server` 不变。
+- 在 `McpSnapshot` 增加 `live_only: Vec`。
+- MCP UI 渲染时把 DB rows 和 live-only rows 合并成 display rows。
+
+这样可以减少对编辑、toggle、delete 逻辑的影响。选中 live-only row 时,只允许
+`import live` 和 `cancel`,不允许直接编辑 DB server。
+
+## 服务层接口
+
+建议新增类型放在 `src-tauri/src/services/mcp.rs`,或拆成
+`src-tauri/src/services/mcp_drift.rs` 后由 `services/mod.rs` re-export。
+
+核心结构:
+
+```rust
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum McpLiveDriftKind {
+ InSync,
+ LiveOnly,
+ DbOnly,
+ Changed,
+ LiveInvalid,
+ Unknown,
+}
+
+#[derive(Debug, Clone)]
+pub struct McpLiveDriftEntry {
+ pub app: AppType,
+ pub id: String,
+ pub kind: McpLiveDriftKind,
+ pub db_spec: Option,
+ pub live_spec: Option,
+ pub message: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct McpLiveDriftReport {
+ pub app: AppType,
+ pub entries: Vec,
+}
+```
+
+服务方法:
+
+```rust
+impl McpService {
+ pub fn get_live_drift(
+ state: &AppState,
+ app: AppType,
+ ) -> Result;
+
+ pub fn import_live_server(
+ state: &AppState,
+ app: AppType,
+ id: &str,
+ ) -> Result<(), AppError>;
+
+ pub fn push_db_server_to_live(
+ state: &AppState,
+ app: AppType,
+ id: &str,
+ ) -> Result<(), AppError>;
+}
+```
+
+读取 live 的 app-specific helper:
+
+```rust
+fn read_live_mcp_servers(app: &AppType)
+ -> Result, AppError>;
+```
+
+第一版可以只实现 Codex。其它 app 返回 `Unknown` 或空 report,后续再逐步接入 Claude、
+Gemini、OpenCode、OpenClaw、Hermes。
+
+## 实施步骤
+
+### Task 1: 修正 Codex provider switch 的 MCP 全量同步
+
+**Objective:** 避免用户只是切换 Codex provider 时,live `[mcp_servers]` 被 DB 旧值覆盖。
+
+**Status:** 已完成(2026-05-19)
+
+**Files:**
+
+- Modify: `src-tauri/src/services/provider/mod.rs`
+- Modify: `src-tauri/src/services/provider/codex.rs`
+- Test: `src-tauri/tests/provider_service.rs`
+
+**Steps:**
+
+1. 添加失败测试:live `config.toml` 中同 ID MCP 的 command 与 DB 不同。
+2. 执行 `ProviderService::switch(&state, AppType::Codex, "p2")`。
+3. 断言 switch 后 live 中该 MCP command 仍是 live 原值。
+4. 修改普通 switch 的 `PostCommitAction.sync_mcp`:Codex 切换不触发全量 MCP sync。
+5. 同时修正 Codex catalog sync 与 live 稳定 `model_provider` alias 的冲突:当非当前 provider
+ 的 catalog key 与 live 当前稳定 alias 冲突时,先把该非当前 provider 重写到唯一 key,
+ 避免 switch 后 live `model_providers.` 又被旧 provider 快照覆盖。
+6. 运行相关测试。
+
+Command:
+
+```bash
+cd src-tauri && cargo test switch_codex_provider_preserves_live_mcp_server_edits -q
+```
+
+Additional verification:
+
+```bash
+cd src-tauri && cargo test --test provider_service provider_service_switch_codex -q
+cd src-tauri && cargo test codex_switch_syncs_all_managed_provider_catalog_entries_into_live_config -q
+```
+
+### Task 2: 抽取 Codex live MCP 读取器
+
+**Objective:** 复用现有导入转换语义,提供只读 live MCP map。
+
+**Files:**
+
+- Modify: `src-tauri/src/mcp.rs`
+- Test: `src-tauri/tests/import_export_sync.rs`
+
+**Steps:**
+
+1. 新增 helper,例如 `read_codex_live_mcp_servers_map()`。
+2. 支持 `[mcp_servers]`。
+3. 保留对 `[mcp.servers]` 的兼容读取。
+4. 复用或抽取 `import_from_codex()` 中 TOML -> JSON spec 的转换逻辑。
+5. 添加测试覆盖 stdio、http、env、http_headers。
+
+Command:
+
+```bash
+cd src-tauri && cargo test read_codex_live_mcp_servers_map_parses_supported_shapes -q
+```
+
+### Task 3: 实现 drift report
+
+**Objective:** 比较 DB enabled state 和 live map,产出稳定 report。
+
+**Files:**
+
+- Modify: `src-tauri/src/services/mcp.rs`
+- Test: `src-tauri/tests/mcp_commands.rs`
+
+**Steps:**
+
+1. 新增 drift enum 和 report structs。
+2. 实现 `McpService::get_live_drift(state, AppType::Codex)`。
+3. DB 侧只比较 `apps.codex == true` 的 server。
+4. live-only id 也进入 report。
+5. 添加测试覆盖 `InSync`、`Changed`、`LiveOnly`、`DbOnly`。
+
+Command:
+
+```bash
+cd src-tauri && cargo test codex_mcp_live_drift_reports_changed_live_only_and_db_only -q
+```
+
+### Task 4: 实现 import live / push db
+
+**Objective:** 提供显式 resolve 动作。
+
+**Files:**
+
+- Modify: `src-tauri/src/services/mcp.rs`
+- Test: `src-tauri/tests/mcp_commands.rs`
+
+**Steps:**
+
+1. 实现 `import_live_server()`。
+2. 已存在 server:覆盖 `server` spec,保留 metadata,设置当前 app enabled。
+3. 不存在 server:创建最小 `McpServer`。
+4. 实现 `push_db_server_to_live()`,内部调用现有单项 sync。
+5. 添加测试覆盖 changed 和 live-only 两种 import。
+6. 添加测试覆盖 push db 覆盖 live 同 ID server。
+
+Command:
+
+```bash
+cd src-tauri && cargo test codex_mcp_resolve_live_drift -q
+```
+
+### Task 5: 把 drift report 接入 TUI 数据层
+
+**Objective:** MCP 页面加载时带上 drift 信息。
+
+**Files:**
+
+- Modify: `src-tauri/src/cli/tui/data.rs`
+- Modify: `src-tauri/src/cli/tui/runtime_actions/mcp.rs`
+- Test: `src-tauri/src/cli/tui/tests.rs`
+
+**Steps:**
+
+1. `McpSnapshot` 增加 drift report 或按 id 索引的 drift map。
+2. `UiData::load()` 调用 `McpService::get_live_drift()`,失败时保存 warning 状态,不阻断页面加载。
+3. toggle、import、edit 后重新加载数据,确保 drift 状态刷新。
+4. 添加数据层测试,覆盖 live 解析失败不阻断 DB rows。
+
+### Task 6: MCP 表格显示 drift 标记
+
+**Objective:** 用户能在列表上直接看见 live drift。
+
+**Files:**
+
+- Modify: `src-tauri/src/cli/tui/ui/mcp.rs`
+- Modify: `src-tauri/src/cli/i18n/texts/*.rs`
+- Test: `src-tauri/src/cli/tui/ui/tests.rs`
+
+**Steps:**
+
+1. 表格新增 `Live` 列或 name 后缀。
+2. 为 `Changed`、`LiveOnly`、`DbOnly`、`LiveInvalid` 显示不同标记。
+3. 摘要栏显示 drift 计数。
+4. 更新 UI snapshot / rendering tests。
+
+### Task 7: 添加 resolve overlay 和动作
+
+**Objective:** 用户能从 TUI 显式选择 import live 或 push db。
+
+**Files:**
+
+- Modify: `src-tauri/src/cli/tui/app/types.rs`
+- Modify: `src-tauri/src/cli/tui/app/content_entities.rs`
+- Modify: `src-tauri/src/cli/tui/runtime_actions/mcp.rs`
+- Modify: `src-tauri/src/cli/tui/ui/overlay/*`
+- Test: `src-tauri/src/cli/tui/app/tests.rs`
+
+**Steps:**
+
+1. 新增 overlay state,记录 app、server id、drift kind、当前选项。
+2. MCP 页面 key bar 增加 resolve 入口。
+3. 选中 `Import live into cc-switch` 调用 `McpService::import_live_server()`。
+4. 选中 `Push cc-switch to live` 调用 `McpService::push_db_server_to_live()`。
+5. live-only row 禁用 push db。
+6. resolve 成功后重新加载 `UiData` 并显示 toast。
+
+## 启动检测
+
+启动时自动检测是可选增强,不建议第一阶段就强依赖。
+
+如果要做,建议只做 best-effort 提醒:
+
+- 应用启动或进入 MCP 页面时异步/惰性检测。
+- 有 drift 时显示一次 toast:`Codex MCP live config has changes not imported into cc-switch`。
+- 不自动修改 DB 或 live。
+- 检测失败只记录 warning,不影响主流程。
+
+更保守的做法是只在进入 MCP 页面时检测。这样成本低,也避免 TUI 启动时读取多个外部 app 配置
+造成性能波动。
+
+## 风险和边界
+
+- Codex TOML 注释不会进入 DB。`import live` 只保存规范化 spec。
+- live-only row 不是完整 DB server,不能复用所有现有 row 操作。
+- 同 ID 多 app 共享 server spec。对 Codex 执行 `import live` 覆盖 DB spec 后,其它 app
+ 如果也启用同一个 server,后续同步可能使用新的 spec。这是统一 MCP 结构的既有语义,需要在
+ resolve overlay 中提示。
+- `sync_all_enabled()` 仍是强覆盖语义。任何调用它的路径都可能把 drift 清掉。实现 drift
+ 功能时应审计 provider switch、配置恢复、WebDAV 下载后的同步路径。
+
+## 验证清单
+
+- Codex provider switch 不再覆盖 live `[mcp_servers]` 同 ID 手工修改。
+- MCP 页面能显示 DB rows,即使 live config 解析失败。
+- live-only server 能显示并导入成 DB server。
+- changed server 能用 live 覆盖 DB spec,metadata 保留。
+- changed server 能用 DB 覆盖 live spec。
+- 普通 MCP edit / toggle 后 drift 状态刷新。
+- `cd src-tauri && cargo test` 通过。
diff --git a/docs/cc-switch-tui/migration-logic.md b/docs/cc-switch-tui/migration-logic.md
new file mode 100644
index 00000000..1c418fc4
--- /dev/null
+++ b/docs/cc-switch-tui/migration-logic.md
@@ -0,0 +1,411 @@
+# cc-switch-tui Migration Logic
+
+Last updated: 2026-05-11
+
+This document records the migration behavior that affects local configuration,
+SQLite state, and WebDAV sync data. It is intended as implementation context for
+future changes; use source code as the final authority when behavior changes.
+
+## Important Terms
+
+- App config directory: resolved by `get_app_config_dir()` in
+ `src-tauri/src/config.rs`.
+- Do not assume the app config directory is `~/.cc-switch-tui`.
+- `CC_SWITCH_TUI_CONFIG_DIR` takes priority when set.
+- `CC_SWITCH_CONFIG_DIR` is the deprecated legacy override and still works.
+- Default app config directory is `$HOME/.cc-switch-tui`.
+- Legacy app config directory is `$HOME/.cc-switch`.
+- WebDAV `V1` and `V2` are sync protocol/data-format versions, not
+ cc-switch-tui software versions.
+
+## Config Directory Migration
+
+Source: `src-tauri/src/config.rs`
+
+The directory migration moves data from the legacy default directory
+`$HOME/.cc-switch` to the active app config directory.
+
+Trigger:
+
+- `get_app_config_dir()` runs `migrate_legacy_config_dir_if_needed()` once per
+ process when no config-dir env override forces a different legacy path.
+- The migration can also be checked via `legacy_config_migration_paths()` and
+ `check_legacy_config_dir_migration_needed()`.
+- Users can skip it with `skip_legacy_config_dir_migration()`.
+
+Target selection:
+
+- If `CC_SWITCH_TUI_CONFIG_DIR` is set, that is the target.
+- If deprecated `CC_SWITCH_CONFIG_DIR` is set, automatic legacy migration is not
+ attempted because the old env var already points at an explicit config dir.
+- Otherwise target is `$HOME/.cc-switch-tui`.
+
+Migration guard:
+
+- Source must be `$HOME/.cc-switch`, must exist, must be a directory, and must
+ contain at least one entry.
+- Source and target must not be the same path.
+- Target must either not exist or exist as an empty directory; if it only
+ contains an early-created `cc-switch.db`, migration is still allowed so legacy
+ JSON config can be copied.
+- Target must not contain `.migrated-from-cc-switch`. If a program-written
+ success marker already exists but the target is missing legacy `settings.json`
+ or `config.json`, startup may silently repair the missing JSON copy.
+
+Copied data:
+
+- Non-symlink files are copied without overwriting existing target files.
+- Non-symlink directories are copied recursively.
+- `backups/` is special-cased: only the three most recent non-symlink entries
+ are copied.
+- The old directory is preserved; it is never deleted by this migration.
+
+Marker:
+
+- On successful migration, the target directory receives
+ `.migrated-from-cc-switch`.
+- The marker text records the source path and timestamp.
+- If the user skips migration, the same marker path is written with
+ `User declined migration`.
+- The marker lives in the target directory, not the old directory.
+
+Failure behavior:
+
+- Migration failures are logged to stderr and do not block startup.
+- Because the old directory is preserved, failure should not destroy existing
+ data.
+
+## App State Startup Migrations
+
+Source: `src-tauri/src/store.rs`
+
+Most local data migrations happen through `AppState::try_new()`.
+
+Paths:
+
+- `cc-switch.db`, `config.json`, and `skills.json` are all resolved under the
+ active app config directory from `get_app_config_dir()`.
+
+If `cc-switch.db` already exists:
+
+- The DB is opened through `Database::init()`.
+- Runtime state is exported from SQLite into `MultiAppConfig`.
+- Legacy Codex provider config normalization runs.
+- Common-config upstream semantics migration runs if needed.
+- Legacy `config.json` is not imported again.
+
+If `cc-switch.db` does not exist:
+
+- Existing `config.json` is validated before creating the DB.
+- Existing `skills.json` is validated before creating the DB.
+- `Database::init()` creates the SQLite database.
+- If legacy `config.json` exists, `Database::migrate_from_json()` imports it.
+- Imported `config.json` is archived as `config.json.migrated`.
+- If legacy `skills.json` exists, its sync method, repos, installed-skill rows,
+ and SSOT pending flag are imported.
+- Imported `skills.json` is archived as `skills.json.migrated`.
+- Default skill repos are inserted if missing.
+- Runtime state is exported from DB into `MultiAppConfig`.
+- Legacy Codex provider config normalization runs.
+- Common-config upstream semantics migration runs if needed.
+
+## `config.json` to SQLite Migration
+
+Source: `src-tauri/src/database/migration.rs`
+
+`Database::migrate_from_json()` imports old `MultiAppConfig` data into SQLite.
+
+Imported data:
+
+- Providers, including current-provider flags.
+- Provider endpoints from provider metadata.
+- MCP servers and their per-app enabled flags.
+- Prompts for Claude, Codex, and Gemini.
+- Skill repos.
+- Common config snippets.
+
+Not directly imported:
+
+- Legacy `skills.skills` install state is not imported from `config.json`
+ because it is not complete enough to guarantee SSOT consistency. Skill
+ recovery is handled by the skill service scan/import paths and by the
+ `skills_ssot_migration_pending` flow.
+
+## SQLite Schema Migrations
+
+Source: `src-tauri/src/database/schema.rs`
+
+`Database::init()` applies DB schema migrations on open/create.
+
+Important side effect:
+
+- The DB schema migration from v2 to v3 sets the DB setting
+ `skills_ssot_migration_pending=true`, so the skill service can perform the
+ SSOT migration later.
+
+## Common Config Upstream Semantics Migration
+
+Source: `src-tauri/src/services/provider/common_config.rs`
+
+This is a one-time DB-backed migration for provider common-config behavior.
+
+Marker:
+
+- DB setting key: `common_config_upstream_semantics_migrated_v1`
+- When the setting is `true`, the migration is skipped.
+
+Behavior:
+
+- Applies only to Claude, Codex, and Gemini.
+- If an app has a non-empty common config snippet, providers without an explicit
+ `meta.apply_common_config` value are treated as `true`.
+- Provider stored settings are normalized so provider-specific storage does not
+ redundantly embed common config.
+- Updated providers are saved back to SQLite.
+
+Failure behavior:
+
+- Errors are returned to startup callers. This is not a best-effort background
+ migration.
+
+## Skills SSOT Migration
+
+Source: `src-tauri/src/services/skill.rs`
+
+The skill system uses a single source-of-truth directory under the active app
+config directory plus DB metadata.
+
+Pending flag:
+
+- DB setting key: `skills_ssot_migration_pending`
+
+Trigger:
+
+- `SkillService::load_index()` reads the pending flag.
+- `SkillService::migrate_ssot_if_pending()` performs the migration when the flag
+ is set.
+
+Behavior:
+
+- If the DB already has managed skill records, migration only backfills SSOT
+ directories for those managed records where possible.
+- If there are no managed skill records, migration scans supported app skill
+ directories and copies discovered skills into SSOT.
+- After migration or best-effort backfill, the pending flag is cleared.
+
+Safety rule:
+
+- When managed records already exist, the migration does not automatically claim
+ every app-local skill directory as managed. This avoids surprising users by
+ taking ownership of directories that were not previously managed by
+ cc-switch-tui.
+
+## Claude MCP Override Migration
+
+Source: `src-tauri/src/claude_mcp.rs`
+
+This migration handles Claude MCP JSON path changes when the Claude config
+directory is overridden.
+
+Trigger:
+
+- `user_config_path()` calls `ensure_mcp_override_migrated()`.
+
+Behavior:
+
+- If no Claude config override is set, no migration runs.
+- If the new derived MCP path already exists, no migration runs.
+- If default `$HOME/.claude.json` exists and the derived override path is
+ missing, the default file is copied to the derived path.
+
+Failure behavior:
+
+- Directory creation or copy errors are logged as warnings and do not panic.
+
+## WebDAV Sync Protocol Versions
+
+Source: `src-tauri/src/services/webdav_sync/mod.rs`
+
+WebDAV `V1` and `V2` describe remote sync data formats, not app release
+versions.
+
+### V1 Remote Layout
+
+Path shape:
+
+- `{remote_root}/v1/{profile}/`
+
+Manifest:
+
+- `manifest.json`
+- `format = "cc-switch-webdav-sync"`
+- `version = 1`
+- artifacts:
+ - `dbSql`
+ - `skillsZip`
+ - `settingsSync`
+
+The default file name for the legacy settings artifact is
+`settings.sync.json`, but the V1 manifest artifact path is authoritative. If the
+manifest says `settings-sync.json`, that path is used.
+
+### V2 Remote Layout
+
+Current path shape:
+
+- `{remote_root}/v2/db-v6/{profile}/`
+
+Legacy V2 fallback path shape:
+
+- `{remote_root}/v2/{profile}/`
+
+Manifest:
+
+- `manifest.json`
+- `format = "cc-switch-webdav-sync"`
+- `version = 2`
+- `dbCompatVersion = 6` for current layout
+- artifacts:
+ - `db.sql`
+ - `skills.zip`
+ - `settings.json`
+
+The manifest is uploaded last. Artifacts are uploaded first so a visible
+manifest only points at data that should already be present.
+
+## WebDAV Upload
+
+Source: `src-tauri/src/services/webdav_sync/mod.rs`
+
+Trigger:
+
+- CLI/TUI upload action calls `WebDavSyncService::upload()`.
+- V1 to V2 migration also calls upload after applying V1 data locally.
+
+Behavior:
+
+- Reads current WebDAV settings from `settings.json` through
+ `get_webdav_sync_settings()`.
+- Ensures the current V2 remote directory exists.
+- Builds a local snapshot:
+ - exports SQLite sync SQL as `db.sql`
+ - zips skill SSOT as `skills.zip`
+ - serializes current app settings as `settings.json`
+ - builds a manifest with artifact hashes and sizes
+- Uploads artifacts:
+ - `db.sql`
+ - `skills.zip`
+ - `settings.json`
+ - `manifest.json` last
+- Reads back the manifest and verifies bytes match.
+- Fetches manifest ETag best-effort.
+- Persists sync success status best-effort.
+- Cleans up V1 remote data best-effort after successful upload.
+
+## WebDAV Download
+
+Source: `src-tauri/src/services/webdav_sync/mod.rs`
+
+Trigger:
+
+- CLI/TUI download action calls `WebDavSyncService::download()`.
+
+Behavior:
+
+- Looks for a V2 snapshot in current layout first.
+- Falls back to legacy V2 layout.
+- If no V2 data exists but a V1 manifest is detected, returns
+ `SyncDecision::V1MigrationNeeded` so UI can ask for confirmation.
+- Validates protocol format, protocol version, and DB compatibility.
+- Downloads and verifies required artifacts:
+ - `db.sql`
+ - `skills.zip`
+- Downloads and verifies `settings.json` when the manifest contains it.
+- Acquires the restore mutation guard.
+- Refuses restore when proxy runtime or takeover state makes restore unsafe.
+- Applies DB and skills as one restore unit:
+ - backs up current skills
+ - restores skills zip
+ - imports SQL into SQLite
+ - rolls back skills if DB import fails
+- Applies downloaded settings if present, but preserves the current local
+ WebDAV connection settings. This prevents a restore from replacing the
+ WebDAV URL/credentials used to perform the restore.
+- Persists sync success status best-effort.
+- Cleans up V1 remote data best-effort.
+
+## WebDAV V1 to V2 Migration
+
+Source: `src-tauri/src/services/webdav_sync/mod.rs`
+
+Trigger:
+
+- CLI: `config webdav migrate-v1-to-v2`
+- TUI: user confirms the V1 migration prompt.
+- Programmatic entry: `WebDavSyncService::migrate_v1_to_v2()`
+
+Behavior:
+
+1. Load local WebDAV connection settings.
+2. Detect and download the V1 manifest.
+3. Refuse migration if restore is unsafe, for example local proxy takeover is
+ active.
+4. Download and verify V1 artifacts:
+ - `dbSql`
+ - `skillsZip`
+ - `settingsSync`
+5. Parse V1 `settingsSync` into syncable app settings:
+ - language
+ - skill sync method
+ - security settings
+ - Claude custom endpoints
+ - Codex custom endpoints
+6. Acquire the restore mutation guard.
+7. Apply DB and skills snapshot locally.
+8. Apply V1 syncable settings locally while preserving the current WebDAV
+ connection settings.
+9. Drop the restore guard.
+10. Upload the local state as V2:
+ - `db.sql`
+ - `skills.zip`
+ - `settings.json`
+ - `manifest.json`
+11. Cleanup of the old V1 remote directory is best-effort.
+
+Important nuance:
+
+- V1 `settingsSync` is not itself uploaded as a V2 artifact.
+- Instead, it is applied to local `settings.json`, then current app settings are
+ serialized and uploaded as V2 `settings.json`.
+
+## WebDAV Restore Safety
+
+Source: `src-tauri/src/services/webdav_sync/mod.rs`
+
+Download and V1 migration both call restore safety checks before mutating local
+state.
+
+Restore is refused when:
+
+- The managed proxy runtime is running.
+- Any proxy takeover state is active.
+
+Reason:
+
+- Restoring DB/live state while proxy takeover is active can leave live client
+ config and DB state out of sync.
+
+## Current Implementation Gotchas
+
+- `settings.json` is app settings, not Claude/Gemini live settings.
+- `settings.json` location follows `get_app_config_dir()`. Do not hard-code
+ `$HOME/.cc-switch-tui/settings.json`.
+- WebDAV remote V1/V2 naming is protocol-level; do not explain it as a software
+ version.
+- The config directory migration marker is `.migrated-from-cc-switch` and lives
+ in the active target directory.
+- `config.json.migrated` and `skills.json.migrated` are archive names for local
+ DB import, separate from the config-directory marker.
+- WebDAV V2 manifest compatibility checks are strict for current layout and
+ tolerate legacy V2 layout by treating missing DB compat as the old compatible
+ generation.
diff --git a/docs/cc-switch-tui/migration-logic.zh.md b/docs/cc-switch-tui/migration-logic.zh.md
new file mode 100644
index 00000000..1e772ec4
--- /dev/null
+++ b/docs/cc-switch-tui/migration-logic.zh.md
@@ -0,0 +1,358 @@
+# cc-switch-tui 迁移逻辑
+
+最后更新:2026-05-11
+
+本文档记录会影响本地配置、SQLite 状态和 WebDAV 同步数据的迁移行为。它用于给后续修改提供实现上下文;如果行为发生变化,以源码为最终依据。
+
+## 重要术语
+
+- 应用配置目录:由 `src-tauri/src/config.rs` 中的 `get_app_config_dir()` 解析。
+- 不要假定应用配置目录一定是 `~/.cc-switch-tui`。
+- 设置了 `CC_SWITCH_TUI_CONFIG_DIR` 时,它拥有最高优先级。
+- `CC_SWITCH_CONFIG_DIR` 是已废弃的旧覆盖变量,但仍然兼容。
+- 默认应用配置目录是 `$HOME/.cc-switch-tui`。
+- 旧应用配置目录是 `$HOME/.cc-switch`。
+- WebDAV `V1` 和 `V2` 是同步协议/远端数据格式版本,不是 cc-switch-tui 的软件版本。
+
+## 配置目录迁移
+
+源码:`src-tauri/src/config.rs`
+
+目录迁移会把旧默认目录 `$HOME/.cc-switch` 中的数据迁移到当前生效的应用配置目录。
+
+触发条件:
+
+- `get_app_config_dir()` 会在每个进程内调用一次 `migrate_legacy_config_dir_if_needed()`,前提是没有配置目录环境变量让旧路径含义发生变化。
+- 也可以通过 `legacy_config_migration_paths()` 和 `check_legacy_config_dir_migration_needed()` 检查是否需要迁移。
+- 用户可以通过 `skip_legacy_config_dir_migration()` 跳过迁移。
+
+目标目录选择:
+
+- 如果设置了 `CC_SWITCH_TUI_CONFIG_DIR`,它就是目标目录。
+- 如果设置了已废弃的 `CC_SWITCH_CONFIG_DIR`,不会尝试自动旧目录迁移,因为这个旧环境变量本身已经指向一个显式配置目录。
+- 否则目标目录是 `$HOME/.cc-switch-tui`。
+
+迁移前置条件:
+
+- 源目录必须是 `$HOME/.cc-switch`,必须存在,必须是目录,并且至少包含一个条目。
+- 源目录和目标目录不能是同一路径。
+- 目标目录要么不存在,要么是空目录;如果目标目录只有启动早期创建的
+ `cc-switch.db`,仍允许迁移补拷贝旧目录中的 JSON 配置。
+- 目标目录不能包含 `.migrated-from-cc-switch`。如果已存在程序写入的成功迁移标志,
+ 但目标目录缺少旧目录里的 `settings.json` 或 `config.json`,启动时允许静默补拷贝。
+
+复制的数据:
+
+- 非符号链接文件会被复制,但不会覆盖目标目录已有文件。
+- 非符号链接目录会被递归复制。
+- `backups/` 有特殊处理:只复制最近的三个非符号链接条目。
+- 旧目录会被保留;这个迁移永远不会删除旧目录。
+
+标志文件:
+
+- 迁移成功后,目标目录会写入 `.migrated-from-cc-switch`。
+- 标志文件内容会记录源路径和时间戳。
+- 如果用户跳过迁移,也会在同一个标志文件路径写入 `User declined migration`。
+- 标志文件位于目标目录,不在旧目录。
+
+失败行为:
+
+- 迁移失败只会写入 stderr 日志,不会阻塞启动。
+- 因为旧目录会保留,失败不应破坏已有数据。
+
+## AppState 启动迁移
+
+源码:`src-tauri/src/store.rs`
+
+大部分本地数据迁移都通过 `AppState::try_new()` 完成。
+
+路径:
+
+- `cc-switch.db`、`config.json` 和 `skills.json` 都位于 `get_app_config_dir()` 解析出来的当前应用配置目录下。
+
+如果 `cc-switch.db` 已存在:
+
+- 通过 `Database::init()` 打开数据库。
+- 从 SQLite 导出运行时状态为 `MultiAppConfig`。
+- 执行旧 Codex provider 配置规范化。
+- 如有需要,执行 common config 上游语义迁移。
+- 不会再次导入旧 `config.json`。
+
+如果 `cc-switch.db` 不存在:
+
+- 创建 DB 前会先校验已有的 `config.json`。
+- 创建 DB 前会先校验已有的 `skills.json`。
+- `Database::init()` 创建 SQLite 数据库。
+- 如果存在旧 `config.json`,通过 `Database::migrate_from_json()` 导入。
+- 导入后的 `config.json` 会归档为 `config.json.migrated`。
+- 如果存在旧 `skills.json`,会导入它的同步方式、仓库、已安装 skill 记录和 SSOT pending 标志。
+- 导入后的 `skills.json` 会归档为 `skills.json.migrated`。
+- 缺失的默认 skill 仓库会被补齐。
+- 从 DB 导出运行时状态为 `MultiAppConfig`。
+- 执行旧 Codex provider 配置规范化。
+- 如有需要,执行 common config 上游语义迁移。
+
+## `config.json` 到 SQLite 的迁移
+
+源码:`src-tauri/src/database/migration.rs`
+
+`Database::migrate_from_json()` 会把旧 `MultiAppConfig` 数据导入 SQLite。
+
+会导入的数据:
+
+- Providers,包括当前 provider 标志。
+- Provider metadata 中的 provider endpoints。
+- MCP servers 及其各应用启用标志。
+- Claude、Codex 和 Gemini 的 prompts。
+- Skill repos。
+- Common config snippets。
+
+不会直接导入的数据:
+
+- 旧 `config.json` 里的 `skills.skills` 安装状态不会直接导入,因为这些数据不足以保证 SSOT 一致性。Skill 恢复由 skill service 的扫描/导入路径以及 `skills_ssot_migration_pending` 流程处理。
+
+## SQLite Schema 迁移
+
+源码:`src-tauri/src/database/schema.rs`
+
+`Database::init()` 会在打开或创建数据库时应用 DB schema migrations。
+
+重要副作用:
+
+- DB schema 从 v2 迁移到 v3 时,会设置 DB setting `skills_ssot_migration_pending=true`,让 skill service 后续执行 SSOT 迁移。
+
+## Common Config 上游语义迁移
+
+源码:`src-tauri/src/services/provider/common_config.rs`
+
+这是 provider common-config 行为的一次性 DB-backed 迁移。
+
+标志:
+
+- DB setting key:`common_config_upstream_semantics_migrated_v1`
+- 当该 setting 为 `true` 时,跳过迁移。
+
+行为:
+
+- 仅适用于 Claude、Codex 和 Gemini。
+- 如果某个 app 有非空 common config snippet,而 provider 没有显式 `meta.apply_common_config` 值,则把它视为 `true`。
+- 规范化 provider 存储配置,避免 provider-specific storage 重复嵌入 common config。
+- 更新后的 providers 会保存回 SQLite。
+
+失败行为:
+
+- 错误会返回给启动调用方。这不是 best-effort 后台迁移。
+
+## Skills SSOT 迁移
+
+源码:`src-tauri/src/services/skill.rs`
+
+Skill 系统使用当前应用配置目录下的单一来源目录,加上 DB metadata。
+
+Pending 标志:
+
+- DB setting key:`skills_ssot_migration_pending`
+
+触发条件:
+
+- `SkillService::load_index()` 读取 pending 标志。
+- 当标志被设置时,`SkillService::migrate_ssot_if_pending()` 执行迁移。
+
+行为:
+
+- 如果 DB 中已经有 managed skill records,迁移只会尽力为这些已管理记录回填 SSOT 目录。
+- 如果没有 managed skill records,迁移会扫描受支持 app 的 skill 目录,并把发现的 skills 复制到 SSOT。
+- 迁移或 best-effort 回填后,pending 标志会被清除。
+
+安全规则:
+
+- 当已存在 managed records 时,迁移不会自动把每个 app-local skill 目录都认领为 managed。这可以避免意外接管原本不由 cc-switch-tui 管理的用户目录。
+
+## Claude MCP Override 迁移
+
+源码:`src-tauri/src/claude_mcp.rs`
+
+这个迁移处理 Claude config 目录被覆盖时 Claude MCP JSON 路径的变化。
+
+触发条件:
+
+- `user_config_path()` 会调用 `ensure_mcp_override_migrated()`。
+
+行为:
+
+- 如果没有设置 Claude config override,不执行迁移。
+- 如果新的派生 MCP 路径已经存在,不执行迁移。
+- 如果默认 `$HOME/.claude.json` 存在,而派生 override 路径不存在,则把默认文件复制到派生路径。
+
+失败行为:
+
+- 创建目录或复制失败只会记录 warning,不会 panic。
+
+## WebDAV 同步协议版本
+
+源码:`src-tauri/src/services/webdav_sync/mod.rs`
+
+WebDAV `V1` 和 `V2` 表示远端同步数据格式,不表示 app 发布版本。
+
+### V1 远端布局
+
+路径形态:
+
+- `{remote_root}/v1/{profile}/`
+
+Manifest:
+
+- `manifest.json`
+- `format = "cc-switch-webdav-sync"`
+- `version = 1`
+- artifacts:
+ - `dbSql`
+ - `skillsZip`
+ - `settingsSync`
+
+旧 settings artifact 的默认文件名是 `settings.sync.json`,但 V1 manifest 中记录的 artifact path 是权威来源。如果 manifest 写的是 `settings-sync.json`,就使用该路径。
+
+### V2 远端布局
+
+当前路径形态:
+
+- `{remote_root}/v2/db-v6/{profile}/`
+
+旧 V2 fallback 路径形态:
+
+- `{remote_root}/v2/{profile}/`
+
+Manifest:
+
+- `manifest.json`
+- `format = "cc-switch-webdav-sync"`
+- `version = 2`
+- 当前布局下 `dbCompatVersion = 6`
+- artifacts:
+ - `db.sql`
+ - `skills.zip`
+ - `settings.json`
+
+Manifest 最后上传。Artifacts 先上传,确保可见 manifest 指向的数据已经存在。
+
+## WebDAV Upload
+
+源码:`src-tauri/src/services/webdav_sync/mod.rs`
+
+触发条件:
+
+- CLI/TUI upload action 调用 `WebDavSyncService::upload()`。
+- V1 到 V2 迁移在本地应用 V1 数据后也会调用 upload。
+
+行为:
+
+- 通过 `get_webdav_sync_settings()` 从 `settings.json` 读取当前 WebDAV 设置。
+- 确保当前 V2 远端目录存在。
+- 构建本地快照:
+ - 导出 SQLite sync SQL 为 `db.sql`
+ - 把 skill SSOT 打包为 `skills.zip`
+ - 把当前 app settings 序列化为 `settings.json`
+ - 用 artifact hashes 和 sizes 构建 manifest
+- 上传 artifacts:
+ - `db.sql`
+ - `skills.zip`
+ - `settings.json`
+ - 最后上传 `manifest.json`
+- 读取远端 manifest 并校验字节完全一致。
+- Best-effort 获取 manifest ETag。
+- Best-effort 持久化同步成功状态。
+- 上传成功后 best-effort 清理 V1 远端数据。
+
+## WebDAV Download
+
+源码:`src-tauri/src/services/webdav_sync/mod.rs`
+
+触发条件:
+
+- CLI/TUI download action 调用 `WebDavSyncService::download()`。
+
+行为:
+
+- 优先查找当前布局中的 V2 snapshot。
+- 回退查找旧 V2 布局。
+- 如果没有 V2 数据但检测到 V1 manifest,返回 `SyncDecision::V1MigrationNeeded`,让 UI 询问用户是否迁移。
+- 校验 protocol format、protocol version 和 DB compatibility。
+- 下载并校验必需 artifacts:
+ - `db.sql`
+ - `skills.zip`
+- 当 manifest 包含 `settings.json` 时,下载并校验它。
+- 获取 restore mutation guard。
+- 当 proxy runtime 或 takeover 状态导致恢复不安全时,拒绝恢复。
+- 以一个恢复单元应用 DB 和 skills:
+ - 备份当前 skills
+ - 恢复 skills zip
+ - 将 SQL 导入 SQLite
+ - 如果 DB 导入失败,回滚 skills
+- 如果下载到了 settings,则应用它,但保留当前本地 WebDAV 连接设置。这可以避免 restore 覆盖正在使用的 WebDAV URL/credentials。
+- Best-effort 持久化同步成功状态。
+- Best-effort 清理 V1 远端数据。
+
+## WebDAV V1 到 V2 迁移
+
+源码:`src-tauri/src/services/webdav_sync/mod.rs`
+
+触发条件:
+
+- CLI:`config webdav migrate-v1-to-v2`
+- TUI:用户确认 V1 migration prompt。
+- 程序入口:`WebDavSyncService::migrate_v1_to_v2()`
+
+行为:
+
+1. 读取本地 WebDAV 连接设置。
+2. 检测并下载 V1 manifest。
+3. 如果 restore 不安全则拒绝迁移,例如本地 proxy takeover 处于 active 状态。
+4. 下载并校验 V1 artifacts:
+ - `dbSql`
+ - `skillsZip`
+ - `settingsSync`
+5. 把 V1 `settingsSync` 解析为可同步 app settings:
+ - language
+ - skill sync method
+ - security settings
+ - Claude custom endpoints
+ - Codex custom endpoints
+6. 获取 restore mutation guard。
+7. 在本地应用 DB 和 skills snapshot。
+8. 在本地应用 V1 syncable settings,同时保留当前 WebDAV 连接设置。
+9. 释放 restore guard。
+10. 把本地状态上传为 V2:
+ - `db.sql`
+ - `skills.zip`
+ - `settings.json`
+ - `manifest.json`
+11. Best-effort 清理旧 V1 远端目录。
+
+重要细节:
+
+- V1 `settingsSync` 本身不会作为 V2 artifact 上传。
+- 它会先应用到本地 `settings.json`,然后当前 app settings 会被序列化并作为 V2 `settings.json` 上传。
+
+## WebDAV Restore 安全检查
+
+源码:`src-tauri/src/services/webdav_sync/mod.rs`
+
+Download 和 V1 migration 在修改本地状态前都会执行 restore 安全检查。
+
+以下情况会拒绝 restore:
+
+- managed proxy runtime 正在运行。
+- 任意 proxy takeover 状态处于 active。
+
+原因:
+
+- 在 proxy takeover active 时恢复 DB/live state,可能导致 live client config 和 DB state 不一致。
+
+## 当前实现注意点
+
+- `settings.json` 是 app settings,不是 Claude/Gemini live settings。
+- `settings.json` 位置跟随 `get_app_config_dir()`。不要硬编码 `$HOME/.cc-switch-tui/settings.json`。
+- WebDAV 远端 V1/V2 命名是协议层概念,不要解释成软件版本。
+- 配置目录迁移标志文件是 `.migrated-from-cc-switch`,位于当前生效的目标目录。
+- `config.json.migrated` 和 `skills.json.migrated` 是本地 DB 导入后的归档文件名,和配置目录 marker 是两回事。
+- WebDAV V2 manifest compatibility 对当前布局严格校验;对旧 V2 布局会兼容缺失 DB compat 的情况,把它视为旧兼容代。
diff --git a/docs/cc-switch-tui/upstream-absorbed-commits.md b/docs/cc-switch-tui/upstream-absorbed-commits.md
new file mode 100644
index 00000000..5922e0fa
--- /dev/null
+++ b/docs/cc-switch-tui/upstream-absorbed-commits.md
@@ -0,0 +1,96 @@
+# 上游提交吸收记录
+
+本文件长期记录本仓库从上游 `SaladDay/cc-switch-cli` 吸收过的提交。
+
+本仓库的同步方向是:只从上游仓库合入到本仓库,不把本仓库的 fork 改动回推到上游。
+
+## 记录规则
+
+- **精确合入**:上游 commit hash 是当前分支祖先,或 patch-id 与本仓库提交等价。
+- **语义吸收**:没有保留上游原 hash,也不是干净 cherry-pick,但本仓库提交明确吸收了该上游功能。
+- **部分覆盖**:本仓库用独立提交实现了相近能力。该状态不等于已合入上游提交,后续继续合上游时需要人工对照。
+- 记录时优先写清楚上游 commit、本仓库吸收 commit、吸收状态、范围说明和注意事项。
+
+## 2026-05-20 快照
+
+- 上游:`SaladDay/cc-switch-cli main`
+- 上游 ref:`saladday/main` at `26360ae3`
+- 本仓库分支:`fit/merge-forked`
+- 本仓库 HEAD:`f1d3a3ab`
+- 核查结论:当时上游新增的 32 个非 merge 提交中,没有任何一个以原 hash 精确合入;已经使用的功能主要由本仓库聚合同步提交 `ab169b4a` 语义吸收。
+
+### 已语义吸收
+
+| 上游提交 | 上游标题 | 本仓库提交 | 状态 | 范围说明 |
+| --- | --- | --- | --- | --- |
+| `af3b291f` | `feat(cli): add failover management commands (#165)` | `ab169b4a` | 语义吸收 | 新增 CLI 故障转移管理命令,包括查看、启用、禁用、队列增删、排序和清空。当前代码保留在 `src-tauri/src/cli/commands/failover.rs`。 |
+| `397741c5` | `(fix) improve DeepSeek model and reasoning compatibility` | `ab169b4a` | 语义吸收 | 吸收 DeepSeek / reasoning 兼容逻辑,包括 OpenAI chat transform、streaming `reasoning_content` alias、模型列表候选 URL 处理等。 |
+| `e7725913` | `feat(tui): add readline text editing shortcuts` | `ab169b4a` | 语义吸收 | 吸收 TUI 文本编辑快捷键,包含 `Ctrl+A/E/U/K/W`、`Alt+B/F` 等;当前核心实现为 `src-tauri/src/cli/tui/text_edit.rs`。 |
+| `92ab4425` | `fix(database): improve future schema error` | `ab169b4a` | 语义吸收 | 吸收数据库 future schema 检查和错误提示改进,避免新版本数据库被旧程序继续迁移。 |
+| `f80a0695` | `Refine provider TUI actions` | `ab169b4a` | 语义吸收 | 吸收供应商页动作区、详情、快捷键和导入当前配置相关体验调整,并在本仓库内适配 Hermes / OpenClaw 等 fork 扩展。 |
+| `0c6f9a65` | `Add provider empty state` | `ab169b4a` | 语义吸收 | 吸收供应商空状态,包括无供应商时的导入当前配置和添加供应商入口。后续本仓库提交 `49a0a921` 又针对 Codex 空状态做了本地扩展。 |
+| `5c6d373f` | `Fix broken internal documentation links (#167)` | `9b205d20` | 语义吸收 | 手工吸收内部文档链接修复:将不存在的 `CLAUDE.md` 链接改为 README 链接,并修正 v3.6.0 / v3.6.1 中文 release note 指向同目录英文版本。 |
+| `d36070bf` | `(tui)refine footer shortcuts` | `9b205d20` | 语义吸收 | 合入 TUI footer 快捷键压缩展示,移除 `NAV` / `ACT` 标签并优先展示 proxy 开关入口,改善窄中文终端可见性。 |
+| `371f4222` | `(prompt)stabilize prompt list order` | `9b205d20` | 语义吸收 | 合入 prompt 列表稳定排序:按 `created_at` 正序并用 id 兜底,避免仅因 `updated_at` 变化导致列表跳动。 |
+| `73b7c3c1` | `fix(webdav): avoid upload readback checks` | `20c949fa` | 语义吸收 | 合入 WebDAV 上传策略修复:去掉 check connection 的 probe 写读删、去掉 upload 后 manifest GET readback 校验,保留 manifest HEAD 作为 best-effort metadata,并避免普通上传触发旧 V1 远端清理。 |
+
+### 部分覆盖但未合入原上游提交
+
+| 上游提交 | 上游标题 | 本仓库相关提交 | 状态 | 注意事项 |
+| --- | --- | --- | --- | --- |
+| `d3c240c5` | `feat: add CODEX_HOME support (#179)` | `3c46a327` | 部分覆盖 | 上游原提交未合入;本仓库独立实现了 Codex MCP live sync 对 `CODEX_HOME` 的支持。后续合上游时应避免重复覆盖路径解析逻辑。 |
+| `83307151` | `Improve failover proxy UX` | `c0f5cb52`, `f1d3a3ab` | 部分覆盖 | 本仓库已有早期故障转移控制和 proxy inactive guard 修复,但没有完整吸收上游新增的 `failover_policy.rs`、自动开启 proxy+failover、停 proxy 清理 failover 等整套 UX。 |
+| `65c4dc75` 到 `d160b168` | provider common config 系列重构 | `0f510eb6`, `c4409be5` 等 | 部分覆盖 | 本仓库已有更早的 common config 体系;2026-05-15 上游 common config 重构系列未作为提交合入。继续合上游时需要逐项对照语义。 |
+
+### 本轮明确暂不处理
+
+| 上游提交 | 上游标题 | 状态 | 原因 |
+| --- | --- | --- | --- |
+| `64cbca79` | `(docs) update RightCode rebate to 5%` | 暂不合入 | 该提交仅调整 RightCode 赞助/返利文案,从 25% 改为 5%,不涉及功能或兼容性;本 fork 是否沿用上游营销信息需要另行确认,本轮按指令跳过。 |
+| `d3c240c5` | `feat: add CODEX_HOME support (#179)` | 暂不合入 | 本仓库已通过 `3c46a327` 部分覆盖 CODEX_HOME live sync 支持,且当前实现有意采用 `CODEX_HOME` 优先于手动覆盖、支持 `~` 展开、且不要求目录预先存在;上游提交的优先级和存在性判断不同,直接合入会改变现有行为。 |
+
+### 本轮高风险暂不处理
+
+| 上游提交 | 上游标题 | 状态 | 原因 |
+| --- | --- | --- | --- |
+| `83307151` | `Improve failover proxy UX` | 暂不合入 | 覆盖 proxy start/stop、failover policy、数据库 schema/DAO、provider routing 和 TUI 多处入口;本仓库已有独立 failover guard 修复,直接套上游 patch 会与当前 UX 和 fork 扩展产生多处冲突。 |
+| `6ff4f888`, `8afd9075`, `d3810be2`, `3fa27235` | prompt 服务和 prompt 编辑系列 | 暂不合入 | 这组涉及 SQLite prompt service、prompt identity、add/edit form 统一和导入确认;当前本仓库缺少上游新增的 prompt form 文件结构,且 `services/prompt.rs` 与 TUI content/form handler 存在冲突,需要作为独立专题迁移。 |
+| `65c4dc75` 到 `d160b168` | provider common config 系列重构 | 暂不合入 | 这组改动 provider live/common config 写入、CLI 命令、TUI editor 和 settings 持久化,且与本仓库现有 Hermes/OpenClaw/provider common config 扩展冲突;需要先定义 fork 行为边界再拆分吸收。 |
+| `a1dd240a` | `(tui)add usage query configuration` | 暂不合入 | 该提交新增 Copilot auth、balance/coding plan 服务、usage query 配置 UI 和大量 provider form 状态,变更面超过 6000 行并与当前 TUI settings/form 状态冲突,不适合和 WebDAV 修复同批合入。 |
+
+### 本轮很高风险复核
+
+复核方法:逐个查看上游 diff/stat,用 `git apply --check --3way` 在当前分支上试套 patch,并对照当前 fork 的核心实现。结论是本轮不合入代码,只记录后续迁移边界。
+
+| 上游提交 | 风险焦点 | 复核结论 |
+| --- | --- | --- |
+| `83307151` | failover / proxy UX、proxy 持久化开关、provider 路由、数据库 DAO 和 TUI 设置页。 | 不直接合入。该提交改动 37 个文件,新增 `cli/failover_policy.rs`,并把“开启自动故障转移”升级为可能自动开启 proxy、切换到队列头 provider、关闭 proxy 时清理 auto failover。试套时 `cli/commands/failover.rs`、`cli/i18n.rs`、`content_entities.rs`、`runtime_actions/settings.rs`、`ui/providers.rs` 等关键 TUI/命令入口冲突;更重要的是它会改变当前 fork 已有的 proxy inactive guard、managed external proxy session、live config/current provider 同步语义。后续应作为独立 failover 迁移:先定行为规格,再迁 service/DAO 测试,最后迁 TUI。 |
+| `6ff4f888`, `8afd9075`, `d3810be2`, `3fa27235` | prompt 存储从 config 快照转向 SQLite、prompt identity 编辑、add/edit form 统一、导入前确认。 | 不直接合入。`6ff4f888` 会删除 `store.rs` 对 prompts 的持久化同步,并让 `PromptService` 直接读写 DB;当前 fork 虽已有 `prompts` 表和 store 同步,但 service 仍以 `MultiAppConfig` 快照为主。后续 UI 提交还新增 `cli/tui/app/form_handlers/prompt.rs`、`cli/tui/form/prompt.rs`、`cli/tui/ui/forms/prompt.rs`,这些文件当前树不存在,试套后在 prompt service、content_entities、form/tab/runtime_actions 多处冲突。后续应先把 PromptService DB-first 作为单独迁移并补齐 stale-config/DB 优先级测试,再处理 prompt 表单结构。 |
+| `65c4dc75` 到 `d160b168` | provider common config 语义、provider snapshot 归一化、live config 写入、CLI common-config 命令、TUI editor/settings。 | 不直接合入。该系列从 `65c4dc75` 起就会重写 `common_config.rs` 的 `provider_uses_common_config` 判定、Codex common snippet 处理、startup live import 和 provider snapshot 迁移。当前 fork 已有 `common_config_upstream_semantics_migrated_v1` 迁移标记、Hermes/OpenClaw 扩展、Codex runtime-local key 处理和大量 provider tests;试套冲突集中在 `app_state.rs`、`provider_state.rs`、`services/provider/codex.rs`、`services/provider/common_config.rs`、`services/provider/mod.rs`、`store.rs`。其中 `a5914cdd` 虽小,但依赖前序 common config 语义,不适合单独摘。后续需要独立设计“上游 common config 语义”和本 fork additive app/provider 扩展的边界。 |
+| `a1dd240a` | usage query 配置、GitHub Copilot 托管认证、balance/coding plan 网络服务、provider form 状态、settings 持久化。 | 不直接合入。该提交改动 46 个文件,新增约 6800 行,包括 `proxy/providers/copilot_auth.rs`、`services/balance.rs`、`services/coding_plan.rs`,并改造 TUI provider 表单、usage script credential 解析和 settings。当前 fork 只有 `services/subscription.rs`、`services/provider/usage.rs` 和既有 usage_script 边界校验;试套冲突落在 TUI 状态机、overlay、settings、provider tests 等位置。该功能还引入外部网络认证、token/account 持久化和多个第三方 API 查询路径,安全与产品行为都需要单独审查,不应作为上游吸收子项混入。 |
+
+### 尚未吸收的上游新增提交
+
+以下提交截至本快照未发现精确合入或明确语义吸收记录:
+
+`fc3b95d1`, `83307151`, `253ce370`, `e3ff1689`, `6ff4f888`, `8afd9075`, `d3810be2`, `50fcb8cd`, `3fa27235`, `564558a2`, `4a292849`, `65c4dc75`, `ee155e69`, `a5914cdd`, `fa96c245`, `8e311ee4`, `d160b168`, `a1dd240a`, `14856f68`, `26360ae3`。
+
+补充说明:`83307151`、`65c4dc75` 到 `d160b168`、`d3c240c5` 在上方标为“部分覆盖”,表示本仓库存在相关能力,但不视为已合入这些上游提交;`64cbca79`、`d3c240c5` 在本轮明确暂不处理;高风险暂不处理项见上表。
+
+## 维护方法
+
+新增记录前建议执行:
+
+```bash
+git fetch --no-tags https://github.com/SaladDay/cc-switch-cli.git main
+git update-ref refs/remotes/saladday/main FETCH_HEAD
+git cherry -v HEAD saladday/main
+git log --reverse --no-merges --date=short --format='%h %ad %s' $(git merge-base HEAD saladday/main)..saladday/main
+```
+
+判断某个上游提交是否已被吸收时,按以下顺序核查:
+
+1. `git merge-base --is-ancestor HEAD`,确认是否精确合入。
+2. `git cherry -v HEAD saladday/main`,确认是否有 patch-id 等价 cherry-pick。
+3. `git log --all -S '<关键符号或文案>'`,确认是否由本仓库提交语义吸收。
+4. 对照相关文件的当前实现,区分“已吸收”和“部分覆盖”。
diff --git a/docs/new-name-tui/rename-to-cc-switch-tui.md b/docs/new-name-tui/rename-to-cc-switch-tui.md
new file mode 100644
index 00000000..d8b8c80b
--- /dev/null
+++ b/docs/new-name-tui/rename-to-cc-switch-tui.md
@@ -0,0 +1,128 @@
+# Rename cc-switch-cli to cc-switch-tui
+
+## Motivation
+
+"cli" is misleading — this is a TUI application. "tui" better describes what it is.
+Also avoids name collision with the upstream `cc-switch-cli` repo.
+
+## Scope: Two Commits
+
+Commit 1 (core rename) + Commit 2 (docs).
+
+---
+
+## Commit 1: Core Rename
+
+### src-tauri/Cargo.toml
+
+- [x] `name = "cc-switch"` → `name = "cc-switch-tui"`
+- [x] All `[[bin]]` entries: `name = "cc-switch"` → `name = "cc-switch-tui"`
+- [x] `repository` URL: `SaladDay/cc-switch-cli` → `handy-sun/cc-switch-tui`
+- [x] (Keep `lib.name = "cc_switch_lib"` unchanged — avoids mass import churn)
+
+### src-tauri/src/config.rs — default config directory
+
+- [x] `get_app_config_dir()`: `.cc-switch` → `.cc-switch-tui`
+ - Isolates from upstream GUI `cc-switch` project (same DB file conflict)
+ - `CC_SWITCH_CONFIG_DIR` env var override still works as before
+
+### .github/workflows/release.yml
+
+- [x] Artifact directory names: `cc-switch-cli-*` → `cc-switch-tui-*`
+- [x] Release asset filenames: `cc-switch-cli-*` → `cc-switch-tui-*`
+- [x] Binary reference: `cc-switch.exe` → `cc-switch-tui.exe`
+- [x] Release title: `cc-switch-cli` → `cc-switch-tui`
+- [x] Repo URL: `SaladDay/cc-switch-cli` → `handy-sun/cc-switch-tui`
+
+### .github/workflows/rust-ci.yml
+
+- [x] Artifact name: `cc-switch-${{ matrix.target }}` → `cc-switch-tui-${{ matrix.target }}`
+
+### install.sh
+
+- [x] `REPO="SaladDay/cc-switch-cli"` → `REPO="handy-sun/cc-switch-tui"`
+- [x] All asset name patterns: `cc-switch-cli-*` → `cc-switch-tui-*`
+- [x] Binary path references
+
+### scripts/generate_latest_json.py
+
+- [x] Asset filename patterns: `cc-switch-cli-*` → `cc-switch-tui-*`
+
+### flake.nix (both)
+
+- [x] `flake.nix`: package name, description references
+- [x] `src-tauri/flake.nix`: same
+
+### src-tauri/src/cli/commands/update.rs
+
+- [x] All asset name strings: `cc-switch-cli-*` → `cc-switch-tui-*`
+- [x] `tagged_asset_name()`: strip_prefix + format strings
+
+### src-tauri/src/cli/commands/update/tests.rs
+
+- [x] All asset name strings: `cc-switch-cli-*` → `cc-switch-tui-*`
+- [x] Repo URLs: `saladday/cc-switch-cli` → `handy-sun/cc-switch-tui`
+
+### src-tauri/tests/install_script.rs
+
+- [x] All asset name strings: `cc-switch-cli-*` → `cc-switch-tui-*`
+
+### Tests (rebuild verification)
+
+- [x] Fix `CARGO_BIN_EXE_cc-switch` → `CARGO_BIN_EXE_cc-switch-tui` (separate task)
+
+---
+
+## Commit 2: Documentation
+
+### README.md + README_ZH.md
+
+- [x] All `cc-switch-cli` references → `cc-switch-tui`
+- [x] All `cc-switch` binary command examples → `cc-switch-tui`
+- [x] Install URLs: `SaladDay/cc-switch-cli` → `handy-sun/cc-switch-tui`
+- [x] Asset filename patterns
+
+### CHANGELOG.md
+
+- [x] Headline `cc-switch-cli` → `cc-switch-tui`
+- [x] Add rename entry for next version
+
+### AGENTS.md / CLAUDE.md (if they exist)
+
+- [x] Project references (check what's current)
+
+---
+
+## NOT Changed
+
+| What | Why |
+|------|-----|
+| `cc_switch_lib` crate name | Would touch every `use cc_switch_lib::...` line — massive diff, zero user-facing impact |
+| `com.ccswitch.desktop` in tauri.conf.json | GUI-only; this project dropped Tauri GUI |
+| Rust source code `"cc-switch"` string refs | Only if they're binary-name paths in docs/help text |
+| `docs/plans/`, `docs/design/`, `docs/superpowers/` | Historical documents — keep as-is for traceability |
+| `provider_templates.rs` PackyAPI promo code | Third-party registration code — must not change |
+
+---
+
+## GitHub Repo
+
+After commits are pushed: **rename the repo on GitHub** from `handy-sun/cc-switch-cli` to `handy-sun/cc-switch-tui`. GitHub auto-redirects old URLs.
+
+---
+
+## Breaking Change
+
+Users who run `cc-switch` from PATH will need to use `cc-switch-tui` instead.
+Users who have data in `~/.cc-switch/` will start with a fresh `~/.cc-switch-tui/`.
+
+## Auto-Migration
+
+On first run, if `~/.cc-switch/` exists and `CC_SWITCH_TUI_CONFIG_DIR` is not set:
+- Checks for `.migrated-from-cc-switch` marker in new directory
+- If absent: copies `config.json`, `skills/`, and 3 most recent `backups/`
+- Writes marker file on success; subsequent runs skip migration
+- Old directory preserved untouched; errors log to stderr, never block startup
+
+Implementation point: embedded in `get_app_config_dir()` with an `AtomicBool` guard,
+so migration runs before any config file I/O, regardless of which code path is taken first.
diff --git a/docs/release-note-v3.6.0-zh.md b/docs/release-note-v3.6.0-zh.md
index e56b1b83..c321ef26 100644
--- a/docs/release-note-v3.6.0-zh.md
+++ b/docs/release-note-v3.6.0-zh.md
@@ -2,7 +2,7 @@
> 全栈架构重构,增强配置同步与数据保护
-**[English Version →](../release-note-v3.6.0.md)**
+**[English Version →](./release-note-v3.6.0-en.md)**
---
diff --git a/docs/release-note-v3.6.1-zh.md b/docs/release-note-v3.6.1-zh.md
index 76943f45..7470979d 100644
--- a/docs/release-note-v3.6.1-zh.md
+++ b/docs/release-note-v3.6.1-zh.md
@@ -2,7 +2,7 @@
> 稳定性提升与用户体验优化(基于 v3.6.0)
-**[English Version →](../release-note-v3.6.1.md)**
+**[English Version →](./release-note-v3.6.1-en.md)**
---
diff --git a/docs/v3.7.0-unified-mcp-refactor.md b/docs/v3.7.0-unified-mcp-refactor.md
index 0e5b0f00..a8f3b988 100644
--- a/docs/v3.7.0-unified-mcp-refactor.md
+++ b/docs/v3.7.0-unified-mcp-refactor.md
@@ -749,8 +749,7 @@ Data Layer (Config + Live Sync)
### 内部文档
- [项目 README](../README.md)
-- [CLAUDE.md](../CLAUDE.md) - Claude Code 工作指南
-- [架构文档](../CLAUDE.md#架构概述)
+- [中文 README](../README_ZH.md) - 项目工作指南与架构说明
### 相关 Issues/PRs
diff --git a/flake.nix b/flake.nix
index 72b082f2..c11edcfc 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,5 +1,5 @@
{
- description = "Nix packaging for cc-switch-cli";
+ description = "Nix packaging for cc-switch-tui";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -19,7 +19,7 @@
{
packages = forAllSystems (system: pkgs:
let
- cc_switch_cli = pkgs.rustPlatform.buildRustPackage {
+ cc_switch_tui = pkgs.rustPlatform.buildRustPackage {
pname = cargoManifest.package.name;
version = cargoManifest.package.version;
@@ -37,18 +37,19 @@
doCheck = false;
meta = with pkgs.lib; {
- description = "CLI manager for Claude Code, Codex, Gemini, OpenCode, and OpenClaw";
- homepage = "https://github.com/saladday/cc-switch-cli";
+ description = "TUI manager for Claude Code, Codex, Gemini, OpenCode, OpenClaw, and Hermes";
+ homepage = "https://github.com/handy-sun/cc-switch-tui";
license = licenses.mit;
- mainProgram = "cc-switch";
+ mainProgram = "cc-switch-tui";
platforms = platforms.unix;
};
};
in
{
- cc-switch = cc_switch_cli;
- cc-switch-cli = cc_switch_cli;
- default = cc_switch_cli;
+ cc-switch-tui = cc_switch_tui;
+ # legacy alias kept for transition
+ cc-switch = cc_switch_tui;
+ default = cc_switch_tui;
});
};
}
diff --git a/install.sh b/install.sh
index 6ed512f8..98ce76f5 100755
--- a/install.sh
+++ b/install.sh
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -Eeuo pipefail
-REPO="SaladDay/cc-switch-cli"
-BIN_NAME="cc-switch"
+REPO="handy-sun/cc-switch-tui"
+BIN_NAME="cc-switch-tui"
INSTALL_DIR="${CC_SWITCH_INSTALL_DIR:-$HOME/.local/bin}"
TARGET="${INSTALL_DIR}/${BIN_NAME}"
RELEASES_URL="https://github.com/${REPO}/releases"
@@ -14,6 +14,7 @@ VERSION="${1:-latest}"
TMP_DIR=""
ASSET_NAME=""
ASSET_CANDIDATES=()
+RESOLVED_VERSION=""
# ── helpers ──────────────────────────────────────────────────────────
@@ -63,7 +64,7 @@ confirm_overwrite_if_needed() {
return 0
fi
- if ! exec 3<> /dev/tty 2>/dev/null; then
+ if [[ ! -t 0 || ! -t 1 ]] || ! exec 3<> /dev/tty 2>/dev/null; then
err "Existing installation detected at ${TARGET}${target_version:+ (${target_version})}."
err "Nothing was overwritten. Re-run interactively to confirm the update, or set CC_SWITCH_FORCE=1 to allow overwrite."
exit 1
@@ -122,15 +123,15 @@ set_linux_asset_candidates() {
case "${mode}" in
auto)
ASSET_CANDIDATES=(
- "cc-switch-cli-linux-x64-musl.tar.gz"
- "cc-switch-cli-linux-x64.tar.gz"
+ "cc-switch-tui-linux-x64-musl.tar.gz"
+ "cc-switch-tui-linux-x64.tar.gz"
)
;;
musl)
- ASSET_CANDIDATES=("cc-switch-cli-linux-x64-musl.tar.gz")
+ ASSET_CANDIDATES=("cc-switch-tui-linux-x64-musl.tar.gz")
;;
glibc)
- ASSET_CANDIDATES=("cc-switch-cli-linux-x64.tar.gz")
+ ASSET_CANDIDATES=("cc-switch-tui-linux-x64.tar.gz")
;;
esac
;;
@@ -138,15 +139,15 @@ set_linux_asset_candidates() {
case "${mode}" in
auto)
ASSET_CANDIDATES=(
- "cc-switch-cli-linux-arm64-musl.tar.gz"
- "cc-switch-cli-linux-arm64.tar.gz"
+ "cc-switch-tui-linux-arm64-musl.tar.gz"
+ "cc-switch-tui-linux-arm64.tar.gz"
)
;;
musl)
- ASSET_CANDIDATES=("cc-switch-cli-linux-arm64-musl.tar.gz")
+ ASSET_CANDIDATES=("cc-switch-tui-linux-arm64-musl.tar.gz")
;;
glibc)
- ASSET_CANDIDATES=("cc-switch-cli-linux-arm64.tar.gz")
+ ASSET_CANDIDATES=("cc-switch-tui-linux-arm64.tar.gz")
;;
esac
;;
@@ -181,14 +182,14 @@ detect_asset() {
case "${os}" in
Darwin)
# Universal binary works on both Apple Silicon and Intel
- ASSET_CANDIDATES=("cc-switch-cli-darwin-universal.tar.gz")
+ ASSET_CANDIDATES=("cc-switch-tui-darwin-universal.tar.gz")
;;
Linux)
set_linux_asset_candidates "${arch}"
;;
MINGW*|MSYS*|CYGWIN*|Windows_NT)
err "This script does not support Windows."
- err "Download cc-switch-cli-windows-x64.zip from: ${RELEASES_URL}"
+ err "Download cc-switch-tui-windows-x64.zip from: ${RELEASES_URL}"
exit 1
;;
*)
@@ -217,21 +218,56 @@ download_asset() {
fi
}
+resolve_latest_version() {
+ local manifest_url manifest_file resolved
+ manifest_url="${RELEASES_URL}/latest/download/latest.json"
+ manifest_file="${TMP_DIR}/latest.json"
+
+ info "Resolving latest release version"
+ download_asset "${manifest_url}" "${manifest_file}"
+
+ resolved="$(
+ sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "${manifest_file}" \
+ | head -n 1
+ )"
+ if [[ -z "${resolved}" || ! "${resolved}" =~ ^v ]]; then
+ err "Unable to resolve latest release version from latest.json."
+ exit 1
+ fi
+
+ RESOLVED_VERSION="${resolved}"
+}
+
+versioned_asset_name() {
+ local asset_name="$1"
+ local version="$2"
+
+ if [[ "${asset_name}" == cc-switch-tui-"${version}"-* ]]; then
+ printf '%s' "${asset_name}"
+ return 0
+ fi
+
+ printf 'cc-switch-tui-%s-%s' "${version}" "${asset_name#cc-switch-tui-}"
+}
+
download() {
- local asset_name url dest
+ local asset_name resolved_asset_name url dest
+
+ if [[ "${VERSION}" == "latest" ]]; then
+ resolve_latest_version
+ else
+ RESOLVED_VERSION="${VERSION}"
+ fi
for asset_name in "${ASSET_CANDIDATES[@]}"; do
- if [[ "${VERSION}" == "latest" ]]; then
- url="${RELEASES_URL}/latest/download/${asset_name}"
- else
- url="${RELEASES_URL}/download/${VERSION}/${asset_name}"
- fi
- dest="${TMP_DIR}/${asset_name}"
+ resolved_asset_name="$(versioned_asset_name "${asset_name}" "${RESOLVED_VERSION}")"
+ url="${RELEASES_URL}/download/${RESOLVED_VERSION}/${resolved_asset_name}"
+ dest="${TMP_DIR}/${resolved_asset_name}"
- info "Downloading ${asset_name}"
+ info "Downloading ${resolved_asset_name}"
if download_asset "${url}" "${dest}"; then
- ASSET_NAME="${asset_name}"
+ ASSET_NAME="${resolved_asset_name}"
return 0
fi
diff --git a/scripts/generate_latest_json.py b/scripts/generate_latest_json.py
index d66d6d60..d618a984 100644
--- a/scripts/generate_latest_json.py
+++ b/scripts/generate_latest_json.py
@@ -20,10 +20,14 @@ def file_exists(release_dir: Path, filename: str) -> bool:
).is_file()
-def add_mac_platforms(manifest: dict, release_dir: Path, base_url: str):
- universal = "cc-switch-cli-darwin-universal.tar.gz"
- x64 = "cc-switch-cli-darwin-x64.tar.gz"
- arm64 = "cc-switch-cli-darwin-arm64.tar.gz"
+def versioned_name(version: str, suffix: str) -> str:
+ return f"cc-switch-tui-{version}-{suffix}"
+
+
+def add_mac_platforms(manifest: dict, release_dir: Path, base_url: str, version: str):
+ universal = versioned_name(version, "darwin-universal.tar.gz")
+ x64 = versioned_name(version, "darwin-x64.tar.gz")
+ arm64 = versioned_name(version, "darwin-arm64.tar.gz")
if file_exists(release_dir, x64):
manifest["platforms"]["darwin-x86_64"] = asset_entry(release_dir, base_url, x64)
@@ -46,10 +50,14 @@ def add_linux_platform(
manifest: dict,
release_dir: Path,
base_url: str,
+ version: str,
platform_key: str,
- musl_name: str,
- glibc_name: str,
+ musl_suffix: str,
+ glibc_suffix: str,
):
+ musl_name = versioned_name(version, musl_suffix)
+ glibc_name = versioned_name(version, glibc_suffix)
+
if file_exists(release_dir, musl_name):
entry: dict[str, object] = dict(asset_entry(release_dir, base_url, musl_name))
if file_exists(release_dir, glibc_name):
@@ -86,25 +94,27 @@ def main() -> int:
"platforms": {},
}
- add_mac_platforms(manifest, release_dir, base_url)
+ add_mac_platforms(manifest, release_dir, base_url, version)
add_linux_platform(
manifest,
release_dir,
base_url,
+ version,
"linux-x86_64",
- "cc-switch-cli-linux-x64-musl.tar.gz",
- "cc-switch-cli-linux-x64.tar.gz",
+ "linux-x64-musl.tar.gz",
+ "linux-x64.tar.gz",
)
add_linux_platform(
manifest,
release_dir,
base_url,
+ version,
"linux-aarch64",
- "cc-switch-cli-linux-arm64-musl.tar.gz",
- "cc-switch-cli-linux-arm64.tar.gz",
+ "linux-arm64-musl.tar.gz",
+ "linux-arm64.tar.gz",
)
- windows = "cc-switch-cli-windows-x64.zip"
+ windows = versioned_name(version, "windows-x64.zip")
if file_exists(release_dir, windows):
manifest["platforms"]["windows-x86_64"] = asset_entry(
release_dir, base_url, windows
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index a834dfa3..a9b74571 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -2,15 +2,6 @@
# It is not intended for manual editing.
version = 4
-[[package]]
-name = "addr2line"
-version = "0.25.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
-dependencies = [
- "gimli",
-]
-
[[package]]
name = "adler2"
version = "2.0.1"
@@ -34,7 +25,7 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
- "getrandom 0.2.16",
+ "getrandom 0.2.17",
"once_cell",
"version_check",
]
@@ -53,9 +44,9 @@ dependencies = [
[[package]]
name = "aho-corasick"
-version = "1.1.3"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
@@ -83,9 +74,9 @@ checksum = "4d032745fe46100dbcb28ee6e30f12c4b148786f8889e07cd0a3445eeb54970f"
[[package]]
name = "anstream"
-version = "0.6.21"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
@@ -98,15 +89,15 @@ dependencies = [
[[package]]
name = "anstyle"
-version = "1.0.13"
+version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
-version = "0.2.7"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
@@ -117,7 +108,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys 0.61.1",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -128,14 +119,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys 0.61.1",
+ "windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
-version = "1.0.100"
+version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
@@ -171,7 +162,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -182,7 +173,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -261,21 +252,6 @@ dependencies = [
"tracing",
]
-[[package]]
-name = "backtrace"
-version = "0.3.76"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
-dependencies = [
- "addr2line",
- "cfg-if",
- "libc",
- "miniz_oxide",
- "object",
- "rustc-demangle",
- "windows-link",
-]
-
[[package]]
name = "base64"
version = "0.22.1"
@@ -305,9 +281,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
-version = "2.10.0"
+version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bitvec"
@@ -332,9 +308,9 @@ dependencies = [
[[package]]
name = "bon"
-version = "3.9.0"
+version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d13a61f2963b88eef9c1be03df65d42f6996dfeac1054870d950fcf66686f83"
+checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe"
dependencies = [
"bon-macros",
"rustversion",
@@ -342,9 +318,9 @@ dependencies = [
[[package]]
name = "bon-macros"
-version = "3.9.0"
+version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d314cc62af2b6b0c65780555abb4d02a03dd3b799cd42419044f0c38d99738c0"
+checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c"
dependencies = [
"darling",
"ident_case",
@@ -352,37 +328,38 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "borsh"
-version = "1.6.0"
+version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
+checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
dependencies = [
"borsh-derive",
+ "bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
-version = "1.6.0"
+version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
+checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
dependencies = [
"once_cell",
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "bumpalo"
-version = "3.19.0"
+version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytecheck"
@@ -408,9 +385,9 @@ dependencies = [
[[package]]
name = "bytemuck"
-version = "1.24.0"
+version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
@@ -420,9 +397,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
-version = "1.10.1"
+version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bzip2"
@@ -454,9 +431,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.40"
+version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
+checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -465,8 +442,8 @@ dependencies = [
]
[[package]]
-name = "cc-switch"
-version = "5.4.0"
+name = "cc-switch-tui"
+version = "0.1.3"
dependencies = [
"anyhow",
"async-stream",
@@ -488,6 +465,7 @@ dependencies = [
"hyper",
"indexmap",
"indicatif",
+ "indoc",
"inquire",
"json-five",
"json5",
@@ -528,9 +506,9 @@ dependencies = [
[[package]]
name = "cfg-if"
-version = "1.0.3"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
@@ -540,9 +518,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
-version = "0.4.42"
+version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -564,9 +542,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.5.53"
+version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
@@ -574,9 +552,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.5.53"
+version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
@@ -587,36 +565,36 @@ dependencies = [
[[package]]
name = "clap_complete"
-version = "4.5.61"
+version = "4.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39615915e2ece2550c0149addac32fb5bd312c657f43845bb9088cb9c8a7c992"
+checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
-version = "4.5.49"
+version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "clap_lex"
-version = "0.7.6"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
-version = "1.0.4"
+version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "colored"
@@ -630,13 +608,13 @@ dependencies = [
[[package]]
name = "comfy-table"
-version = "7.2.1"
+version = "7.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b"
+checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
dependencies = [
"crossterm",
"unicode-segmentation",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -662,7 +640,7 @@ dependencies = [
"encode_unicode",
"libc",
"once_cell",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
"windows-sys 0.59.0",
]
@@ -698,18 +676,18 @@ dependencies = [
[[package]]
name = "crc"
-version = "3.3.0"
+version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
-version = "2.4.0"
+version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
@@ -732,13 +710,13 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
- "rustix 1.1.2",
+ "rustix 1.1.4",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -755,9 +733,9 @@ dependencies = [
[[package]]
name = "crypto-common"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
@@ -799,7 +777,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -810,14 +788,14 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "deflate64"
-version = "0.1.10"
+version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
+checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
name = "deltae"
@@ -827,9 +805,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
[[package]]
name = "deranged"
-version = "0.5.4"
+version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
@@ -842,7 +820,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -864,7 +842,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -907,7 +885,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -949,9 +927,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "env_filter"
-version = "0.1.3"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
+checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
dependencies = [
"log",
"regex",
@@ -959,9 +937,9 @@ dependencies = [
[[package]]
name = "env_logger"
-version = "0.11.8"
+version = "0.11.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
+checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
dependencies = [
"anstream",
"anstyle",
@@ -983,14 +961,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.61.1",
+ "windows-sys 0.61.2",
]
[[package]]
name = "euclid"
-version = "0.22.13"
+version = "0.22.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
dependencies = [
"num-traits",
]
@@ -1019,9 +997,9 @@ dependencies = [
[[package]]
name = "fastrand"
-version = "2.3.0"
+version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "filedescriptor"
@@ -1036,20 +1014,19 @@ dependencies = [
[[package]]
name = "filetime"
-version = "0.2.27"
+version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
- "libredox",
]
[[package]]
name = "find-msvc-tools"
-version = "0.1.3"
+version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "finl_unicode"
@@ -1065,9 +1042,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
-version = "1.1.2"
+version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
@@ -1108,9 +1085,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
@@ -1123,9 +1100,9 @@ dependencies = [
[[package]]
name = "futures-channel"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
@@ -1133,15 +1110,15 @@ dependencies = [
[[package]]
name = "futures-core"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
@@ -1150,38 +1127,38 @@ dependencies = [
[[package]]
name = "futures-io"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "futures-sink"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
-version = "0.3.31"
+version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
@@ -1191,7 +1168,6 @@ dependencies = [
"futures-task",
"memchr",
"pin-project-lite",
- "pin-utils",
"slab",
]
@@ -1216,28 +1192,28 @@ dependencies = [
[[package]]
name = "getrandom"
-version = "0.2.16"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
- "wasi 0.11.1+wasi-snapshot-preview1",
+ "wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
-version = "0.3.3"
+version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
- "wasi 0.14.7+wasi-0.2.4",
+ "wasip2",
"wasm-bindgen",
]
@@ -1256,17 +1232,11 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "gimli"
-version = "0.32.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
-
[[package]]
name = "h2"
-version = "0.4.13"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
dependencies = [
"atomic-waker",
"bytes",
@@ -1310,15 +1280,21 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.16.0"
+version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
]
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
[[package]]
name = "hashlink"
version = "0.9.1"
@@ -1355,17 +1331,16 @@ version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
- "windows-sys 0.61.1",
+ "windows-sys 0.61.2",
]
[[package]]
name = "http"
-version = "1.3.1"
+version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
- "fnv",
"itoa",
]
@@ -1406,9 +1381,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
-version = "1.7.0"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@@ -1421,7 +1396,6 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
- "pin-utils",
"smallvec",
"tokio",
"want",
@@ -1429,15 +1403,14 @@ dependencies = [
[[package]]
name = "hyper-rustls"
-version = "0.27.7"
+version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
- "rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
@@ -1446,14 +1419,13 @@ dependencies = [
[[package]]
name = "hyper-util"
-version = "0.1.17"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
- "futures-core",
"futures-util",
"http",
"http-body",
@@ -1470,9 +1442,9 @@ dependencies = [
[[package]]
name = "iana-time-zone"
-version = "0.1.64"
+version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
@@ -1494,12 +1466,13 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.0.0"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
+ "utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@@ -1507,9 +1480,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.0.0"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@@ -1520,11 +1493,10 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.0.0"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
- "displaydoc",
"icu_collections",
"icu_normalizer_data",
"icu_properties",
@@ -1535,42 +1507,38 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.0.0"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
-version = "2.0.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
- "displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
- "potential_utf",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
-version = "2.0.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
-version = "2.0.0"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
- "stable_deref_trait",
- "tinystr",
"writeable",
"yoke",
"zerofrom",
@@ -1603,9 +1571,9 @@ dependencies = [
[[package]]
name = "idna_adapter"
-version = "1.2.1"
+version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
@@ -1613,12 +1581,12 @@ dependencies = [
[[package]]
name = "indexmap"
-version = "2.11.4"
+version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
- "hashbrown 0.16.0",
+ "hashbrown 0.17.1",
"serde",
"serde_core",
]
@@ -1632,7 +1600,7 @@ dependencies = [
"console",
"number_prefix",
"portable-atomic",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
"web-time",
]
@@ -1656,57 +1624,36 @@ dependencies = [
[[package]]
name = "inquire"
-version = "0.9.2"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae51d5da01ce7039024fbdec477767c102c454dbdb09d4e2a432ece705b1b25d"
+checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"crossterm",
"dyn-clone",
"fuzzy-matcher",
"unicode-segmentation",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
]
[[package]]
name = "instability"
-version = "0.3.11"
+version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
+checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
- "syn 2.0.106",
-]
-
-[[package]]
-name = "io-uring"
-version = "0.7.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b"
-dependencies = [
- "bitflags 2.10.0",
- "cfg-if",
- "libc",
+ "syn 2.0.117",
]
[[package]]
name = "ipnet"
-version = "2.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
-
-[[package]]
-name = "iri-string"
-version = "0.7.8"
+version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
-dependencies = [
- "memchr",
- "serde",
-]
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "is_terminal_polyfill"
@@ -1725,15 +1672,15 @@ dependencies = [
[[package]]
name = "itoa"
-version = "1.0.15"
+version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
-version = "0.2.16"
+version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35"
+checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
dependencies = [
"jiff-static",
"log",
@@ -1744,13 +1691,13 @@ dependencies = [
[[package]]
name = "jiff-static"
-version = "0.2.16"
+version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69"
+checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -1759,16 +1706,18 @@ version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
- "getrandom 0.3.3",
+ "getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
-version = "0.3.81"
+version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
+checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
dependencies = [
+ "cfg-if",
+ "futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -1796,13 +1745,13 @@ dependencies = [
[[package]]
name = "kasuari"
-version = "0.4.11"
+version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b"
+checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
- "hashbrown 0.16.0",
+ "hashbrown 0.16.1",
"portable-atomic",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
]
[[package]]
@@ -1825,19 +1774,17 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
-version = "0.2.176"
+version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
-version = "0.1.10"
+version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
- "bitflags 2.10.0",
"libc",
- "redox_syscall",
]
[[package]]
@@ -1853,11 +1800,11 @@ dependencies = [
[[package]]
name = "line-clipping"
-version = "0.3.5"
+version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a"
+checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
]
[[package]]
@@ -1868,15 +1815,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "linux-raw-sys"
-version = "0.11.0"
+version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
-version = "0.8.0"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
@@ -1895,17 +1842,17 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.28"
+version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
-version = "0.16.3"
+version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
+checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
dependencies = [
- "hashbrown 0.16.0",
+ "hashbrown 0.16.1",
]
[[package]]
@@ -1953,9 +1900,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "memchr"
-version = "2.7.6"
+version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memmem"
@@ -2015,18 +1962,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
+ "simd-adler32",
]
[[package]]
name = "mio"
-version = "1.0.4"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
- "wasi 0.11.1+wasi-snapshot-preview1",
- "windows-sys 0.59.0",
+ "wasi",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2035,7 +1983,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
@@ -2054,9 +2002,9 @@ dependencies = [
[[package]]
name = "num-conv"
-version = "0.1.0"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-derive"
@@ -2066,7 +2014,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -2093,20 +2041,11 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
-[[package]]
-name = "object"
-version = "0.37.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
-dependencies = [
- "memchr",
-]
-
[[package]]
name = "once_cell"
-version = "1.21.3"
+version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
@@ -2170,9 +2109,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
-version = "2.8.5"
+version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7"
+checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
@@ -2180,9 +2119,9 @@ dependencies = [
[[package]]
name = "pest_derive"
-version = "2.8.5"
+version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed"
+checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
dependencies = [
"pest",
"pest_generator",
@@ -2190,22 +2129,22 @@ dependencies = [
[[package]]
name = "pest_generator"
-version = "2.8.5"
+version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5"
+checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "pest_meta"
-version = "2.8.5"
+version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365"
+checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
@@ -2238,7 +2177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
- "rand 0.8.5",
+ "rand 0.8.6",
]
[[package]]
@@ -2251,7 +2190,7 @@ dependencies = [
"phf_shared",
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -2265,42 +2204,36 @@ dependencies = [
[[package]]
name = "pin-project-lite"
-version = "0.2.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
-
-[[package]]
-name = "pin-utils"
-version = "0.1.0"
+version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "portable-atomic"
-version = "1.11.1"
+version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
-version = "0.2.4"
+version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
-version = "0.1.3"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
@@ -2327,7 +2260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -2336,14 +2269,14 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
- "toml_edit 0.25.4+spec-1.1.0",
+ "toml_edit 0.25.11+spec-1.1.0",
]
[[package]]
name = "proc-macro2"
-version = "1.0.101"
+version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
@@ -2382,7 +2315,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
@@ -2390,20 +2323,20 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.13"
+version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
- "getrandom 0.3.3",
+ "getrandom 0.3.4",
"lru-slab",
- "rand 0.9.2",
+ "rand 0.9.4",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
@@ -2425,9 +2358,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.41"
+version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -2452,9 +2385,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rand"
-version = "0.8.5"
+version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
@@ -2463,12 +2396,12 @@ dependencies = [
[[package]]
name = "rand"
-version = "0.9.2"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
- "rand_core 0.9.3",
+ "rand_core 0.9.5",
]
[[package]]
@@ -2488,7 +2421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
- "rand_core 0.9.3",
+ "rand_core 0.9.5",
]
[[package]]
@@ -2497,16 +2430,16 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
- "getrandom 0.2.16",
+ "getrandom 0.2.17",
]
[[package]]
name = "rand_core"
-version = "0.9.3"
+version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
- "getrandom 0.3.3",
+ "getrandom 0.3.4",
]
[[package]]
@@ -2529,18 +2462,18 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"compact_str",
- "hashbrown 0.16.0",
+ "hashbrown 0.16.1",
"indoc",
"itertools",
"kasuari",
"lru",
"strum",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"unicode-segmentation",
"unicode-truncate",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -2581,8 +2514,8 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
dependencies = [
- "bitflags 2.10.0",
- "hashbrown 0.16.0",
+ "bitflags 2.11.1",
+ "hashbrown 0.16.1",
"indoc",
"instability",
"itertools",
@@ -2591,16 +2524,16 @@ dependencies = [
"strum",
"time",
"unicode-segmentation",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
]
[[package]]
name = "redox_syscall"
-version = "0.5.17"
+version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
]
[[package]]
@@ -2609,16 +2542,16 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
- "getrandom 0.2.16",
+ "getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "regex"
-version = "1.11.3"
+version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
@@ -2628,9 +2561,9 @@ dependencies = [
[[package]]
name = "regex-automata"
-version = "0.4.11"
+version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
@@ -2639,9 +2572,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
-version = "0.8.6"
+version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rend"
@@ -2654,9 +2587,9 @@ dependencies = [
[[package]]
name = "reqwest"
-version = "0.12.23"
+version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
@@ -2683,7 +2616,7 @@ dependencies = [
"tokio-rustls",
"tokio-util",
"tower",
- "tower-http 0.6.6",
+ "tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
@@ -2701,7 +2634,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
- "getrandom 0.2.16",
+ "getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -2738,13 +2671,13 @@ dependencies = [
[[package]]
name = "rpassword"
-version = "7.4.0"
+version = "7.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
+checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64"
dependencies = [
"libc",
"rtoolbox",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2776,12 +2709,12 @@ dependencies = [
[[package]]
name = "rtoolbox"
-version = "0.0.3"
+version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f"
+checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -2790,7 +2723,7 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
@@ -2800,31 +2733,26 @@ dependencies = [
[[package]]
name = "rust_decimal"
-version = "1.40.0"
+version = "1.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0"
+checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
- "rand 0.8.5",
+ "rand 0.8.6",
"rkyv",
"serde",
"serde_json",
+ "wasm-bindgen",
]
-[[package]]
-name = "rustc-demangle"
-version = "0.1.26"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
-
[[package]]
name = "rustc-hash"
-version = "2.1.1"
+version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
@@ -2841,7 +2769,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys 0.4.15",
@@ -2850,22 +2778,22 @@ dependencies = [
[[package]]
name = "rustix"
-version = "1.1.2"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"errno",
"libc",
- "linux-raw-sys 0.11.0",
- "windows-sys 0.61.1",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
]
[[package]]
name = "rustls"
-version = "0.23.32"
+version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
@@ -2877,9 +2805,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
-version = "1.12.0"
+version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
@@ -2887,9 +2815,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
-version = "0.103.7"
+version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
@@ -2904,9 +2832,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
-version = "1.0.20"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "salsa20"
@@ -2968,9 +2896,9 @@ dependencies = [
[[package]]
name = "semver"
-version = "1.0.27"
+version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
@@ -2999,20 +2927,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "serde_json"
-version = "1.0.145"
+version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
- "ryu",
"serde",
"serde_core",
+ "zmij",
]
[[package]]
@@ -3062,11 +2990,12 @@ dependencies = [
[[package]]
name = "serial_test"
-version = "3.2.0"
+version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9"
+checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f"
dependencies = [
- "futures",
+ "futures-executor",
+ "futures-util",
"log",
"once_cell",
"parking_lot",
@@ -3076,13 +3005,13 @@ dependencies = [
[[package]]
name = "serial_test_derive"
-version = "3.2.0"
+version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef"
+checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -3136,18 +3065,19 @@ dependencies = [
[[package]]
name = "signal-hook-registry"
-version = "1.4.6"
+version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
+ "errno",
"libc",
]
[[package]]
name = "simd-adler32"
-version = "0.3.7"
+version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simdutf8"
@@ -3157,15 +3087,15 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
-version = "1.0.2"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
-version = "0.4.11"
+version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
@@ -3175,19 +3105,19 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
-version = "0.6.0"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
name = "stable_deref_trait"
-version = "1.2.0"
+version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
@@ -3219,7 +3149,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -3241,9 +3171,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "2.0.106"
+version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -3267,7 +3197,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -3281,8 +3211,8 @@ dependencies = [
"compact_str",
"micromath",
"ratatui-core",
- "thiserror 2.0.17",
- "unicode-width 0.2.0",
+ "thiserror 2.0.18",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -3293,9 +3223,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
-version = "0.4.44"
+version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
+checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
@@ -3304,25 +3234,25 @@ dependencies = [
[[package]]
name = "tempfile"
-version = "3.23.0"
+version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.3",
+ "getrandom 0.4.2",
"once_cell",
- "rustix 1.1.2",
- "windows-sys 0.61.1",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
]
[[package]]
name = "terminal_size"
-version = "0.4.3"
+version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
- "rustix 1.1.2",
- "windows-sys 0.60.2",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3354,7 +3284,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [
"anyhow",
"base64",
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"fancy-regex",
"filedescriptor",
"finl_unicode",
@@ -3399,11 +3329,11 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.17"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
- "thiserror-impl 2.0.17",
+ "thiserror-impl 2.0.18",
]
[[package]]
@@ -3414,18 +3344,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "thiserror-impl"
-version = "2.0.17"
+version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -3439,30 +3369,30 @@ dependencies = [
[[package]]
name = "time"
-version = "0.3.44"
+version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"libc",
"num-conv",
"num_threads",
"powerfmt",
- "serde",
+ "serde_core",
"time-core",
]
[[package]]
name = "time-core"
-version = "0.1.6"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "tinystr"
-version = "0.8.1"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@@ -3470,9 +3400,9 @@ dependencies = [
[[package]]
name = "tinyvec"
-version = "1.10.0"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
@@ -3485,32 +3415,29 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.47.1"
+version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
- "backtrace",
"bytes",
- "io-uring",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
- "slab",
"socket2",
"tokio-macros",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
-version = "2.5.0"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -3538,14 +3465,14 @@ dependencies = [
[[package]]
name = "toml"
-version = "0.8.2"
+version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
- "toml_edit 0.20.2",
+ "toml_edit 0.22.27",
]
[[package]]
@@ -3559,26 +3486,13 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "1.0.0+spec-1.1.0"
+version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
-[[package]]
-name = "toml_edit"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
-dependencies = [
- "indexmap",
- "serde",
- "serde_spanned",
- "toml_datetime 0.6.11",
- "winnow 0.5.40",
-]
-
[[package]]
name = "toml_edit"
version = "0.22.27"
@@ -3586,30 +3500,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
+ "serde",
+ "serde_spanned",
"toml_datetime 0.6.11",
"toml_write",
- "winnow 0.7.13",
+ "winnow 0.7.15",
]
[[package]]
name = "toml_edit"
-version = "0.25.4+spec-1.1.0"
+version = "0.25.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
+checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
dependencies = [
"indexmap",
- "toml_datetime 1.0.0+spec-1.1.0",
+ "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
- "winnow 0.7.13",
+ "winnow 1.0.3",
]
[[package]]
name = "toml_parser"
-version = "1.0.9+spec-1.1.0"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
- "winnow 0.7.13",
+ "winnow 1.0.3",
]
[[package]]
@@ -3620,9 +3536,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
-version = "0.5.2"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
@@ -3640,7 +3556,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"bytes",
"http",
"http-body",
@@ -3652,20 +3568,20 @@ dependencies = [
[[package]]
name = "tower-http"
-version = "0.6.6"
+version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"bytes",
"futures-util",
"http",
"http-body",
- "iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
+ "url",
]
[[package]]
@@ -3682,9 +3598,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
-version = "0.1.41"
+version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
@@ -3693,9 +3609,9 @@ dependencies = [
[[package]]
name = "tracing-core"
-version = "0.1.34"
+version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
@@ -3708,9 +3624,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
-version = "1.19.0"
+version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "ucd-trie"
@@ -3726,15 +3642,15 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
-version = "1.0.19"
+version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
-version = "1.12.0"
+version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
@@ -3744,7 +3660,7 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"unicode-segmentation",
- "unicode-width 0.2.0",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -3755,9 +3671,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
-version = "0.2.0"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
@@ -3779,9 +3695,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
-version = "2.5.7"
+version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
@@ -3803,12 +3719,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.20.0"
+version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
+checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"atomic",
- "getrandom 0.3.3",
+ "getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
@@ -3849,22 +3765,13 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
-[[package]]
-name = "wasi"
-version = "0.14.7+wasi-0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
-dependencies = [
- "wasip2",
-]
-
[[package]]
name = "wasip2"
-version = "1.0.1+wasi-0.2.4"
+version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
- "wit-bindgen 0.46.0",
+ "wit-bindgen 0.57.1",
]
[[package]]
@@ -3878,49 +3785,33 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.104"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
+checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
+ "serde",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
-[[package]]
-name = "wasm-bindgen-backend"
-version = "0.2.104"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
-dependencies = [
- "bumpalo",
- "log",
- "proc-macro2",
- "quote",
- "syn 2.0.106",
- "wasm-bindgen-shared",
-]
-
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.54"
+version = "0.4.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
+checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
dependencies = [
- "cfg-if",
"js-sys",
- "once_cell",
"wasm-bindgen",
- "web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.104"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
+checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -3928,22 +3819,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.104"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
+checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
dependencies = [
+ "bumpalo",
"proc-macro2",
"quote",
- "syn 2.0.106",
- "wasm-bindgen-backend",
+ "syn 2.0.117",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.104"
+version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
+checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
dependencies = [
"unicode-ident",
]
@@ -3989,7 +3880,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -3997,9 +3888,9 @@ dependencies = [
[[package]]
name = "web-sys"
-version = "0.3.81"
+version = "0.3.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
+checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -4017,9 +3908,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
-version = "1.0.2"
+version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2"
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
dependencies = [
"rustls-pki-types",
]
@@ -4040,7 +3931,7 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
dependencies = [
- "getrandom 0.3.3",
+ "getrandom 0.3.4",
"mac_address",
"sha2",
"thiserror 1.0.69",
@@ -4144,9 +4035,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
-version = "0.62.1"
+version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
@@ -4157,46 +4048,46 @@ dependencies = [
[[package]]
name = "windows-implement"
-version = "0.60.1"
+version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "windows-interface"
-version = "0.59.2"
+version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "windows-link"
-version = "0.2.0"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
-version = "0.4.0"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
-version = "0.5.0"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
@@ -4234,14 +4125,14 @@ version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
- "windows-targets 0.53.4",
+ "windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
-version = "0.61.1"
+version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
@@ -4279,19 +4170,19 @@ dependencies = [
[[package]]
name = "windows-targets"
-version = "0.53.4"
+version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
- "windows_aarch64_gnullvm 0.53.0",
- "windows_aarch64_msvc 0.53.0",
- "windows_i686_gnu 0.53.0",
- "windows_i686_gnullvm 0.53.0",
- "windows_i686_msvc 0.53.0",
- "windows_x86_64_gnu 0.53.0",
- "windows_x86_64_gnullvm 0.53.0",
- "windows_x86_64_msvc 0.53.0",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
]
[[package]]
@@ -4308,9 +4199,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
-version = "0.53.0"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
@@ -4326,9 +4217,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
-version = "0.53.0"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
@@ -4344,9 +4235,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
-version = "0.53.0"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
@@ -4356,9 +4247,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
-version = "0.53.0"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
@@ -4374,9 +4265,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
-version = "0.53.0"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
@@ -4392,9 +4283,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
-version = "0.53.0"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
@@ -4410,9 +4301,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
-version = "0.53.0"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
@@ -4428,24 +4319,24 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
-version = "0.53.0"
+version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
-version = "0.5.40"
+version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
-version = "0.7.13"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
+checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [
"memchr",
]
@@ -4466,12 +4357,6 @@ version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
-[[package]]
-name = "wit-bindgen"
-version = "0.46.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
-
[[package]]
name = "wit-bindgen"
version = "0.51.0"
@@ -4481,6 +4366,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
@@ -4502,7 +4393,7 @@ dependencies = [
"heck",
"indexmap",
"prettyplease",
- "syn 2.0.106",
+ "syn 2.0.117",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
@@ -4518,7 +4409,7 @@ dependencies = [
"prettyplease",
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
"wit-bindgen-core",
"wit-bindgen-rust",
]
@@ -4530,7 +4421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
- "bitflags 2.10.0",
+ "bitflags 2.11.1",
"indexmap",
"log",
"serde",
@@ -4562,9 +4453,9 @@ dependencies = [
[[package]]
name = "writeable"
-version = "0.6.1"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "wyz"
@@ -4582,7 +4473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
- "rustix 1.1.2",
+ "rustix 1.1.4",
]
[[package]]
@@ -4596,11 +4487,10 @@ dependencies = [
[[package]]
name = "yoke"
-version = "0.8.0"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
- "serde",
"stable_deref_trait",
"yoke-derive",
"zerofrom",
@@ -4608,54 +4498,54 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.0"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
"synstructure",
]
[[package]]
name = "zerocopy"
-version = "0.8.27"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.27"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "zerofrom"
-version = "0.1.6"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
"synstructure",
]
@@ -4670,20 +4560,20 @@ dependencies = [
[[package]]
name = "zeroize_derive"
-version = "1.4.2"
+version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
+checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
name = "zerotrie"
-version = "0.2.2"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@@ -4692,9 +4582,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.4"
+version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@@ -4703,13 +4593,13 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.1"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.106",
+ "syn 2.0.117",
]
[[package]]
@@ -4727,14 +4617,14 @@ dependencies = [
"deflate64",
"displaydoc",
"flate2",
- "getrandom 0.3.3",
+ "getrandom 0.3.4",
"hmac",
"indexmap",
"lzma-rs",
"memchr",
"pbkdf2",
"sha1",
- "thiserror 2.0.17",
+ "thiserror 2.0.18",
"time",
"xz2",
"zeroize",
@@ -4742,6 +4632,12 @@ dependencies = [
"zstd",
]
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
[[package]]
name = "zopfli"
version = "0.8.3"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 05a77330..819e54f9 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,12 +1,14 @@
[package]
-name = "cc-switch"
-version = "5.4.0"
-description = "All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode & OpenClaw"
-authors = ["Jason Young", "saladday"]
+name = "cc-switch-tui"
+version = "0.1.3"
+description = "All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode, OpenClaw & Hermes"
+authors = ["Jason Young", "saladday", "handy-sun"]
license = "MIT"
-repository = "https://github.com/saladday/cc-switch-cli"
+repository = "https://github.com/handy-sun/cc-switch-tui"
+keywords = ["opencode", "hermes", "claude-code", "openclaw", "llm"]
edition = "2021"
-rust-version = "1.91.1"
+rust-version = "1.94.0"
+build = "build.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -15,7 +17,7 @@ name = "cc_switch_lib"
crate-type = ["rlib"]
[[bin]]
-name = "cc-switch"
+name = "cc-switch-tui"
path = "src/main.rs"
[features]
@@ -100,6 +102,10 @@ panic = "abort"
strip = "symbols"
[dev-dependencies]
+indoc = "2"
minisign = "0.9.1"
serial_test = "3"
tempfile = "3"
+
+[build-dependencies]
+chrono = "0.4"
diff --git a/src-tauri/README.md b/src-tauri/README.md
new file mode 120000
index 00000000..32d46ee8
--- /dev/null
+++ b/src-tauri/README.md
@@ -0,0 +1 @@
+../README.md
\ No newline at end of file
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
new file mode 100644
index 00000000..80461107
--- /dev/null
+++ b/src-tauri/build.rs
@@ -0,0 +1,55 @@
+use chrono::Local;
+use std::process::Command;
+
+fn set_build_time() {
+ let build_time = Local::now().format("%Y-%m-%d %H:%M:%S %:z").to_string();
+ println!("cargo:rustc-env=CC_SWITCH_TUI_BUILD_TIME={build_time}");
+}
+
+fn git_output(args: &[&str]) -> Option {
+ let output = Command::new("git").args(args).output().ok()?;
+ if !output.status.success() {
+ return None;
+ }
+
+ let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ (!value.is_empty()).then_some(value)
+}
+
+fn set_git_revision_hash() {
+ if let Some(rev) = git_output(&["rev-parse", "--short=7", "HEAD"]) {
+ println!("cargo:rustc-env=CC_SWITCH_TUI_BUILD_GIT_HASH={rev}");
+ }
+}
+
+fn set_git_tag_version() {
+ if let Some(tag) = git_output(&["describe", "--tags", "--abbrev=0"]) {
+ let version = tag.strip_prefix('v').unwrap_or(&tag);
+ if !version.is_empty() {
+ println!("cargo:rustc-env=CC_SWITCH_TUI_GIT_TAG_VERSION={version}");
+ }
+ }
+}
+
+fn set_git_is_clean_commit() {
+ let Ok(output) = Command::new("git").args(["status", "--porcelain"]).output() else {
+ return;
+ };
+
+ if output.status.success() && output.stdout.is_empty() {
+ println!("cargo:rustc-env=CC_SWITCH_TUI_GIT_IS_CLEAN_COMMIT=1");
+ }
+}
+
+fn main() {
+ println!("cargo:rerun-if-changed=Cargo.toml");
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-changed=src");
+ println!("cargo:rerun-if-changed=../.git/HEAD");
+ println!("cargo:rerun-if-changed=../.git/index");
+
+ set_build_time();
+ set_git_revision_hash();
+ set_git_tag_version();
+ set_git_is_clean_commit();
+}
diff --git a/src-tauri/flake.lock b/src-tauri/flake.lock
new file mode 100644
index 00000000..4fe7997b
--- /dev/null
+++ b/src-tauri/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1777954456,
+ "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/src-tauri/flake.nix b/src-tauri/flake.nix
new file mode 100644
index 00000000..7f3ef501
--- /dev/null
+++ b/src-tauri/flake.nix
@@ -0,0 +1,92 @@
+## Rust devShell for cc-switch cross-compilation via cargo-zigbuild.
+## Usage:
+## cd src-tauri && nix develop
+## cargo zigbuild --target x86_64-unknown-linux-gnu --release
+## cargo zigbuild --target aarch64-unknown-linux-gnu --release
+##
+## cargo-zigbuild uses zig as the C cross-compiler, so no container
+## runtime (podman/docker) is needed. rusqlite bundled SQLite and
+## rquickjs QuickJS C code are compiled by zig automatically.
+
+{
+ description = "Rust devShell for cc-switch-tui (cargo-zigbuild cross-compilation)";
+
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+
+ outputs = { self, nixpkgs }:
+ let
+ systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
+ forAllSystems = f:
+ nixpkgs.lib.genAttrs systems (system:
+ f system (import nixpkgs { inherit system; }));
+ in
+ {
+ devShells = forAllSystems (system: pkgs:
+ let
+ isDarwin = pkgs.stdenv.isDarwin;
+ crossTargets = [
+ "x86_64-unknown-linux-gnu"
+ "aarch64-unknown-linux-gnu"
+ ];
+ crossTargetsArm = pkgs.lib.optionals isDarwin [
+ "aarch64-apple-darwin"
+ ];
+ allTargets = crossTargets ++ crossTargetsArm;
+ targetListCmd = builtins.concatStringsSep " " (map (t: "rustup target add ${t}") allTargets);
+ in
+ {
+ default = pkgs.mkShell {
+ name = "cc-switch-rust";
+
+ packages = with pkgs; [
+ ## cross-compilation via zig (no container needed)
+ cargo-zigbuild
+ zig
+
+ ## rusqlite bundled SQLite needs cmake
+ cmake
+
+ ## rustup manages toolchain + cross targets
+ ## (rust-toolchain.toml in this dir pins the version)
+ rustup
+
+ ## dev helpers
+ cargo-watch
+ ];
+
+ shellHook = ''
+ ## Install pinned toolchain if missing (rustup stores in ~/.rustup/)
+ if ! rustup toolchain list 2>/dev/null | grep -q '1.94.0'; then
+ echo "Installing rustup 1.94.0 toolchain..."
+ rustup toolchain install 1.94.0 --profile minimal --no-self-update 2>&1 | tail -1
+ fi
+
+ ## Ensure rustup reads local rust-toolchain.toml
+ export RUSTUP_TOOLCHAIN=1.94.0
+
+ ## Add cross-compilation targets
+ for tgt in ${builtins.toString allTargets}; do
+ if ! rustup target list --installed 2>/dev/null | grep -q "^$tgt$"; then
+ echo "Adding rustup target: $tgt"
+ rustup target add "$tgt" 2>&1 | tail -1
+ fi
+ done
+
+ echo ""
+ echo "cc-switch Rust devShell (cargo-zigbuild)"
+ echo "========================================"
+ echo "Cross-compile:"
+ echo " cargo zigbuild --target x86_64-unknown-linux-gnu --release"
+ echo " cargo zigbuild --target aarch64-unknown-linux-gnu --release"
+ ${pkgs.lib.optionalString isDarwin ''
+ echo " cargo zigbuild --target aarch64-apple-darwin --release"
+ ''}
+ echo ""
+ echo "Native build:"
+ echo " cargo build --release"
+ echo ""
+ '';
+ };
+ });
+ };
+}
diff --git a/src-tauri/rust-toolchain.toml b/src-tauri/rust-toolchain.toml
index 03199c42..7a2297a0 100644
--- a/src-tauri/rust-toolchain.toml
+++ b/src-tauri/rust-toolchain.toml
@@ -1,4 +1,4 @@
[toolchain]
-channel = "1.91.1"
+channel = "1.94.0"
components = ["rustfmt", "clippy"]
profile = "minimal"
diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs
index 46642f50..aea2a6b6 100644
--- a/src-tauri/src/app_config.rs
+++ b/src-tauri/src/app_config.rs
@@ -16,6 +16,8 @@ pub struct McpApps {
#[serde(default)]
pub opencode: bool,
#[serde(default)]
+ pub openclaw: bool,
+ #[serde(default)]
pub hermes: bool,
}
@@ -27,7 +29,8 @@ impl McpApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
- AppType::OpenClaw => false,
+ AppType::OpenClaw => self.openclaw,
+ AppType::Hermes => self.hermes,
}
}
@@ -38,7 +41,8 @@ impl McpApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
- AppType::OpenClaw => {}
+ AppType::OpenClaw => self.openclaw = enabled,
+ AppType::Hermes => self.hermes = enabled,
}
}
@@ -57,12 +61,23 @@ impl McpApps {
if self.opencode {
apps.push(AppType::OpenCode);
}
+ if self.openclaw {
+ apps.push(AppType::OpenClaw);
+ }
+ if self.hermes {
+ apps.push(AppType::Hermes);
+ }
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
- !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
+ !self.claude
+ && !self.codex
+ && !self.gemini
+ && !self.opencode
+ && !self.openclaw
+ && !self.hermes
}
}
@@ -77,6 +92,10 @@ pub struct SkillApps {
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
+ #[serde(default)]
+ pub openclaw: bool,
+ #[serde(default)]
+ pub hermes: bool,
}
impl SkillApps {
@@ -86,7 +105,8 @@ impl SkillApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
- AppType::OpenClaw => false,
+ AppType::OpenClaw => self.openclaw,
+ AppType::Hermes => self.hermes,
}
}
@@ -96,12 +116,18 @@ impl SkillApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
- AppType::OpenClaw => {}
+ AppType::OpenClaw => self.openclaw = enabled,
+ AppType::Hermes => self.hermes = enabled,
}
}
pub fn is_empty(&self) -> bool {
- !self.claude && !self.codex && !self.gemini && !self.opencode
+ !self.claude
+ && !self.codex
+ && !self.gemini
+ && !self.opencode
+ && !self.openclaw
+ && !self.hermes
}
pub fn only(app: &AppType) -> Self {
@@ -125,6 +151,8 @@ impl SkillApps {
self.codex |= other.codex;
self.gemini |= other.gemini;
self.opencode |= other.opencode;
+ self.openclaw |= other.openclaw;
+ self.hermes |= other.hermes;
}
}
@@ -224,6 +252,8 @@ pub struct McpRoot {
pub opencode: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub openclaw: McpConfig,
+ #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
+ pub hermes: McpConfig,
}
impl Default for McpRoot {
@@ -237,6 +267,7 @@ impl Default for McpRoot {
gemini: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
+ hermes: McpConfig::default(),
}
}
}
@@ -261,6 +292,8 @@ pub struct PromptRoot {
pub opencode: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
+ #[serde(default)]
+ pub hermes: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -269,7 +302,7 @@ use crate::prompt_files::prompt_file_path;
use crate::provider::ProviderManager;
/// 应用类型
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, clap::ValueEnum)]
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
Claude,
@@ -277,8 +310,39 @@ pub enum AppType {
Gemini,
OpenCode,
OpenClaw,
+ Hermes,
}
+/// Apps shown in the MCP server picker.
+pub const MCP_PICKER_APPS: &[AppType] = &[
+ AppType::Claude,
+ AppType::Codex,
+ AppType::Gemini,
+ AppType::OpenCode,
+ AppType::OpenClaw,
+ AppType::Hermes,
+];
+
+/// Apps shown in the "Visible Apps" settings picker (all apps).
+pub const VISIBLE_PICKER_APPS: &[AppType] = &[
+ AppType::Claude,
+ AppType::Codex,
+ AppType::Gemini,
+ AppType::OpenCode,
+ AppType::OpenClaw,
+ AppType::Hermes,
+];
+
+/// Apps shown in the skills picker.
+pub const SKILLS_PICKER_APPS: &[AppType] = &[
+ AppType::Claude,
+ AppType::Codex,
+ AppType::Gemini,
+ AppType::OpenCode,
+ AppType::OpenClaw,
+ AppType::Hermes,
+];
+
impl AppType {
pub fn as_str(&self) -> &'static str {
match self {
@@ -287,11 +351,15 @@ impl AppType {
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
+ AppType::Hermes => "hermes",
}
}
pub fn is_additive_mode(&self) -> bool {
- matches!(self, AppType::OpenCode | AppType::OpenClaw)
+ matches!(
+ self,
+ AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
+ )
}
pub fn all() -> impl Iterator- {
@@ -301,6 +369,7 @@ impl AppType {
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
+ AppType::Hermes,
]
.into_iter()
}
@@ -323,13 +392,14 @@ impl FromStr for AppType {
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
+ "hermes" => Ok(AppType::Hermes),
other => Err(AppError::localized(
"unsupported_app",
format!(
- "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"
+ "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"
),
format!(
- "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."
+ "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."
),
)),
}
@@ -353,6 +423,9 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw: Option
,
+
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub hermes: Option,
}
impl CommonConfigSnippets {
@@ -364,6 +437,7 @@ impl CommonConfigSnippets {
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
+ AppType::Hermes => self.hermes.as_ref(),
}
}
@@ -375,6 +449,7 @@ impl CommonConfigSnippets {
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
+ AppType::Hermes => self.hermes = snippet,
}
}
}
@@ -416,6 +491,7 @@ impl Default for MultiAppConfig {
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
+ apps.insert("hermes".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -461,8 +537,8 @@ impl MultiAppConfig {
if is_v1 {
return Err(AppError::localized(
"config.unsupported_v1",
- "检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
- "Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
+ "检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch-tui/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
+ "Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch-tui/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
));
}
@@ -552,7 +628,7 @@ impl MultiAppConfig {
/// 保存配置到文件
pub fn save(&self) -> Result<(), AppError> {
let config_path = get_app_config_path();
- // 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容
+ // 先备份旧版(若存在)到 ~/.cc-switch-tui/config.json.bak,再写入新内容
if config_path.exists() {
let backup_path = get_app_config_dir().join("config.json.bak");
if let Err(e) = copy_file(&config_path, &backup_path) {
@@ -590,6 +666,7 @@ impl MultiAppConfig {
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
+ AppType::Hermes => &self.mcp.hermes,
}
}
@@ -601,6 +678,7 @@ impl MultiAppConfig {
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
+ AppType::Hermes => &mut self.mcp.hermes,
}
}
@@ -650,6 +728,7 @@ impl MultiAppConfig {
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
+ AppType::Hermes,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -718,6 +797,7 @@ impl MultiAppConfig {
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
+ AppType::Hermes => &mut config.prompts.hermes.prompts,
};
prompts.insert(id, prompt);
@@ -751,13 +831,16 @@ impl MultiAppConfig {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
+ AppType::OpenClaw,
+ AppType::Hermes,
] {
let old_servers = match app {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
- AppType::OpenClaw => continue,
+ AppType::OpenClaw => &self.mcp.openclaw.servers,
+ AppType::Hermes => &self.mcp.hermes.servers,
};
for (id, entry) in old_servers {
@@ -894,9 +977,15 @@ mod tests {
let original_userprofile = env::var_os("USERPROFILE");
let original_config_dir = env::var_os("CC_SWITCH_CONFIG_DIR");
- env::set_var("HOME", dir.path());
- env::set_var("USERPROFILE", dir.path());
- env::set_var("CC_SWITCH_CONFIG_DIR", dir.path().join(".cc-switch"));
+ unsafe {
+ env::set_var("HOME", dir.path());
+ }
+ unsafe {
+ env::set_var("USERPROFILE", dir.path());
+ }
+ unsafe {
+ env::set_var("CC_SWITCH_CONFIG_DIR", dir.path().join(".cc-switch"));
+ }
crate::test_support::set_test_home_override(Some(dir.path()));
crate::settings::reload_test_settings();
@@ -913,18 +1002,18 @@ mod tests {
impl Drop for TempHome {
fn drop(&mut self) {
match &self.original_home {
- Some(value) => env::set_var("HOME", value),
- None => env::remove_var("HOME"),
+ Some(value) => unsafe { env::set_var("HOME", value) },
+ None => unsafe { env::remove_var("HOME") },
}
match &self.original_userprofile {
- Some(value) => env::set_var("USERPROFILE", value),
- None => env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { env::set_var("USERPROFILE", value) },
+ None => unsafe { env::remove_var("USERPROFILE") },
}
match &self.original_config_dir {
- Some(value) => env::set_var("CC_SWITCH_CONFIG_DIR", value),
- None => env::remove_var("CC_SWITCH_CONFIG_DIR"),
+ Some(value) => unsafe { env::set_var("CC_SWITCH_CONFIG_DIR", value) },
+ None => unsafe { env::remove_var("CC_SWITCH_CONFIG_DIR") },
}
crate::test_support::set_test_home_override(
@@ -947,7 +1036,9 @@ mod tests {
let _lock = crate::test_support::lock_test_home_and_settings();
let original_config_dir = env::var_os("CC_SWITCH_CONFIG_DIR");
- env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ unsafe {
+ env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ }
crate::test_support::set_test_home_override(Some(home));
crate::settings::reload_test_settings();
@@ -957,8 +1048,8 @@ mod tests {
crate::settings::reload_test_settings();
match original_config_dir {
- Some(value) => env::set_var("CC_SWITCH_CONFIG_DIR", value),
- None => env::remove_var("CC_SWITCH_CONFIG_DIR"),
+ Some(value) => unsafe { env::set_var("CC_SWITCH_CONFIG_DIR", value) },
+ None => unsafe { env::remove_var("CC_SWITCH_CONFIG_DIR") },
}
}
@@ -1130,7 +1221,7 @@ mod tests {
}
#[test]
- fn migrate_mcp_to_unified_keeps_openclaw_legacy_servers_unmigrated() {
+ fn migrate_mcp_to_unified_imports_openclaw_legacy_servers() {
let mut config = MultiAppConfig::default();
config.mcp.servers = None;
config.mcp.claude.servers.insert(
@@ -1162,12 +1253,10 @@ mod tests {
.expect("unified servers should exist");
assert!(unified.contains_key("claude-tool"));
assert!(
- !unified.contains_key("openclaw-tool"),
- "OpenClaw MCP should remain in legacy storage until upstream supports it"
- );
- assert!(
- config.mcp.openclaw.servers.contains_key("openclaw-tool"),
- "OpenClaw legacy MCP entries should be preserved"
+ unified
+ .get("openclaw-tool")
+ .is_some_and(|server| server.apps.openclaw),
+ "OpenClaw legacy MCP entries should migrate into unified storage"
);
}
}
diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs
index d17f75b0..729cc865 100644
--- a/src-tauri/src/claude_mcp.rs
+++ b/src-tauri/src/claude_mcp.rs
@@ -1,20 +1,10 @@
-use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
-use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
use crate::error::AppError;
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "camelCase")]
-pub struct McpStatus {
- pub user_config_path: String,
- pub user_config_exists: bool,
- pub server_count: usize,
-}
-
fn user_config_path() -> PathBuf {
ensure_mcp_override_migrated();
get_claude_mcp_path()
@@ -79,23 +69,6 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> {
atomic_write(path, json.as_bytes())
}
-pub fn get_mcp_status() -> Result {
- let path = user_config_path();
- let (exists, count) = if path.exists() {
- let v = read_json_value(&path)?;
- let servers = v.get("mcpServers").and_then(|x| x.as_object());
- (true, servers.map(|m| m.len()).unwrap_or(0))
- } else {
- (false, 0)
- };
-
- Ok(McpStatus {
- user_config_path: path.to_string_lossy().to_string(),
- user_config_exists: exists,
- server_count: count,
- })
-}
-
pub fn read_mcp_json() -> Result, AppError> {
let path = user_config_path();
if !path.exists() {
@@ -154,135 +127,6 @@ pub fn clear_has_completed_onboarding() -> Result {
Ok(true)
}
-pub fn upsert_mcp_server(id: &str, spec: Value) -> Result {
- if id.trim().is_empty() {
- return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
- }
- // 基础字段校验(尽量宽松)
- if !spec.is_object() {
- return Err(AppError::McpValidation(
- "MCP 服务器定义必须为 JSON 对象".into(),
- ));
- }
- let t_opt = spec.get("type").and_then(|x| x.as_str());
- let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true); // 兼容缺省(按 stdio 处理)
- let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
- let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
- if !(is_stdio || is_http || is_sse) {
- return Err(AppError::McpValidation(
- "MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
- ));
- }
-
- // stdio 类型必须有 command
- if is_stdio {
- let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
- if cmd.is_empty() {
- return Err(AppError::McpValidation(
- "stdio 类型的 MCP 服务器缺少 command 字段".into(),
- ));
- }
- }
-
- // http/sse 类型必须有 url
- if is_http || is_sse {
- let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
- if url.is_empty() {
- return Err(AppError::McpValidation(if is_http {
- "http 类型的 MCP 服务器缺少 url 字段".into()
- } else {
- "sse 类型的 MCP 服务器缺少 url 字段".into()
- }));
- }
- }
-
- let path = user_config_path();
- let mut root = if path.exists() {
- read_json_value(&path)?
- } else {
- serde_json::json!({})
- };
-
- // 确保 mcpServers 对象存在
- {
- let obj = root
- .as_object_mut()
- .ok_or_else(|| AppError::Config("mcp.json 根必须是对象".into()))?;
- if !obj.contains_key("mcpServers") {
- obj.insert("mcpServers".into(), serde_json::json!({}));
- }
- }
-
- let before = root.clone();
- if let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
- servers.insert(id.to_string(), spec);
- }
-
- if before == root && path.exists() {
- return Ok(false);
- }
-
- write_json_value(&path, &root)?;
- Ok(true)
-}
-
-pub fn delete_mcp_server(id: &str) -> Result {
- if id.trim().is_empty() {
- return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
- }
- let path = user_config_path();
- if !path.exists() {
- return Ok(false);
- }
- let mut root = read_json_value(&path)?;
- let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
- return Ok(false);
- };
- let existed = servers.remove(id).is_some();
- if !existed {
- return Ok(false);
- }
- write_json_value(&path, &root)?;
- Ok(true)
-}
-
-pub fn validate_command_in_path(cmd: &str) -> Result {
- if cmd.trim().is_empty() {
- return Ok(false);
- }
- // 如果包含路径分隔符,直接判断是否存在可执行文件
- if cmd.contains('/') || cmd.contains('\\') {
- return Ok(Path::new(cmd).exists());
- }
-
- let path_var = env::var_os("PATH").unwrap_or_default();
- let paths = env::split_paths(&path_var);
-
- #[cfg(windows)]
- let exts: Vec = env::var("PATHEXT")
- .unwrap_or(".COM;.EXE;.BAT;.CMD".into())
- .split(';')
- .map(|s| s.trim().to_uppercase())
- .collect();
-
- for p in paths {
- let candidate = p.join(cmd);
- if candidate.is_file() {
- return Ok(true);
- }
- #[cfg(windows)]
- {
- for ext in &exts {
- let cand = p.join(format!("{}{}", cmd, ext));
- if cand.is_file() {
- return Ok(true);
- }
- }
- }
- }
- Ok(false)
-}
-
/// 读取 ~/.claude.json 中的 mcpServers 映射
pub fn read_mcp_servers_map() -> Result, AppError> {
let path = user_config_path();
diff --git a/src-tauri/src/claude_plugin.rs b/src-tauri/src/claude_plugin.rs
index 68f2f735..4a76d394 100644
--- a/src-tauri/src/claude_plugin.rs
+++ b/src-tauri/src/claude_plugin.rs
@@ -13,7 +13,8 @@ fn claude_dir() -> Result {
if let Some(dir) = crate::settings::get_claude_override_dir() {
return Ok(dir);
}
- let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;
+ let home =
+ crate::config::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;
Ok(home.join(CLAUDE_DIR))
}
@@ -39,17 +40,6 @@ pub fn read_claude_config() -> Result, AppError> {
}
}
-fn is_managed_config(content: &str) -> bool {
- match serde_json::from_str::(content) {
- Ok(value) => value
- .get("primaryApiKey")
- .and_then(|v| v.as_str())
- .map(|val| val == "any")
- .unwrap_or(false),
- Err(_) => false,
- }
-}
-
pub fn write_claude_config() -> Result {
// 增量写入:仅设置 primaryApiKey = "any",保留其它字段
let path = claude_config_path()?;
@@ -120,18 +110,6 @@ pub fn clear_claude_config() -> Result {
Ok(true)
}
-pub fn claude_config_status() -> Result<(bool, PathBuf), AppError> {
- let path = claude_config_path()?;
- Ok((path.exists(), path))
-}
-
-pub fn is_claude_config_applied() -> Result {
- match read_claude_config()? {
- Some(content) => Ok(is_managed_config(&content)),
- None => Ok(false),
- }
-}
-
pub fn sync_claude_plugin_on_settings_toggle(enabled: bool) -> Result<(), AppError> {
if enabled {
let _ = write_claude_config()?;
diff --git a/src-tauri/src/cli/claude_temp_launch.rs b/src-tauri/src/cli/claude_temp_launch.rs
index 6085afcc..e053b3e9 100644
--- a/src-tauri/src/cli/claude_temp_launch.rs
+++ b/src-tauri/src/cli/claude_temp_launch.rs
@@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::AppError;
+#[cfg(test)]
use crate::provider::Provider;
use crate::services::provider::ProviderService;
use serde_json::Value;
@@ -21,13 +22,6 @@ impl PreparedClaudeLaunch {
}
}
-pub(crate) fn prepare_launch(
- provider: &Provider,
- temp_dir: &Path,
-) -> Result {
- prepare_launch_with(provider, temp_dir, resolve_claude_binary)
-}
-
pub(crate) fn prepare_launch_from_settings(
provider_id: &str,
settings: &Value,
@@ -36,6 +30,7 @@ pub(crate) fn prepare_launch_from_settings(
prepare_launch_from_settings_with(provider_id, settings, temp_dir, resolve_claude_binary)
}
+#[cfg(test)]
pub(crate) fn prepare_launch_with(
provider: &Provider,
temp_dir: &Path,
diff --git a/src-tauri/src/cli/commands/completions.rs b/src-tauri/src/cli/commands/completions.rs
index 8e804ee8..00c5d4fa 100644
--- a/src-tauri/src/cli/commands/completions.rs
+++ b/src-tauri/src/cli/commands/completions.rs
@@ -9,7 +9,7 @@ use crate::AppError;
const MANAGED_BLOCK_START: &str = "# >>> cc-switch completions >>>";
const MANAGED_BLOCK_END: &str = "# <<< cc-switch completions <<<";
-const COMMAND_NAME: &str = "cc-switch";
+const COMMAND_NAME: &str = "cc-switch-tui";
#[derive(Args, Debug, Clone)]
#[command(arg_required_else_help = true)]
@@ -604,14 +604,14 @@ mod tests {
let bash = completion_paths(ManagedShell::Bash, &home);
assert_eq!(
bash.completion_file,
- home.join(".local/share/bash-completion/completions/cc-switch")
+ home.join(".local/share/bash-completion/completions/cc-switch-tui")
);
assert_eq!(bash.rc_file, home.join(".bashrc"));
let zsh = completion_paths(ManagedShell::Zsh, &home);
assert_eq!(
zsh.completion_file,
- home.join(".local/share/zsh/site-functions/_cc-switch")
+ home.join(".local/share/zsh/site-functions/_cc-switch-tui")
);
assert_eq!(zsh.rc_file, home.join(".zshrc"));
}
@@ -632,9 +632,9 @@ mod tests {
let script = fs::read_to_string(&paths.completion_file).expect("read bash completion");
let rc = fs::read_to_string(&paths.rc_file).expect("read bash rc");
- assert!(script.contains("_cc-switch"));
+ assert!(script.contains("_cc-switch-tui"));
assert_eq!(marker_count(&rc), 1);
- assert!(rc.contains(". \"$HOME/.local/share/bash-completion/completions/cc-switch\""));
+ assert!(rc.contains(". \"$HOME/.local/share/bash-completion/completions/cc-switch-tui\""));
}
#[test]
diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs
index 0bc62e74..16b93451 100644
--- a/src-tauri/src/cli/commands/config_common.rs
+++ b/src-tauri/src/cli/commands/config_common.rs
@@ -133,7 +133,11 @@ fn set(
};
let snippet = match app_type {
- AppType::Claude | AppType::Gemini | AppType::OpenCode | AppType::OpenClaw => {
+ AppType::Claude
+ | AppType::Gemini
+ | AppType::OpenCode
+ | AppType::OpenClaw
+ | AppType::Hermes => {
let value: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string()))
})?;
@@ -236,8 +240,10 @@ mod tests {
let lock = lock_test_home_and_settings();
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
- std::env::set_var("HOME", home);
- std::env::set_var("USERPROFILE", home);
+ unsafe {
+ std::env::set_var("HOME", home);
+ std::env::set_var("USERPROFILE", home);
+ }
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
@@ -251,12 +257,12 @@ mod tests {
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
+ Some(value) => unsafe { std::env::set_var("HOME", value) },
+ None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
- Some(value) => std::env::set_var("USERPROFILE", value),
- None => std::env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
+ None => unsafe { std::env::remove_var("USERPROFILE") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
diff --git a/src-tauri/src/cli/commands/failover.rs b/src-tauri/src/cli/commands/failover.rs
new file mode 100644
index 00000000..ffabc584
--- /dev/null
+++ b/src-tauri/src/cli/commands/failover.rs
@@ -0,0 +1,518 @@
+use clap::{Subcommand, ValueEnum};
+
+use crate::app_config::AppType;
+use crate::cli::ui::{create_table, highlight, info, success, warning};
+use crate::database::FailoverQueueItem;
+use crate::error::AppError;
+use crate::proxy::types::ProxyTakeoverStatus;
+use crate::services::provider::ProviderSortUpdate;
+use crate::services::ProviderService;
+use crate::AppState;
+
+#[derive(Subcommand, Debug, Clone)]
+pub enum FailoverCommand {
+ /// Show automatic failover status and queue
+ Show,
+
+ /// Enable automatic failover for the selected app
+ Enable,
+
+ /// Disable automatic failover for the selected app
+ Disable,
+
+ /// List queued failover providers
+ List,
+
+ /// List providers that can be added to the failover queue
+ Available,
+
+ /// Add a provider to the failover queue
+ Add { id: String },
+
+ /// Remove a provider from the failover queue
+ Remove { id: String },
+
+ /// Move a queued provider up or down
+ Move {
+ id: String,
+ #[arg(value_enum)]
+ direction: FailoverMoveDirection,
+ },
+
+ /// Clear the failover queue
+ Clear {
+ /// Confirm clearing the queue
+ #[arg(long)]
+ yes: bool,
+ },
+}
+
+#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FailoverMoveDirection {
+ Up,
+ Down,
+}
+
+pub fn execute(cmd: FailoverCommand, app: Option) -> Result<(), AppError> {
+ let app_type = app.unwrap_or(AppType::Claude);
+ match cmd {
+ FailoverCommand::Show => show_failover(app_type),
+ FailoverCommand::Enable => set_auto_failover(app_type, true),
+ FailoverCommand::Disable => set_auto_failover(app_type, false),
+ FailoverCommand::List => list_queue(app_type),
+ FailoverCommand::Available => list_available(app_type),
+ FailoverCommand::Add { id } => add_provider(app_type, &id),
+ FailoverCommand::Remove { id } => remove_provider(app_type, &id),
+ FailoverCommand::Move { id, direction } => move_provider(app_type, &id, direction),
+ FailoverCommand::Clear { yes } => clear_queue(app_type, yes),
+ }
+}
+
+fn get_state() -> Result {
+ AppState::try_new()
+}
+
+fn create_runtime() -> Result {
+ tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| AppError::Message(format!("failed to create async runtime: {e}")))
+}
+
+fn ensure_failover_supported(app_type: &AppType) -> Result<(), AppError> {
+ match app_type {
+ AppType::Claude | AppType::Codex | AppType::Gemini => Ok(()),
+ AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Err(AppError::InvalidInput(
+ format!("failover is not supported for {}", app_type.as_str()),
+ )),
+ }
+}
+
+fn show_failover(app_type: AppType) -> Result<(), AppError> {
+ ensure_failover_supported(&app_type)?;
+ let state = get_state()?;
+ let runtime = create_runtime()?;
+ let config = runtime.block_on(state.db.get_proxy_config_for_app(app_type.as_str()))?;
+ let status = runtime.block_on(state.proxy_service.get_status());
+ let takeovers = runtime
+ .block_on(state.proxy_service.get_takeover_status())
+ .map_err(AppError::Message)?;
+ let queue = state.db.get_failover_queue(app_type.as_str())?;
+
+ println!("{}", highlight("Failover"));
+ println!("App: {}", app_type.as_str());
+ println!(
+ "Automatic failover: {}",
+ if config.auto_failover_enabled {
+ "enabled"
+ } else {
+ "disabled"
+ }
+ );
+ println!(
+ "Proxy running: {}",
+ if status.running { "yes" } else { "no" }
+ );
+ println!(
+ "Takeover active: {}",
+ if status.running && takeover_enabled_for(&takeovers, &app_type) {
+ "yes"
+ } else {
+ "no"
+ }
+ );
+ println!();
+ print_queue(&queue);
+ Ok(())
+}
+
+fn set_auto_failover(app_type: AppType, enabled: bool) -> Result<(), AppError> {
+ ensure_failover_supported(&app_type)?;
+ let state = get_state()?;
+ let runtime = create_runtime()?;
+ let queue_empty = state.db.get_failover_queue(app_type.as_str())?.is_empty();
+
+ runtime.block_on(async {
+ let mut config = state.db.get_proxy_config_for_app(app_type.as_str()).await?;
+ config.auto_failover_enabled = enabled;
+ state.db.update_proxy_config_for_app(config).await
+ })?;
+
+ println!(
+ "{}",
+ success(&format!(
+ "Automatic failover {} for {}.",
+ if enabled { "enabled" } else { "disabled" },
+ app_type.as_str()
+ ))
+ );
+ if enabled && queue_empty {
+ println!(
+ "{}",
+ warning(
+ "Add providers to the failover queue before routing traffic through the proxy."
+ )
+ );
+ }
+ print_hot_update_note();
+ Ok(())
+}
+
+fn list_queue(app_type: AppType) -> Result<(), AppError> {
+ ensure_failover_supported(&app_type)?;
+ let state = get_state()?;
+ let queue = state.db.get_failover_queue(app_type.as_str())?;
+ print_queue(&queue);
+ Ok(())
+}
+
+fn list_available(app_type: AppType) -> Result<(), AppError> {
+ ensure_failover_supported(&app_type)?;
+ let state = get_state()?;
+ let providers = state
+ .db
+ .get_available_providers_for_failover(app_type.as_str())?;
+ if providers.is_empty() {
+ println!("{}", info("No providers are available to add."));
+ return Ok(());
+ }
+
+ let mut table = create_table();
+ table.set_header(vec!["ID", "Name", "Sort"]);
+ for provider in providers {
+ table.add_row(vec![
+ provider.id,
+ provider.name,
+ provider
+ .sort_index
+ .map(|index| index.to_string())
+ .unwrap_or_else(|| "-".to_string()),
+ ]);
+ }
+ println!("{}", table);
+ Ok(())
+}
+
+fn add_provider(app_type: AppType, id: &str) -> Result<(), AppError> {
+ ensure_failover_supported(&app_type)?;
+ let state = get_state()?;
+ ensure_provider_exists(&state, &app_type, id)?;
+
+ if state.db.is_in_failover_queue(app_type.as_str(), id)? {
+ println!("{}", info("Provider is already in the failover queue."));
+ return Ok(());
+ }
+
+ state.db.add_to_failover_queue(app_type.as_str(), id)?;
+ println!("{}", success("Provider added to the failover queue."));
+ print_hot_update_note_if_running(&state)?;
+ Ok(())
+}
+
+fn remove_provider(app_type: AppType, id: &str) -> Result<(), AppError> {
+ ensure_failover_supported(&app_type)?;
+ let state = get_state()?;
+ ensure_provider_exists(&state, &app_type, id)?;
+
+ if !state.db.is_in_failover_queue(app_type.as_str(), id)? {
+ println!("{}", info("Provider is not in the failover queue."));
+ return Ok(());
+ }
+
+ if provider_is_last_active_failover_queue_entry(&state, &app_type, id)? {
+ return Err(active_proxy_failover_queue_guard_error());
+ }
+
+ state.db.remove_from_failover_queue(app_type.as_str(), id)?;
+ println!("{}", success("Provider removed from the failover queue."));
+ print_hot_update_note_if_running(&state)?;
+ Ok(())
+}
+
+fn clear_queue(app_type: AppType, yes: bool) -> Result<(), AppError> {
+ ensure_failover_supported(&app_type)?;
+ let state = get_state()?;
+ let queue = state.db.get_failover_queue(app_type.as_str())?;
+
+ if queue.is_empty() {
+ println!("{}", info("Failover queue is already empty."));
+ return Ok(());
+ }
+ if !yes {
+ return Err(AppError::InvalidInput(
+ "clearing the failover queue requires --yes".to_string(),
+ ));
+ }
+ if queue_has_active_failover_guard(&state, &app_type, &queue)? {
+ return Err(active_proxy_failover_queue_guard_error());
+ }
+
+ let runtime = create_runtime()?;
+ state.db.clear_failover_queue(app_type.as_str())?;
+ runtime.block_on(state.db.clear_provider_health_for_app(app_type.as_str()))?;
+ println!("{}", success("Failover queue cleared."));
+ print_hot_update_note_if_running(&state)?;
+ Ok(())
+}
+
+fn move_provider(
+ app_type: AppType,
+ id: &str,
+ direction: FailoverMoveDirection,
+) -> Result<(), AppError> {
+ ensure_failover_supported(&app_type)?;
+ let state = get_state()?;
+ ensure_provider_exists(&state, &app_type, id)?;
+ let outcome = move_provider_in_state(&state, app_type, id, direction)?;
+ match outcome {
+ MoveOutcome::Updated => {
+ println!("{}", success("Failover queue order updated."));
+ print_hot_update_note_if_running(&state)?;
+ }
+ MoveOutcome::NotQueued => {
+ println!(
+ "{}",
+ info("Add this provider to the failover queue before moving it.")
+ );
+ }
+ MoveOutcome::AtEdge => {
+ println!(
+ "{}",
+ info("Provider is already at the edge of the failover queue.")
+ );
+ }
+ }
+ Ok(())
+}
+
+fn move_provider_in_state(
+ state: &AppState,
+ app_type: AppType,
+ id: &str,
+ direction: FailoverMoveDirection,
+) -> Result {
+ let mut queue = state.db.get_failover_queue(app_type.as_str())?;
+ let Some(index) = queue.iter().position(|item| item.provider_id == id) else {
+ return Ok(MoveOutcome::NotQueued);
+ };
+
+ let target = match direction {
+ FailoverMoveDirection::Up if index > 0 => index - 1,
+ FailoverMoveDirection::Down if index + 1 < queue.len() => index + 1,
+ _ => return Ok(MoveOutcome::AtEdge),
+ };
+
+ queue.swap(index, target);
+ let updates = queue
+ .iter()
+ .enumerate()
+ .map(|(sort_index, item)| ProviderSortUpdate {
+ id: item.provider_id.clone(),
+ sort_index,
+ })
+ .collect::>();
+ ProviderService::update_sort_order(state, app_type, updates)?;
+ Ok(MoveOutcome::Updated)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum MoveOutcome {
+ Updated,
+ NotQueued,
+ AtEdge,
+}
+
+fn ensure_provider_exists(state: &AppState, app_type: &AppType, id: &str) -> Result<(), AppError> {
+ state
+ .db
+ .get_provider_by_id(id, app_type.as_str())?
+ .map(|_| ())
+ .ok_or_else(|| AppError::InvalidInput(format!("Provider not found: {id}")))
+}
+
+fn provider_is_last_active_failover_queue_entry(
+ state: &AppState,
+ app_type: &AppType,
+ provider_id: &str,
+) -> Result {
+ let queue = state.db.get_failover_queue(app_type.as_str())?;
+ Ok(queue.len() == 1
+ && queue
+ .first()
+ .is_some_and(|item| item.provider_id == provider_id)
+ && active_failover_routes_app(state, app_type)?)
+}
+
+fn queue_has_active_failover_guard(
+ state: &AppState,
+ app_type: &AppType,
+ queue: &[FailoverQueueItem],
+) -> Result {
+ Ok(!queue.is_empty() && active_failover_routes_app(state, app_type)?)
+}
+
+fn active_failover_routes_app(state: &AppState, app_type: &AppType) -> Result {
+ let runtime = create_runtime()?;
+ let status = runtime.block_on(state.proxy_service.get_status());
+ if !status.running {
+ return Ok(false);
+ }
+
+ let config = runtime.block_on(state.db.get_proxy_config_for_app(app_type.as_str()))?;
+ Ok(config.enabled && config.auto_failover_enabled)
+}
+
+fn active_proxy_failover_queue_guard_error() -> AppError {
+ AppError::InvalidInput(
+ "At least one provider must remain in the failover queue while proxy failover is active."
+ .to_string(),
+ )
+}
+
+fn takeover_enabled_for(takeovers: &ProxyTakeoverStatus, app_type: &AppType) -> bool {
+ match app_type {
+ AppType::Claude => takeovers.claude,
+ AppType::Codex => takeovers.codex,
+ AppType::Gemini => takeovers.gemini,
+ AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => false,
+ }
+}
+
+fn print_queue(queue: &[FailoverQueueItem]) {
+ if queue.is_empty() {
+ println!("{}", info("Failover queue is empty."));
+ return;
+ }
+
+ let mut table = create_table();
+ table.set_header(vec!["#", "Provider ID", "Name", "Sort"]);
+ for (index, item) in queue.iter().enumerate() {
+ table.add_row(vec![
+ (index + 1).to_string(),
+ item.provider_id.clone(),
+ item.provider_name.clone(),
+ item.sort_index
+ .map(|sort_index| sort_index.to_string())
+ .unwrap_or_else(|| "-".to_string()),
+ ]);
+ }
+ println!("{}", table);
+}
+
+fn print_hot_update_note() {
+ println!(
+ "{}",
+ info("Running proxy sessions will use this on subsequent requests.")
+ );
+}
+
+fn print_hot_update_note_if_running(state: &AppState) -> Result<(), AppError> {
+ let runtime = create_runtime()?;
+ let status = runtime.block_on(state.proxy_service.get_status());
+ if status.running {
+ print_hot_update_note();
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::{Arc, RwLock};
+
+ use crate::{Database, MultiAppConfig, ProxyService};
+
+ use super::*;
+
+ fn test_state() -> AppState {
+ let db = Arc::new(Database::memory().expect("create memory database"));
+ AppState {
+ db: db.clone(),
+ config: RwLock::new(MultiAppConfig::default()),
+ proxy_service: ProxyService::new(db),
+ }
+ }
+
+ fn provider(id: &str, name: &str, sort_index: usize) -> crate::provider::Provider {
+ let mut provider = crate::provider::Provider::with_id(
+ id.to_string(),
+ name.to_string(),
+ serde_json::json!({"api_key": "test"}),
+ Some("https://example.com".to_string()),
+ );
+ provider.sort_index = Some(sort_index);
+ provider
+ }
+
+ fn save_provider(state: &AppState, provider: crate::provider::Provider) {
+ state
+ .db
+ .save_provider("claude", &provider)
+ .expect("save provider");
+ let mut config = state.config.write().expect("lock config");
+ let manager = config
+ .get_manager_mut(&AppType::Claude)
+ .expect("claude manager");
+ manager.providers.insert(provider.id.clone(), provider);
+ }
+
+ #[test]
+ fn unsupported_apps_are_rejected() {
+ assert!(ensure_failover_supported(&AppType::OpenCode).is_err());
+ assert!(ensure_failover_supported(&AppType::OpenClaw).is_err());
+ }
+
+ #[test]
+ fn moving_non_queued_provider_is_noop() {
+ let state = test_state();
+ save_provider(&state, provider("p1", "Provider 1", 0));
+
+ let outcome =
+ move_provider_in_state(&state, AppType::Claude, "p1", FailoverMoveDirection::Down)
+ .expect("move provider");
+
+ assert_eq!(outcome, MoveOutcome::NotQueued);
+ }
+
+ #[test]
+ fn moving_provider_at_queue_edge_is_noop() {
+ let state = test_state();
+ state
+ .db
+ .save_provider("claude", &provider("p1", "Provider 1", 0))
+ .expect("save provider");
+ state
+ .db
+ .add_to_failover_queue("claude", "p1")
+ .expect("queue provider");
+
+ let outcome =
+ move_provider_in_state(&state, AppType::Claude, "p1", FailoverMoveDirection::Up)
+ .expect("move provider");
+
+ assert_eq!(outcome, MoveOutcome::AtEdge);
+ }
+
+ #[test]
+ fn moving_provider_updates_queue_order() {
+ let state = test_state();
+ save_provider(&state, provider("p1", "Provider 1", 0));
+ save_provider(&state, provider("p2", "Provider 2", 1));
+ state
+ .db
+ .add_to_failover_queue("claude", "p1")
+ .expect("queue p1");
+ state
+ .db
+ .add_to_failover_queue("claude", "p2")
+ .expect("queue p2");
+
+ let outcome =
+ move_provider_in_state(&state, AppType::Claude, "p1", FailoverMoveDirection::Down)
+ .expect("move provider");
+ let queue = state.db.get_failover_queue("claude").expect("load queue");
+
+ assert_eq!(outcome, MoveOutcome::Updated);
+ assert_eq!(queue[0].provider_id, "p2");
+ assert_eq!(queue[1].provider_id, "p1");
+ }
+}
diff --git a/src-tauri/src/cli/commands/mcp.rs b/src-tauri/src/cli/commands/mcp.rs
index 34639b3c..f99bd4cf 100644
--- a/src-tauri/src/cli/commands/mcp.rs
+++ b/src-tauri/src/cli/commands/mcp.rs
@@ -265,7 +265,8 @@ fn import_servers(app_type: AppType) -> Result<(), AppError> {
AppType::Codex => McpService::import_from_codex(&state)?,
AppType::Gemini => McpService::import_from_gemini(&state)?,
AppType::OpenCode => 0,
- AppType::OpenClaw => 0,
+ AppType::OpenClaw => McpService::import_from_openclaw(&state)?,
+ AppType::Hermes => McpService::import_from_hermes(&state)?,
};
if count > 0 {
diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs
index 84e959c7..d958aeef 100644
--- a/src-tauri/src/cli/commands/mod.rs
+++ b/src-tauri/src/cli/commands/mod.rs
@@ -3,6 +3,7 @@ pub mod config;
mod config_common;
pub mod config_webdav;
pub mod env;
+pub mod failover;
pub mod internal;
pub mod mcp;
pub mod prompts;
diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs
index 3c378f73..06bb96bf 100644
--- a/src-tauri/src/cli/commands/provider_input.rs
+++ b/src-tauri/src/cli/commands/provider_input.rs
@@ -86,6 +86,7 @@ pub fn prompt_settings_config_for_add(
(AppType::Gemini, _) => prompt_gemini_config(None),
(AppType::OpenCode, _) => Ok(json!({})),
(AppType::OpenClaw, _) => Ok(json!({})),
+ (AppType::Hermes, _) => Ok(json!({})),
}
}
@@ -322,6 +323,7 @@ pub fn prompt_settings_config(
AppType::Gemini => prompt_gemini_config(current),
AppType::OpenCode => Ok(current.cloned().unwrap_or_else(|| json!({}))),
AppType::OpenClaw => Ok(current.cloned().unwrap_or_else(|| json!({}))),
+ AppType::Hermes => Ok(current.cloned().unwrap_or_else(|| json!({}))),
}
}
@@ -854,6 +856,33 @@ pub fn display_provider_summary(provider: &Provider, app_type: &AppType) {
println!(" {}: {}", texts::model_label(), models.len());
}
}
+ AppType::Hermes => {
+ if let Some(api_key) = provider
+ .settings_config
+ .get("apiKey")
+ .and_then(|v| v.as_str())
+ {
+ println!(
+ " {}: {}",
+ texts::api_key_display_label(),
+ mask_api_key(api_key)
+ );
+ }
+ if let Some(base_url) = provider
+ .settings_config
+ .get("baseUrl")
+ .and_then(|v| v.as_str())
+ {
+ println!(" {}: {}", texts::base_url_display_label(), base_url);
+ }
+ if let Some(models) = provider
+ .settings_config
+ .get("models")
+ .and_then(|v| v.as_array())
+ {
+ println!(" {}: {}", texts::model_label(), models.len());
+ }
+ }
}
// 可选字段
diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs
index 0e8374c2..04bcc40a 100644
--- a/src-tauri/src/cli/commands/provider_inspect.rs
+++ b/src-tauri/src/cli/commands/provider_inspect.rs
@@ -355,6 +355,20 @@ fn model_fetch_target(
})?,
strategy: ProviderModelFetchStrategy::Bearer,
}),
+ AppType::Hermes => Ok(ModelFetchTarget {
+ base_url,
+ auth_value: provider
+ .settings_config
+ .get("apiKey")
+ .and_then(|value| value.as_str())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string)
+ .ok_or_else(|| {
+ AppError::Message(format!("Missing API key for provider '{}'", provider.id))
+ })?,
+ strategy: ProviderModelFetchStrategy::Bearer,
+ }),
}
}
diff --git a/src-tauri/src/cli/commands/update.rs b/src-tauri/src/cli/commands/update.rs
index a595173c..93f4a299 100644
--- a/src-tauri/src/cli/commands/update.rs
+++ b/src-tauri/src/cli/commands/update.rs
@@ -16,7 +16,7 @@ use crate::cli::ui::{highlight, info, success};
use crate::error::AppError;
const REPO_URL: &str = env!("CARGO_PKG_REPOSITORY");
-const BINARY_NAME: &str = "cc-switch";
+const BINARY_NAME: &str = "cc-switch-tui";
const CHECKSUMS_FILE_NAME: &str = "checksums.txt";
const LATEST_MANIFEST_FILE_NAME: &str = "latest.json";
const HTTP_REQUEST_TIMEOUT_SECS: u64 = 30;
@@ -32,8 +32,24 @@ const USER_AGENT: &str = concat!(
#[derive(Args, Debug, Clone)]
pub struct UpdateCommand {
/// Target version (example: v4.6.2). Defaults to latest release.
- #[arg(long)]
- pub version: Option,
+ #[arg(
+ long = "target-version",
+ value_name = "VERSION",
+ conflicts_with = "target-tag"
+ )]
+ pub target_version: Option,
+
+ /// Target version (example: v4.6.2). Defaults to latest release.
+ #[arg(value_name = "VERSION", id = "target-tag")]
+ pub target_tag: Option,
+}
+
+impl UpdateCommand {
+ fn requested_version(&self) -> Option<&str> {
+ self.target_version
+ .as_deref()
+ .or(self.target_tag.as_deref())
+ }
}
struct DownloadedAsset {
@@ -45,8 +61,10 @@ struct DownloadedAsset {
struct UpdateManifest {
version: String,
#[serde(default)]
+ #[allow(dead_code)]
notes: Option,
#[serde(default)]
+ #[allow(dead_code)]
pub_date: Option,
platforms: BTreeMap,
}
@@ -132,9 +150,10 @@ pub fn execute(cmd: UpdateCommand) -> Result<(), AppError> {
async fn execute_async(cmd: UpdateCommand) -> Result<(), AppError> {
let current_version = env!("CARGO_PKG_VERSION");
- let explicit_version = cmd.version.as_deref().is_some_and(|v| !v.trim().is_empty());
+ let requested_version = cmd.requested_version();
+ let explicit_version = requested_version.is_some_and(|v| !v.trim().is_empty());
let client = create_http_client()?;
- let release = resolve_target_release(&client, REPO_URL, cmd.version.as_deref()).await?;
+ let release = resolve_target_release(&client, REPO_URL, requested_version).await?;
let target_tag = release.target_tag().to_string();
let target_version = target_tag.trim_start_matches('v');
@@ -150,7 +169,7 @@ async fn execute_async(cmd: UpdateCommand) -> Result<(), AppError> {
println!(
"{}",
info(&format!(
- "Current version v{current_version} is newer than target {target_tag}; skipping automatic downgrade. Use `cc-switch update --version {target_tag}` to force."
+ "Current version v{current_version} is newer than target {target_tag}; skipping automatic downgrade. Use `cc-switch update {target_tag}` to force."
))
);
return Ok(());
@@ -445,38 +464,38 @@ fn release_asset_candidates_for_platform(
) -> Result, AppError> {
let names = match (os, arch) {
("macos", "x86_64") => vec![
- "cc-switch-cli-darwin-universal.tar.gz".to_string(),
- "cc-switch-cli-darwin-x64.tar.gz".to_string(),
+ "cc-switch-tui-darwin-universal.tar.gz".to_string(),
+ "cc-switch-tui-darwin-x64.tar.gz".to_string(),
],
("macos", "aarch64") => vec![
- "cc-switch-cli-darwin-universal.tar.gz".to_string(),
- "cc-switch-cli-darwin-arm64.tar.gz".to_string(),
+ "cc-switch-tui-darwin-universal.tar.gz".to_string(),
+ "cc-switch-tui-darwin-arm64.tar.gz".to_string(),
],
("linux", "x86_64") => match preference {
LinuxLibcPreference::Auto => vec![
- "cc-switch-cli-linux-x64-musl.tar.gz".to_string(),
- "cc-switch-cli-linux-x64.tar.gz".to_string(),
+ "cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
+ "cc-switch-tui-linux-x64.tar.gz".to_string(),
],
- LinuxLibcPreference::Musl => vec!["cc-switch-cli-linux-x64-musl.tar.gz".to_string()],
+ LinuxLibcPreference::Musl => vec!["cc-switch-tui-linux-x64-musl.tar.gz".to_string()],
LinuxLibcPreference::Glibc => vec![
- "cc-switch-cli-linux-x64.tar.gz".to_string(),
- "cc-switch-cli-linux-x64-musl.tar.gz".to_string(),
+ "cc-switch-tui-linux-x64.tar.gz".to_string(),
+ "cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
],
},
("linux", "aarch64") => match preference {
LinuxLibcPreference::Auto => vec![
- "cc-switch-cli-linux-arm64-musl.tar.gz".to_string(),
- "cc-switch-cli-linux-arm64.tar.gz".to_string(),
+ "cc-switch-tui-linux-arm64-musl.tar.gz".to_string(),
+ "cc-switch-tui-linux-arm64.tar.gz".to_string(),
],
LinuxLibcPreference::Musl => {
- vec!["cc-switch-cli-linux-arm64-musl.tar.gz".to_string()]
+ vec!["cc-switch-tui-linux-arm64-musl.tar.gz".to_string()]
}
LinuxLibcPreference::Glibc => vec![
- "cc-switch-cli-linux-arm64.tar.gz".to_string(),
- "cc-switch-cli-linux-arm64-musl.tar.gz".to_string(),
+ "cc-switch-tui-linux-arm64.tar.gz".to_string(),
+ "cc-switch-tui-linux-arm64-musl.tar.gz".to_string(),
],
},
- ("windows", "x86_64") => vec!["cc-switch-cli-windows-x64.zip".to_string()],
+ ("windows", "x86_64") => vec!["cc-switch-tui-windows-x64.zip".to_string()],
_ => {
return Err(AppError::Message(format!(
"Self-update is not supported for platform {os}/{arch}."
@@ -657,18 +676,6 @@ async fn resolve_target_release(
})
}
-async fn resolve_target_tag(
- client: &reqwest::Client,
- version: Option<&str>,
-) -> Result {
- let tag = match version.map(str::trim).filter(|v| !v.is_empty()) {
- Some(version) => normalize_tag(version),
- None => fetch_latest_release_tag(client, REPO_URL).await?,
- };
- validate_target_tag(&tag)?;
- Ok(tag)
-}
-
fn validate_target_tag(tag: &str) -> Result<(), AppError> {
if !tag.starts_with('v') {
return Err(AppError::Message(format!(
@@ -865,8 +872,8 @@ fn extract_release_tag_from_url(url: &Url) -> Option {
}
fn tagged_asset_name(tag: &str, asset_name: &str) -> String {
- if let Some(suffix) = asset_name.strip_prefix("cc-switch-cli-") {
- return format!("cc-switch-cli-{tag}-{suffix}");
+ if let Some(suffix) = asset_name.strip_prefix("cc-switch-tui-") {
+ return format!("cc-switch-tui-{tag}-{suffix}");
}
asset_name.to_string()
}
diff --git a/src-tauri/src/cli/commands/update/tests.rs b/src-tauri/src/cli/commands/update/tests.rs
index b8ce670b..66a8ffc2 100644
--- a/src-tauri/src/cli/commands/update/tests.rs
+++ b/src-tauri/src/cli/commands/update/tests.rs
@@ -18,8 +18,8 @@ fn normalize_tag_keeps_existing_prefix() {
#[test]
fn parse_checksum_for_asset_finds_plain_filename() {
let checksums =
- "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cc-switch-cli-linux-x64-musl.tar.gz\n";
- let got = parse_checksum_for_asset(checksums, "cc-switch-cli-linux-x64-musl.tar.gz")
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cc-switch-tui-linux-x64-musl.tar.gz\n";
+ let got = parse_checksum_for_asset(checksums, "cc-switch-tui-linux-x64-musl.tar.gz")
.expect("checksum should exist");
assert_eq!(
got,
@@ -30,8 +30,8 @@ fn parse_checksum_for_asset_finds_plain_filename() {
#[test]
fn parse_checksum_for_asset_supports_star_prefix() {
let checksums =
- "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB *cc-switch-cli-linux-x64-musl.tar.gz\n";
- let got = parse_checksum_for_asset(checksums, "cc-switch-cli-linux-x64-musl.tar.gz")
+ "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB *cc-switch-tui-linux-x64-musl.tar.gz\n";
+ let got = parse_checksum_for_asset(checksums, "cc-switch-tui-linux-x64-musl.tar.gz")
.expect("checksum should exist");
assert_eq!(
got,
@@ -53,11 +53,11 @@ fn parse_checksum_for_asset_supports_spaces_in_filename() {
#[test]
fn release_page_url_for_github_com() {
- let url = release_page_url("https://github.com/saladday/cc-switch-cli", "latest")
+ let url = release_page_url("https://github.com/handy-sun/cc-switch-tui", "latest")
.expect("release page url should be built");
assert_eq!(
url.as_str(),
- "https://github.com/saladday/cc-switch-cli/releases/latest"
+ "https://github.com/handy-sun/cc-switch-tui/releases/latest"
);
}
@@ -76,29 +76,29 @@ fn release_page_url_for_github_enterprise() {
#[test]
fn release_asset_names_prefer_plain_then_tagged_variant() {
- let names = release_asset_names("v4.6.2", "cc-switch-cli-linux-x64-musl.tar.gz");
+ let names = release_asset_names("v4.6.2", "cc-switch-tui-linux-x64-musl.tar.gz");
assert_eq!(
names,
vec![
- "cc-switch-cli-linux-x64-musl.tar.gz".to_string(),
- "cc-switch-cli-v4.6.2-linux-x64-musl.tar.gz".to_string(),
+ "cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
+ "cc-switch-tui-v4.6.2-linux-x64-musl.tar.gz".to_string(),
]
);
}
#[test]
fn release_api_url_for_github_com() {
- let url = release_api_url("https://github.com/saladday/cc-switch-cli", "latest")
+ let url = release_api_url("https://github.com/handy-sun/cc-switch-tui", "latest")
.expect("api url should be built");
assert_eq!(
url.as_str(),
- "https://api.github.com/repos/saladday/cc-switch-cli/releases/latest"
+ "https://api.github.com/repos/handy-sun/cc-switch-tui/releases/latest"
);
}
#[test]
fn extract_release_tag_from_url_reads_release_tag_page() {
- let url = Url::parse("https://github.com/saladday/cc-switch-cli/releases/tag/v4.6.2")
+ let url = Url::parse("https://github.com/handy-sun/cc-switch-tui/releases/tag/v4.6.2")
.expect("url should parse");
let tag = extract_release_tag_from_url(&url).expect("tag should be extracted");
assert_eq!(tag, "v4.6.2");
@@ -176,17 +176,17 @@ async fn fetch_latest_release_tag_falls_back_to_release_page_after_rate_limit()
fn select_release_asset_prefers_unprefixed_name() {
let assets = vec![
ReleaseAsset {
- name: "cc-switch-cli-v4.6.2-linux-x64-musl.tar.gz".to_string(),
+ name: "cc-switch-tui-v4.6.2-linux-x64-musl.tar.gz".to_string(),
browser_download_url: "https://example.com/tagged".to_string(),
digest: None,
},
ReleaseAsset {
- name: "cc-switch-cli-linux-x64-musl.tar.gz".to_string(),
+ name: "cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
browser_download_url: "https://example.com/plain".to_string(),
digest: None,
},
];
- let selected = select_release_asset(&assets, "v4.6.2", "cc-switch-cli-linux-x64-musl.tar.gz")
+ let selected = select_release_asset(&assets, "v4.6.2", "cc-switch-tui-linux-x64-musl.tar.gz")
.expect("asset should be selected");
assert_eq!(selected.browser_download_url, "https://example.com/plain");
}
@@ -194,11 +194,11 @@ fn select_release_asset_prefers_unprefixed_name() {
#[test]
fn select_release_asset_falls_back_to_tagged_variant() {
let assets = vec![ReleaseAsset {
- name: "cc-switch-cli-v4.6.2-linux-x64-musl.tar.gz".to_string(),
+ name: "cc-switch-tui-v4.6.2-linux-x64-musl.tar.gz".to_string(),
browser_download_url: "https://example.com/tagged".to_string(),
digest: None,
}];
- let selected = select_release_asset(&assets, "v4.6.2", "cc-switch-cli-linux-x64-musl.tar.gz")
+ let selected = select_release_asset(&assets, "v4.6.2", "cc-switch-tui-linux-x64-musl.tar.gz")
.expect("asset should be selected");
assert_eq!(selected.browser_download_url, "https://example.com/tagged");
}
@@ -235,9 +235,9 @@ fn should_not_skip_when_version_explicitly_requested() {
#[test]
fn sanitized_asset_file_name_strips_path_segments() {
- let name = sanitized_asset_file_name("nested/path/cc-switch-cli-linux-x64-musl.tar.gz")
+ let name = sanitized_asset_file_name("nested/path/cc-switch-tui-linux-x64-musl.tar.gz")
.expect("file name should be extracted");
- assert_eq!(name, "cc-switch-cli-linux-x64-musl.tar.gz");
+ assert_eq!(name, "cc-switch-tui-linux-x64-musl.tar.gz");
}
#[test]
@@ -261,7 +261,7 @@ fn validate_target_tag_rejects_path_content() {
fn validate_download_size_limit_accepts_limit_boundary() {
validate_download_size_limit(
MAX_RELEASE_ASSET_SIZE_BYTES,
- "cc-switch-cli-linux-x64-musl.tar.gz",
+ "cc-switch-tui-linux-x64-musl.tar.gz",
)
.expect("size at limit should pass");
}
@@ -270,7 +270,7 @@ fn validate_download_size_limit_accepts_limit_boundary() {
fn validate_download_size_limit_rejects_oversized_asset() {
let err = validate_download_size_limit(
MAX_RELEASE_ASSET_SIZE_BYTES + 1,
- "cc-switch-cli-linux-x64-musl.tar.gz",
+ "cc-switch-tui-linux-x64-musl.tar.gz",
)
.expect_err("size over limit should fail");
assert!(err.to_string().contains("too large"));
@@ -285,12 +285,12 @@ fn select_manifest_asset_prefers_linux_glibc_variant_when_overridden() {
platforms: BTreeMap::from([(
"linux-x86_64".to_string(),
UpdatePlatformEntry {
- url: "https://example.com/cc-switch-cli-linux-x64-musl.tar.gz".to_string(),
+ url: "https://example.com/cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
signature: "musl-signature".to_string(),
variants: BTreeMap::from([(
"glibc".to_string(),
UpdatePlatformVariant {
- url: "https://example.com/cc-switch-cli-linux-x64.tar.gz".to_string(),
+ url: "https://example.com/cc-switch-tui-linux-x64.tar.gz".to_string(),
signature: "glibc-signature".to_string(),
},
)]),
@@ -303,7 +303,7 @@ fn select_manifest_asset_prefers_linux_glibc_variant_when_overridden() {
assert_eq!(
asset.url,
- "https://example.com/cc-switch-cli-linux-x64.tar.gz"
+ "https://example.com/cc-switch-tui-linux-x64.tar.gz"
);
assert_eq!(asset.signature, "glibc-signature");
}
@@ -339,12 +339,12 @@ fn manifest_linux_asset_candidates_keep_musl_strict_when_forced() {
platforms: BTreeMap::from([(
"linux-x86_64".to_string(),
UpdatePlatformEntry {
- url: "https://example.com/cc-switch-cli-linux-x64-musl.tar.gz".to_string(),
+ url: "https://example.com/cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
signature: "musl-signature".to_string(),
variants: BTreeMap::from([(
"glibc".to_string(),
UpdatePlatformVariant {
- url: "https://example.com/cc-switch-cli-linux-x64.tar.gz".to_string(),
+ url: "https://example.com/cc-switch-tui-linux-x64.tar.gz".to_string(),
signature: "glibc-signature".to_string(),
},
)]),
@@ -359,7 +359,7 @@ fn manifest_linux_asset_candidates_keep_musl_strict_when_forced() {
assert_eq!(
candidates,
vec![ManifestAsset {
- url: "https://example.com/cc-switch-cli-linux-x64-musl.tar.gz".to_string(),
+ url: "https://example.com/cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
signature: "musl-signature".to_string(),
}]
);
@@ -374,8 +374,8 @@ fn legacy_linux_asset_candidates_follow_glibc_override() {
assert_eq!(
candidates,
vec![
- "cc-switch-cli-linux-x64.tar.gz".to_string(),
- "cc-switch-cli-linux-x64-musl.tar.gz".to_string(),
+ "cc-switch-tui-linux-x64.tar.gz".to_string(),
+ "cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
]
);
}
@@ -388,7 +388,7 @@ fn legacy_linux_asset_candidates_keep_musl_strict_when_forced() {
assert_eq!(
candidates,
- vec!["cc-switch-cli-linux-x64-musl.tar.gz".to_string(),]
+ vec!["cc-switch-tui-linux-x64-musl.tar.gz".to_string(),]
);
}
diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs
index c3928d6a..464e88d2 100644
--- a/src-tauri/src/cli/i18n.rs
+++ b/src-tauri/src/cli/i18n.rs
@@ -298,7 +298,7 @@ pub mod texts {
// Ratatui TUI (new interactive UI)
pub fn tui_app_title() -> &'static str {
- "cc-switch"
+ "cc-switch-tui"
}
pub fn tui_tabs_title() -> &'static str {
@@ -485,17 +485,17 @@ pub mod texts {
pub fn tui_footer_action_keys_providers() -> &'static str {
if is_chinese() {
- "[ ] 切换应用 Enter 详情 s 切换 a 添加 e 编辑 d 删除 t 测速 c 健康检查 / 过滤 Esc 返回 ? 帮助"
+ "[ ] 切换应用 Enter 详情 Space 切换 a 新增 e 编辑 d 删除 t 测试 r 刷新 o 临时启动 f 管理故障转移 x 设为默认 / 过滤 Esc 返回 ? 帮助"
} else {
- "[ ] switch app Enter details s switch a add e edit d delete t speedtest c stream check / filter Esc back ? help"
+ "[ ] switch app Enter details Space switch a add e edit d delete t test r refresh o launch temp f manage failover x set default / filter Esc back ? help"
}
}
pub fn tui_footer_action_keys_provider_detail() -> &'static str {
if is_chinese() {
- "[ ] 切换应用 s 切换 e 编辑 t 测速 c 健康检查 / 过滤 Esc 返回 ? 帮助"
+ "[ ] 切换应用 Space 切换 e 编辑 t 测试 r 刷新 o 临时启动 f 管理故障转移 x 设为默认 / 过滤 Esc 返回 ? 帮助"
} else {
- "[ ] switch app s switch e edit t speedtest c stream check / filter Esc back ? help"
+ "[ ] switch app Space switch e edit t test r refresh o launch temp f manage failover x set default / filter Esc back ? help"
}
}
@@ -565,9 +565,9 @@ pub mod texts {
pub fn tui_help_text() -> &'static str {
if is_chinese() {
- "[ ] 切换应用\n←→ 切换菜单/内容焦点\n↑↓ 移动\n/ 过滤\nEsc 返回\n? 显示/关闭帮助\n\n页面快捷键(在页面内容区顶部显示):\n- 供应商:Enter 详情,s 切换/添加移除,a 添加,e 编辑,d 删除,t 测速,c 健康检查\n- 供应商详情:s 切换/添加移除,e 编辑,t 测速,c 健康检查\n- MCP:x 启用/禁用(当前应用),m 选择应用,a 添加,e 编辑,i 导入已有,d 删除\n- 提示词:c 新建,r 刷新,Enter 查看,a 激活,x 取消激活(当前),n 重命名,e 编辑,d 删除\n- 技能:Enter 详情,x 启用/禁用(当前应用),m 选择应用,d 卸载,i 导入已有\n- 配置:Enter 打开/执行,e 编辑片段\n- 设置:Enter 应用"
+ "[ ] 切换应用\n←→ 切换菜单/内容焦点\n↑↓ 移动\n/ 过滤\nEsc 返回\n? 显示/关闭帮助\n\n文本输入:Ctrl+A/E 行首/行尾,Ctrl+U/K 删除行片段,Ctrl+W 删除前词,Alt+B/F 按词移动\n\n页面快捷键(在页面内容区顶部显示):\n- 供应商:Enter 详情,Space 切换,a 新增,e 编辑,d 删除,t 测试,r 刷新,o 临时启动,f 管理故障转移,x 设为默认\n- 供应商详情:Space 切换,e 编辑,t 测试,r 刷新,o 临时启动,f 管理故障转移,x 设为默认\n- MCP:x 启用/禁用(当前应用),m 选择应用,a 添加,e 编辑,i 导入已有,d 删除\n- 提示词:c 新建,r 刷新,Enter 查看,a 激活,x 取消激活(当前),n 重命名,e 编辑,d 删除\n- 技能:Enter 详情,x 启用/禁用(当前应用),m 选择应用,d 卸载,i 导入已有\n- 配置:Enter 打开/执行,e 编辑片段\n- 设置:Enter 应用"
} else {
- "[ ] switch app\n←→ focus menu/content\n↑↓ move\n/ filter\nEsc back\n? toggle help\n\nPage keys (shown at the top of each page):\n- Providers: Enter details, s switch/add-remove, a add, e edit, d delete, t speedtest, c stream check\n- Provider Detail: s switch/add-remove, e edit, t speedtest, c stream check\n- MCP: x toggle current, m select apps, a add, e edit, i import existing, d delete\n- Prompts: c create, r refresh, Enter view, a activate, x deactivate active, n rename, e edit, d delete\n- Skills: Enter details, x toggle current, m select apps, d uninstall, i import existing\n- Config: Enter open/run, e edit snippet\n- Settings: Enter apply"
+ "[ ] switch app\n←→ focus menu/content\n↑↓ move\n/ filter\nEsc back\n? toggle help\n\nText input: Ctrl+A/E move line, Ctrl+U/K delete line parts, Ctrl+W delete word, Alt+B/F move word\n\nPage keys (shown at the top of each page):\n- Providers: Enter details, Space switch, a add, e edit, d delete, t test, r refresh, o launch temp, f manage failover, x set default\n- Provider Detail: Space switch, e edit, t test, r refresh, o launch temp, f manage failover, x set default\n- MCP: x toggle current, m select apps, a add, e edit, i import existing, d delete\n- Prompts: c create, r refresh, Enter view, a activate, x deactivate active, n rename, e edit, d delete\n- Skills: Enter details, x toggle current, m select apps, d uninstall, i import existing\n- Config: Enter open/run, e edit snippet\n- Settings: Enter apply"
}
}
@@ -695,6 +695,14 @@ pub mod texts {
}
}
+ pub fn tui_provider_test_menu_title() -> &'static str {
+ if is_chinese() {
+ "测试"
+ } else {
+ "Test"
+ }
+ }
+
pub fn tui_main_hint() -> &'static str {
if is_chinese() {
"使用左侧菜单(↑↓ + Enter)。←→ 在菜单与内容间切换焦点。"
@@ -1063,6 +1071,22 @@ pub mod texts {
}
}
+ pub fn tui_label_current() -> &'static str {
+ if is_chinese() {
+ "当前"
+ } else {
+ "Current"
+ }
+ }
+
+ pub fn tui_provider_none() -> &'static str {
+ if is_chinese() {
+ "无"
+ } else {
+ "None"
+ }
+ }
+
pub fn tui_label_latest_proxy_route() -> &'static str {
if is_chinese() {
"最近代理路由"
@@ -1894,6 +1918,22 @@ pub mod texts {
}
}
+ pub fn tui_label_app_openclaw() -> &'static str {
+ if is_chinese() {
+ "应用: OpenClaw"
+ } else {
+ "App: OpenClaw"
+ }
+ }
+
+ pub fn tui_label_app_hermes() -> &'static str {
+ if is_chinese() {
+ "应用: Hermes"
+ } else {
+ "App: Hermes"
+ }
+ }
+
pub fn tui_form_templates_title() -> &'static str {
if is_chinese() {
"模板"
@@ -2130,6 +2170,46 @@ pub mod texts {
}
}
+ pub fn tui_provider_empty_title() -> &'static str {
+ if is_chinese() {
+ "还没有添加任何供应商"
+ } else {
+ "No providers have been added yet"
+ }
+ }
+
+ pub fn tui_provider_empty_subtitle() -> &'static str {
+ if is_chinese() {
+ "如果你已有配置,请点击\"导入当前配置\",所有数据将安全保存在 default 供应商中"
+ } else {
+ "If you already have a config, use \"Import Current Config\". Everything will be safely stored in the default provider."
+ }
+ }
+
+ pub fn tui_provider_empty_subtitle_codex() -> &'static str {
+ if is_chinese() {
+ "如果你已有 Codex 配置,请点击\"导入当前配置\",程序会读取当前 config.toml 里的所有可识别供应商并合并到 TUI 中"
+ } else {
+ "If you already have a Codex config, use \"Import Current Config\". CC-Switch will read every recognizable provider from the current config.toml and merge them into the TUI."
+ }
+ }
+
+ pub fn tui_key_import_current_config() -> &'static str {
+ if is_chinese() {
+ "导入当前配置"
+ } else {
+ "import current config"
+ }
+ }
+
+ pub fn tui_key_add_provider() -> &'static str {
+ if is_chinese() {
+ "添加供应商"
+ } else {
+ "add provider"
+ }
+ }
+
pub fn tui_codex_official_no_api_key_tip() -> &'static str {
if is_chinese() {
"官方无需填写 API Key,直接保存即可。"
@@ -2156,9 +2236,9 @@ pub mod texts {
pub fn tui_provider_detail_keys() -> &'static str {
if is_chinese() {
- "按键:s=切换 e=编辑 t=测速 c=健康检查"
+ "按键:Space=切换 e=编辑 t=测试"
} else {
- "Keys: s=switch e=edit t=speedtest c=stream check"
+ "Keys: Space=switch e=edit t=test"
}
}
@@ -2202,6 +2282,14 @@ pub mod texts {
}
}
+ pub fn tui_key_test() -> &'static str {
+ if is_chinese() {
+ "测试"
+ } else {
+ "test"
+ }
+ }
+
pub fn tui_key_stream_check() -> &'static str {
if is_chinese() {
"健康检查"
@@ -2282,6 +2370,14 @@ pub mod texts {
}
}
+ pub fn tui_key_failover() -> &'static str {
+ if is_chinese() {
+ "管理故障转移"
+ } else {
+ "manage failover"
+ }
+ }
+
pub fn tui_key_install() -> &'static str {
if is_chinese() {
"安装"
@@ -2410,6 +2506,14 @@ pub mod texts {
}
}
+ pub fn tui_key_resolve() -> &'static str {
+ if is_chinese() {
+ "解决"
+ } else {
+ "resolve"
+ }
+ }
+
pub fn tui_key_activate() -> &'static str {
if is_chinese() {
"激活"
@@ -2618,6 +2722,14 @@ pub mod texts {
}
}
+ pub fn tui_key_edges() -> &'static str {
+ if is_chinese() {
+ "首尾"
+ } else {
+ "top/end"
+ }
+ }
+
pub fn tui_key_fetch_model() -> &'static str {
if is_chinese() {
"获取模型"
@@ -2956,6 +3068,14 @@ pub mod texts {
}
}
+ pub fn tui_confirm_uninstall_skills_message(count: usize) -> String {
+ if is_chinese() {
+ format!("确认卸载选中的 {count} 个 Skill?")
+ } else {
+ format!("Uninstall the {count} selected Skills?")
+ }
+ }
+
pub fn tui_skills_discover_title() -> &'static str {
if is_chinese() {
"发现 Skills"
@@ -3057,6 +3177,14 @@ pub mod texts {
}
}
+ pub fn tui_skills_agent_import_title() -> &'static str {
+ if is_chinese() {
+ "从智能体导入技能"
+ } else {
+ "Import Skills from Agent"
+ }
+ }
+
pub fn tui_skills_unmanaged_hint() -> &'static str {
tui_skills_import_description()
}
@@ -3069,6 +3197,14 @@ pub mod texts {
}
}
+ pub fn tui_skills_agent_import_description() -> &'static str {
+ if is_chinese() {
+ "选择要导入到 CC Switch 的智能体技能。"
+ } else {
+ "Select agent skills to import into CC Switch."
+ }
+ }
+
pub fn tui_skills_unmanaged_empty() -> &'static str {
if is_chinese() {
"未发现可导入的技能。"
@@ -3101,6 +3237,14 @@ pub mod texts {
}
}
+ pub fn tui_settings_skill_sync_method_label() -> &'static str {
+ if is_chinese() {
+ "技能管理方式"
+ } else {
+ "Skill Management"
+ }
+ }
+
pub fn tui_skills_sync_method_title() -> &'static str {
if is_chinese() {
"选择同步方式"
@@ -3148,14 +3292,16 @@ pub mod texts {
codex: usize,
gemini: usize,
opencode: usize,
+ openclaw: usize,
+ hermes: usize,
) -> String {
if is_chinese() {
format!(
- "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}"
+ "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · OpenClaw: {openclaw} · Hermes: {hermes}"
)
} else {
format!(
- "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}"
+ "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · OpenClaw: {openclaw} · Hermes: {hermes}"
)
}
}
@@ -3165,14 +3311,16 @@ pub mod texts {
codex: usize,
gemini: usize,
opencode: usize,
+ openclaw: usize,
+ hermes: usize,
) -> String {
if is_chinese() {
format!(
- "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}"
+ "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · OpenClaw: {openclaw} · Hermes: {hermes}"
)
} else {
format!(
- "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}"
+ "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · OpenClaw: {openclaw} · Hermes: {hermes}"
)
}
}
@@ -3185,6 +3333,125 @@ pub mod texts {
}
}
+ pub fn tui_mcp_live_header() -> &'static str {
+ "Live"
+ }
+
+ pub fn tui_mcp_live_drift_summary(
+ changed: usize,
+ live_only: usize,
+ db_only: usize,
+ invalid: usize,
+ ) -> String {
+ let mut parts = Vec::new();
+ if changed > 0 {
+ parts.push(if is_chinese() {
+ format!("{changed} 已变更")
+ } else {
+ format!("{changed} changed")
+ });
+ }
+ if live_only > 0 {
+ parts.push(if is_chinese() {
+ format!("{live_only} 仅 live")
+ } else {
+ format!("{live_only} live-only")
+ });
+ }
+ if db_only > 0 {
+ parts.push(if is_chinese() {
+ format!("{db_only} 缺少 live")
+ } else {
+ format!("{db_only} db-only")
+ });
+ }
+ if invalid > 0 {
+ parts.push(if is_chinese() {
+ "live 配置无效".to_string()
+ } else {
+ "live invalid".to_string()
+ });
+ }
+
+ if is_chinese() {
+ format!("Live 漂移:{}", parts.join("、"))
+ } else {
+ format!("Live drift: {}", parts.join(", "))
+ }
+ }
+
+ pub fn tui_mcp_live_drift_resolve_title() -> &'static str {
+ if is_chinese() {
+ "解决 MCP Live 漂移"
+ } else {
+ "Resolve MCP Live Drift"
+ }
+ }
+
+ pub fn tui_mcp_live_drift_import_live() -> &'static str {
+ if is_chinese() {
+ "从 live 导入到 cc-switch"
+ } else {
+ "Import live into cc-switch"
+ }
+ }
+
+ pub fn tui_mcp_live_drift_push_db() -> &'static str {
+ if is_chinese() {
+ "用 cc-switch 覆盖 live"
+ } else {
+ "Push cc-switch to live"
+ }
+ }
+
+ pub fn tui_mcp_live_drift_cancel() -> &'static str {
+ if is_chinese() {
+ "暂不处理"
+ } else {
+ "Cancel"
+ }
+ }
+
+ pub fn tui_mcp_live_drift_status(kind: &crate::services::McpLiveDriftKind) -> &'static str {
+ match kind {
+ crate::services::McpLiveDriftKind::Changed => {
+ if is_chinese() {
+ "live changed"
+ } else {
+ "live changed"
+ }
+ }
+ crate::services::McpLiveDriftKind::LiveOnly => {
+ if is_chinese() {
+ "live only"
+ } else {
+ "live only"
+ }
+ }
+ crate::services::McpLiveDriftKind::DbOnly => {
+ if is_chinese() {
+ "missing live"
+ } else {
+ "missing live"
+ }
+ }
+ crate::services::McpLiveDriftKind::LiveInvalid => {
+ if is_chinese() {
+ "live invalid"
+ } else {
+ "live invalid"
+ }
+ }
+ _ => {
+ if is_chinese() {
+ "in sync"
+ } else {
+ "in sync"
+ }
+ }
+ }
+ }
+
pub fn tui_skills_action_import_existing() -> &'static str {
if is_chinese() {
"导入已有"
@@ -3193,6 +3460,14 @@ pub mod texts {
}
}
+ pub fn tui_skills_action_import_agent() -> &'static str {
+ if is_chinese() {
+ "从智能体导入"
+ } else {
+ "Import from Agent"
+ }
+ }
+
pub fn tui_skills_empty_title() -> &'static str {
if is_chinese() {
"暂无已安装的技能"
@@ -4475,6 +4750,14 @@ pub mod texts {
}
}
+ pub fn tui_skills_batch_selection_name(count: usize) -> String {
+ if is_chinese() {
+ format!("已选择 {count} 个 Skill")
+ } else {
+ format!("{count} selected Skills")
+ }
+ }
+
pub fn tui_toast_provider_no_api_url() -> &'static str {
if is_chinese() {
"该供应商未配置 API URL。"
@@ -4571,14 +4854,6 @@ pub mod texts {
}
}
- pub fn tui_toast_prompt_edit_not_implemented() -> &'static str {
- if is_chinese() {
- "提示词编辑尚未实现。"
- } else {
- "Prompt editing not implemented yet."
- }
- }
-
pub fn tui_toast_prompt_edit_finished() -> &'static str {
if is_chinese() {
"提示词编辑完成"
@@ -5130,6 +5405,20 @@ pub mod texts {
}
}
+ pub fn tui_toast_skills_toggled(count: usize, enabled: bool) -> String {
+ if is_chinese() {
+ format!(
+ "已{} {count} 个 Skill",
+ if enabled { "启用" } else { "禁用" }
+ )
+ } else {
+ format!(
+ "{} {count} Skills",
+ if enabled { "Enabled" } else { "Disabled" }
+ )
+ }
+ }
+
pub fn tui_toast_skill_uninstalled(directory: &str) -> String {
if is_chinese() {
format!("已卸载: {directory}")
@@ -5138,6 +5427,14 @@ pub mod texts {
}
}
+ pub fn tui_toast_skills_uninstalled(count: usize) -> String {
+ if is_chinese() {
+ format!("已卸载 {count} 个 Skill")
+ } else {
+ format!("Uninstalled {count} Skills")
+ }
+ }
+
pub fn tui_toast_skill_apps_updated() -> &'static str {
if is_chinese() {
"Skill 应用已更新。"
@@ -5156,9 +5453,9 @@ pub mod texts {
pub fn tui_toast_skills_sync_method_set(method: &str) -> String {
if is_chinese() {
- format!("同步方式已设置为: {method}")
+ format!("同步方式已设置为:{method}。重新同步后会应用到已有技能。")
} else {
- format!("Sync method set to: {method}")
+ format!("Sync method set to: {method}. Re-sync to apply it to existing skills.")
}
}
@@ -5378,6 +5675,22 @@ pub mod texts {
}
}
+ pub fn tui_toast_mcp_live_imported() -> &'static str {
+ if is_chinese() {
+ "已从 live 导入 MCP 服务器。"
+ } else {
+ "Imported MCP server from live config."
+ }
+ }
+
+ pub fn tui_toast_mcp_live_pushed() -> &'static str {
+ if is_chinese() {
+ "已用 cc-switch MCP 配置覆盖 live。"
+ } else {
+ "Pushed cc-switch MCP server to live config."
+ }
+ }
+
pub fn tui_toast_live_sync_skipped_uninitialized(app: &str) -> String {
if is_chinese() {
format!(
@@ -6104,6 +6417,14 @@ pub mod texts {
}
}
+ pub fn skills_no_agent_installed_found() -> &'static str {
+ if is_chinese() {
+ "未发现可导入的智能体技能。已管理技能会自动同步实际启用状态。"
+ } else {
+ "No agent skills found to import. Managed skills automatically reflect live app enablement."
+ }
+ }
+
pub fn skills_select_unmanaged_to_import() -> &'static str {
if is_chinese() {
"选择要导入的技能:"
@@ -8865,12 +9186,14 @@ mod tests {
assert_eq!(texts::menu_manage_mcp(), "🔌 MCP 服务器");
let help = texts::tui_help_text();
+ assert!(help.contains("文本输入:Ctrl+A/E 行首/行尾"));
assert!(help.contains("供应商:Enter 详情"));
- assert!(help.contains("供应商详情:s 切换/添加移除"));
- assert!(help.contains("提示词:Enter 查看"));
+ assert!(help.contains("供应商详情:Space 切换"));
+ assert!(help.contains("提示词:c 新建,r 刷新,Enter 查看"));
assert!(help.contains("技能:Enter 详情"));
assert!(help.contains("配置:Enter 打开/执行"));
assert!(help.contains("设置:Enter 应用"));
+ assert!(!help.contains("Text input:"));
assert!(!help.contains("Providers:"));
assert!(!help.contains("Provider Detail:"));
assert!(!help.contains("Skills:"));
diff --git a/src-tauri/src/cli/i18n/texts/config_actions.rs b/src-tauri/src/cli/i18n/texts/config_actions.rs
index 85a4ee2c..27636145 100644
--- a/src-tauri/src/cli/i18n/texts/config_actions.rs
+++ b/src-tauri/src/cli/i18n/texts/config_actions.rs
@@ -123,6 +123,14 @@ pub fn tui_confirm_uninstall_skill_message(name: &str, directory: &str) -> Strin
}
}
+pub fn tui_confirm_uninstall_skills_message(count: usize) -> String {
+ if is_chinese() {
+ format!("确认卸载选中的 {count} 个 Skill?")
+ } else {
+ format!("Uninstall the {count} selected Skills?")
+ }
+}
+
pub fn tui_skills_discover_title() -> &'static str {
if is_chinese() {
"发现 Skills"
@@ -224,6 +232,14 @@ pub fn tui_skills_import_title() -> &'static str {
}
}
+pub fn tui_skills_agent_import_title() -> &'static str {
+ if is_chinese() {
+ "从智能体导入技能"
+ } else {
+ "Import Skills from Agent"
+ }
+}
+
pub fn tui_skills_unmanaged_hint() -> &'static str {
tui_skills_import_description()
}
@@ -236,6 +252,14 @@ pub fn tui_skills_import_description() -> &'static str {
}
}
+pub fn tui_skills_agent_import_description() -> &'static str {
+ if is_chinese() {
+ "选择要导入到 CC Switch 的智能体技能。"
+ } else {
+ "Select agent skills to import into CC Switch."
+ }
+}
+
pub fn tui_skills_unmanaged_empty() -> &'static str {
if is_chinese() {
"未发现可导入的技能。"
@@ -268,6 +292,14 @@ pub fn tui_skills_sync_method_label() -> &'static str {
}
}
+pub fn tui_settings_skill_sync_method_label() -> &'static str {
+ if is_chinese() {
+ "技能管理方式"
+ } else {
+ "Skill Management"
+ }
+}
+
pub fn tui_skills_sync_method_title() -> &'static str {
if is_chinese() {
"选择同步方式"
@@ -315,14 +347,16 @@ pub fn tui_skills_installed_counts(
codex: usize,
gemini: usize,
opencode: usize,
+ openclaw: usize,
+ hermes: usize,
) -> String {
if is_chinese() {
format!(
- "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}"
+ "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · OpenClaw: {openclaw} · Hermes: {hermes}"
)
} else {
format!(
- "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}"
+ "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · OpenClaw: {openclaw} · Hermes: {hermes}"
)
}
}
@@ -332,14 +366,15 @@ pub fn tui_mcp_server_counts(
codex: usize,
gemini: usize,
opencode: usize,
+ hermes: usize,
) -> String {
if is_chinese() {
format!(
- "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}"
+ "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}"
)
} else {
format!(
- "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}"
+ "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}"
)
}
}
@@ -360,6 +395,14 @@ pub fn tui_skills_action_import_existing() -> &'static str {
}
}
+pub fn tui_skills_action_import_agent() -> &'static str {
+ if is_chinese() {
+ "从智能体导入"
+ } else {
+ "Import from Agent"
+ }
+}
+
pub fn tui_skills_empty_title() -> &'static str {
if is_chinese() {
"暂无已安装的技能"
@@ -760,6 +803,14 @@ pub fn tui_skill_apps_title(name: &str) -> String {
}
}
+pub fn tui_skills_batch_selection_name(count: usize) -> String {
+ if is_chinese() {
+ format!("已选择 {count} 个 Skill")
+ } else {
+ format!("{count} selected Skills")
+ }
+}
+
pub fn tui_toast_provider_no_api_url() -> &'static str {
if is_chinese() {
"该供应商未配置 API URL。"
@@ -856,14 +907,6 @@ pub fn tui_confirm_delete_prompt_message(name: &str, id: &str) -> String {
}
}
-pub fn tui_toast_prompt_edit_not_implemented() -> &'static str {
- if is_chinese() {
- "提示词编辑尚未实现。"
- } else {
- "Prompt editing not implemented yet."
- }
-}
-
pub fn tui_toast_prompt_edit_finished() -> &'static str {
if is_chinese() {
"提示词编辑完成"
diff --git a/src-tauri/src/cli/i18n/texts/core.rs b/src-tauri/src/cli/i18n/texts/core.rs
index b854f7ed..8cb9b8f9 100644
--- a/src-tauri/src/cli/i18n/texts/core.rs
+++ b/src-tauri/src/cli/i18n/texts/core.rs
@@ -160,7 +160,7 @@ pub fn interactive_legacy_tui_removed() -> &'static str {
// Ratatui TUI (new interactive UI)
pub fn tui_app_title() -> &'static str {
- "cc-switch"
+ "cc-switch-tui"
}
pub fn tui_tabs_title() -> &'static str {
@@ -347,17 +347,17 @@ pub fn tui_footer_action_keys_main() -> &'static str {
pub fn tui_footer_action_keys_providers() -> &'static str {
if is_chinese() {
- "[ ] 切换应用 Enter 详情 s 切换 a 添加 e 编辑 d 删除 t 测速 c 健康检查 / 过滤 Esc 返回 ? 帮助"
+ "[ ] 切换应用 Enter 详情 Space 切换 a 新增 e 编辑 d 删除 t 测试 r 刷新 o 临时启动 f 管理故障转移 x 设为默认 / 过滤 Esc 返回 ? 帮助"
} else {
- "[ ] switch app Enter details s switch a add e edit d delete t speedtest c stream check / filter Esc back ? help"
+ "[ ] switch app Enter details Space switch a add e edit d delete t test r refresh o launch temp f manage failover x set default / filter Esc back ? help"
}
}
pub fn tui_footer_action_keys_provider_detail() -> &'static str {
if is_chinese() {
- "[ ] 切换应用 s 切换 e 编辑 t 测速 c 健康检查 / 过滤 Esc 返回 ? 帮助"
+ "[ ] 切换应用 Space 切换 e 编辑 t 测试 r 刷新 o 临时启动 f 管理故障转移 x 设为默认 / 过滤 Esc 返回 ? 帮助"
} else {
- "[ ] switch app s switch e edit t speedtest c stream check / filter Esc back ? help"
+ "[ ] switch app Space switch e edit t test r refresh o launch temp f manage failover x set default / filter Esc back ? help"
}
}
@@ -427,9 +427,9 @@ pub fn tui_help_title() -> &'static str {
pub fn tui_help_text() -> &'static str {
if is_chinese() {
- "[ ] 切换应用\n←→ 切换菜单/内容焦点\n↑↓ 移动\n/ 过滤\nEsc 返回\n? 显示/关闭帮助\n\n页面快捷键(在页面内容区顶部显示):\n- 供应商:Enter 详情,s 切换,a 添加,e 编辑,d 删除,t 测速,c 健康检查\n- 供应商详情:s 切换,e 编辑,t 测速,c 健康检查\n- MCP:x 启用/禁用(当前应用),m 选择应用,a 添加,e 编辑,i 导入已有,d 删除\n- 提示词:c 新建,r 刷新,Enter 查看,a 激活,x 取消激活(当前),n 重命名,e 编辑,d 删除\n- 技能:Enter 详情,x 启用/禁用(当前应用),m 选择应用,d 卸载,i 导入已有\n- 配置:Enter 打开/执行,e 编辑片段\n- 设置:Enter 应用"
+ "[ ] 切换应用\n←→ 切换菜单/内容焦点\n↑↓ 移动\n/ 过滤\nEsc 返回\n? 显示/关闭帮助\n\n文本输入:Ctrl+A/E 行首/行尾,Ctrl+U/K 删除行片段,Ctrl+W 删除前词,Alt+B/F 按词移动\n\n页面快捷键(在页面内容区顶部显示):\n- 供应商:Enter 详情,Space 切换,a 新增,e 编辑,d 删除,t 测试,r 刷新,o 临时启动,f 管理故障转移,x 设为默认\n- 供应商详情:Space 切换,e 编辑,t 测试,r 刷新,o 临时启动,f 管理故障转移,x 设为默认\n- MCP:x 启用/禁用(当前应用),m 选择应用,a 添加,e 编辑,i 导入已有,d 删除\n- 提示词:c 新建,r 刷新,Enter 查看,a 激活,x 取消激活(当前),n 重命名,e 编辑,d 删除\n- 技能:Enter 详情,x 启用/禁用(当前应用),m 选择应用,d 卸载,i 导入已有\n- 配置:Enter 打开/执行,e 编辑片段\n- 设置:Enter 应用"
} else {
- "[ ] switch app\n←→ focus menu/content\n↑↓ move\n/ filter\nEsc back\n? toggle help\n\nPage keys (shown at the top of each page):\n- Providers: Enter details, s switch, a add, e edit, d delete, t speedtest, c stream check\n- Provider Detail: s switch, e edit, t speedtest, c stream check\n- MCP: x toggle current, m select apps, a add, e edit, i import existing, d delete\n- Prompts: c create, r refresh, Enter view, a activate, x deactivate active, n rename, e edit, d delete\n- Skills: Enter details, x toggle current, m select apps, d uninstall, i import existing\n- Config: Enter open/run, e edit snippet\n- Settings: Enter apply"
+ "[ ] switch app\n←→ focus menu/content\n↑↓ move\n/ filter\nEsc back\n? toggle help\n\nText input: Ctrl+A/E move line, Ctrl+U/K delete line parts, Ctrl+W delete word, Alt+B/F move word\n\nPage keys (shown at the top of each page):\n- Providers: Enter details, Space switch, a add, e edit, d delete, t test, r refresh, o launch temp, f manage failover, x set default\n- Provider Detail: Space switch, e edit, t test, r refresh, o launch temp, f manage failover, x set default\n- MCP: x toggle current, m select apps, a add, e edit, i import existing, d delete\n- Prompts: c create, r refresh, Enter view, a activate, x deactivate active, n rename, e edit, d delete\n- Skills: Enter details, x toggle current, m select apps, d uninstall, i import existing\n- Config: Enter open/run, e edit snippet\n- Settings: Enter apply"
}
}
@@ -557,6 +557,14 @@ pub fn tui_stream_check_title() -> &'static str {
}
}
+pub fn tui_provider_test_menu_title() -> &'static str {
+ if is_chinese() {
+ "测试"
+ } else {
+ "Test"
+ }
+}
+
pub fn tui_main_hint() -> &'static str {
if is_chinese() {
"使用左侧菜单(↑↓ + Enter)。←→ 在菜单与内容间切换焦点。"
diff --git a/src-tauri/src/cli/i18n/texts/menu_skills.rs b/src-tauri/src/cli/i18n/texts/menu_skills.rs
index 0c07595d..038f3f95 100644
--- a/src-tauri/src/cli/i18n/texts/menu_skills.rs
+++ b/src-tauri/src/cli/i18n/texts/menu_skills.rs
@@ -289,6 +289,14 @@ pub fn skills_no_unmanaged_found() -> &'static str {
}
}
+pub fn skills_no_agent_installed_found() -> &'static str {
+ if is_chinese() {
+ "未发现可导入的智能体技能。已管理技能会自动同步实际启用状态。"
+ } else {
+ "No agent skills found to import. Managed skills automatically reflect live app enablement."
+ }
+}
+
pub fn skills_select_unmanaged_to_import() -> &'static str {
if is_chinese() {
"选择要导入的技能:"
diff --git a/src-tauri/src/cli/i18n/texts/providers.rs b/src-tauri/src/cli/i18n/texts/providers.rs
index 3575c028..3a5323ff 100644
--- a/src-tauri/src/cli/i18n/texts/providers.rs
+++ b/src-tauri/src/cli/i18n/texts/providers.rs
@@ -178,6 +178,22 @@ pub fn tui_label_app_opencode() -> &'static str {
}
}
+pub fn tui_label_app_openclaw() -> &'static str {
+ if is_chinese() {
+ "应用: OpenClaw"
+ } else {
+ "App: OpenClaw"
+ }
+}
+
+pub fn tui_label_app_hermes() -> &'static str {
+ if is_chinese() {
+ "应用: Hermes"
+ } else {
+ "App: Hermes"
+ }
+}
+
pub fn tui_form_templates_title() -> &'static str {
if is_chinese() {
"模板"
@@ -414,6 +430,46 @@ pub fn tui_provider_add_title() -> &'static str {
}
}
+pub fn tui_provider_empty_title() -> &'static str {
+ if is_chinese() {
+ "还没有添加任何供应商"
+ } else {
+ "No providers have been added yet"
+ }
+}
+
+pub fn tui_provider_empty_subtitle() -> &'static str {
+ if is_chinese() {
+ "如果你已有配置,请点击\"导入当前配置\",所有数据将安全保存在 default 供应商中"
+ } else {
+ "If you already have a config, use \"Import Current Config\". Everything will be safely stored in the default provider."
+ }
+}
+
+pub fn tui_provider_empty_subtitle_codex() -> &'static str {
+ if is_chinese() {
+ "如果你已有 Codex 配置,请点击\"导入当前配置\",程序会读取当前 config.toml 里的所有可识别供应商并合并到 TUI 中"
+ } else {
+ "If you already have a Codex config, use \"Import Current Config\". CC-Switch will read every recognizable provider from the current config.toml and merge them into the TUI."
+ }
+}
+
+pub fn tui_key_import_current_config() -> &'static str {
+ if is_chinese() {
+ "导入当前配置"
+ } else {
+ "import current config"
+ }
+}
+
+pub fn tui_key_add_provider() -> &'static str {
+ if is_chinese() {
+ "添加供应商"
+ } else {
+ "add provider"
+ }
+}
+
pub fn tui_codex_official_no_api_key_tip() -> &'static str {
if is_chinese() {
"官方无需填写 API Key,直接保存即可。"
@@ -440,9 +496,9 @@ pub fn tui_provider_edit_title(name: &str) -> String {
pub fn tui_provider_detail_keys() -> &'static str {
if is_chinese() {
- "按键:s=切换 e=编辑 t=测速 c=健康检查"
+ "按键:Space=切换 e=编辑 t=测试"
} else {
- "Keys: s=switch e=edit t=speedtest c=stream check"
+ "Keys: Space=switch e=edit t=test"
}
}
@@ -470,6 +526,14 @@ pub fn tui_key_speedtest() -> &'static str {
}
}
+pub fn tui_key_test() -> &'static str {
+ if is_chinese() {
+ "测试"
+ } else {
+ "test"
+ }
+}
+
pub fn tui_key_stream_check() -> &'static str {
if is_chinese() {
"健康检查"
@@ -558,6 +622,14 @@ pub fn tui_key_import() -> &'static str {
}
}
+pub fn tui_key_failover() -> &'static str {
+ if is_chinese() {
+ "管理故障转移"
+ } else {
+ "manage failover"
+ }
+}
+
pub fn tui_key_install() -> &'static str {
if is_chinese() {
"安装"
@@ -878,6 +950,14 @@ pub fn tui_key_select() -> &'static str {
}
}
+pub fn tui_key_edges() -> &'static str {
+ if is_chinese() {
+ "首尾"
+ } else {
+ "top/end"
+ }
+}
+
pub fn tui_key_fetch_model() -> &'static str {
if is_chinese() {
"获取模型"
diff --git a/src-tauri/src/cli/i18n/texts/toasts.rs b/src-tauri/src/cli/i18n/texts/toasts.rs
index 8bcdc344..273b85c2 100644
--- a/src-tauri/src/cli/i18n/texts/toasts.rs
+++ b/src-tauri/src/cli/i18n/texts/toasts.rs
@@ -430,6 +430,20 @@ pub fn tui_toast_skill_toggled(directory: &str, enabled: bool) -> String {
}
}
+pub fn tui_toast_skills_toggled(count: usize, enabled: bool) -> String {
+ if is_chinese() {
+ format!(
+ "已{} {count} 个 Skill",
+ if enabled { "启用" } else { "禁用" }
+ )
+ } else {
+ format!(
+ "{} {count} Skills",
+ if enabled { "Enabled" } else { "Disabled" }
+ )
+ }
+}
+
pub fn tui_toast_skill_uninstalled(directory: &str) -> String {
if is_chinese() {
format!("已卸载: {directory}")
@@ -438,6 +452,14 @@ pub fn tui_toast_skill_uninstalled(directory: &str) -> String {
}
}
+pub fn tui_toast_skills_uninstalled(count: usize) -> String {
+ if is_chinese() {
+ format!("已卸载 {count} 个 Skill")
+ } else {
+ format!("Uninstalled {count} Skills")
+ }
+}
+
pub fn tui_toast_skill_apps_updated() -> &'static str {
if is_chinese() {
"Skill 应用已更新。"
@@ -456,9 +478,9 @@ pub fn tui_toast_skills_synced() -> &'static str {
pub fn tui_toast_skills_sync_method_set(method: &str) -> String {
if is_chinese() {
- format!("同步方式已设置为: {method}")
+ format!("同步方式已设置为:{method}。重新同步后会应用到已有技能。")
} else {
- format!("Sync method set to: {method}")
+ format!("Sync method set to: {method}. Re-sync to apply it to existing skills.")
}
}
diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs
index 9c646b4f..057a264c 100644
--- a/src-tauri/src/cli/mod.rs
+++ b/src-tauri/src/cli/mod.rs
@@ -16,12 +16,16 @@ use crate::app_config::AppType;
#[derive(Parser)]
#[command(
- name = "cc-switch",
- version,
- about = "All-in-One Assistant for Claude Code, Codex, Gemini & OpenCode CLI",
- long_about = "Unified management for Claude Code, Codex, Gemini, and OpenCode CLI provider configurations, MCP servers, skills, prompts, local proxy routes, and environment checks.\n\nRun without arguments to enter interactive mode."
+ name = "cc-switch-tui",
+ disable_version_flag = true,
+ about = "All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode, OpenClaw & Hermes",
+ long_about = "Unified management for Claude Code, Codex, Gemini, OpenCode, OpenClaw, and Hermes provider configurations, MCP servers, skills, prompts, local proxy routes, and environment checks.\n\nRun without arguments to enter interactive mode."
)]
pub struct Cli {
+ /// Show version information
+ #[arg(short = 'V', long = "version", global = true)]
+ pub version: bool,
+
/// Specify the application type
#[arg(short, long, global = true, value_enum)]
pub app: Option,
@@ -60,6 +64,10 @@ pub enum Commands {
#[command(subcommand)]
Proxy(commands::proxy::ProxyCommand),
+ /// Manage automatic failover and provider queue
+ #[command(subcommand)]
+ Failover(commands::failover::FailoverCommand),
+
/// Start an app with a provider selector without switching the global current provider
#[cfg(unix)]
#[command(subcommand)]
@@ -96,12 +104,57 @@ pub(crate) fn generate_completions_to(shell: Shell, writer: &mut W) {
clap_complete::generate(shell, &mut cmd, name, writer);
}
+fn non_empty(value: Option<&str>) -> Option<&str> {
+ value.map(str::trim).filter(|value| !value.is_empty())
+}
+
+fn format_version_string(
+ name: &str,
+ version: &str,
+ git_hash: Option<&str>,
+ build_time: Option<&str>,
+ is_clean_commit: bool,
+) -> String {
+ let mut metadata = Vec::new();
+
+ if let Some(git_hash) = non_empty(git_hash) {
+ if is_clean_commit {
+ metadata.push(git_hash.to_string());
+ } else {
+ metadata.push(format!("{git_hash}-dirty"));
+ }
+ }
+
+ if let Some(build_time) = non_empty(build_time) {
+ metadata.push(build_time.to_string());
+ }
+
+ if metadata.is_empty() {
+ format!("{name} {version}")
+ } else {
+ format!("{name} {version} ({})", metadata.join(" "))
+ }
+}
+
+pub fn version_string() -> String {
+ let version = non_empty(option_env!("CC_SWITCH_TUI_GIT_TAG_VERSION"))
+ .unwrap_or(env!("CARGO_PKG_VERSION"));
+
+ format_version_string(
+ env!("CARGO_PKG_NAME"),
+ version,
+ option_env!("CC_SWITCH_TUI_BUILD_GIT_HASH"),
+ option_env!("CC_SWITCH_TUI_BUILD_TIME"),
+ option_env!("CC_SWITCH_TUI_GIT_IS_CLEAN_COMMIT").is_some(),
+ )
+}
+
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser};
use std::ffi::OsString;
- use super::{Cli, Commands};
+ use super::{format_version_string, Cli, Commands};
use crate::cli::commands::completions::{
CompletionLifecycleCommand, CompletionsAction, ManagedShellSelection,
};
@@ -114,6 +167,43 @@ mod tests {
assert!(help.contains("prompts, local proxy routes, and environment checks"));
}
+ #[test]
+ fn parses_manual_version_flags_without_stealing_verbose() {
+ let long = Cli::parse_from(["cc-switch-tui", "--version"]);
+ let short = Cli::parse_from(["cc-switch-tui", "-V"]);
+ let subcommand = Cli::parse_from(["cc-switch-tui", "provider", "list", "--version"]);
+ let verbose = Cli::parse_from(["cc-switch-tui", "-v"]);
+
+ assert!(long.version);
+ assert!(short.version);
+ assert!(subcommand.version);
+ assert!(!verbose.version);
+ assert!(verbose.verbose);
+ }
+
+ #[test]
+ fn version_string_includes_dirty_suffix_for_unclean_builds() {
+ let version = format_version_string(
+ "cc-switch-tui",
+ "0.1.1",
+ Some("abc1234"),
+ Some("2026-05-11 12:34:56 +08:00"),
+ false,
+ );
+
+ assert_eq!(
+ version,
+ "cc-switch-tui 0.1.1 (abc1234-dirty 2026-05-11 12:34:56 +08:00)"
+ );
+ }
+
+ #[test]
+ fn version_string_omits_metadata_when_build_env_is_missing() {
+ let version = format_version_string("cc-switch-tui", "0.1.1", None, None, true);
+
+ assert_eq!(version, "cc-switch-tui 0.1.1");
+ }
+
#[test]
fn skills_help_uses_current_storage_description() {
let mut cmd = Cli::command();
@@ -128,7 +218,7 @@ mod tests {
#[test]
fn parses_proxy_serve_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "proxy", "serve", "--listen-port", "0"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "proxy", "serve", "--listen-port", "0"]);
match cli.command {
Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Serve {
@@ -144,7 +234,7 @@ mod tests {
#[test]
fn parses_proxy_serve_takeover_flags() {
let cli = Cli::parse_from([
- "cc-switch",
+ "cc-switch-tui",
"proxy",
"serve",
"--takeover",
@@ -169,7 +259,7 @@ mod tests {
#[test]
fn parses_proxy_enable_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "proxy", "enable"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "proxy", "enable"]);
match cli.command {
Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Enable)) => {}
@@ -179,7 +269,7 @@ mod tests {
#[test]
fn parses_proxy_disable_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "proxy", "disable"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "proxy", "disable"]);
match cli.command {
Some(Commands::Proxy(super::commands::proxy::ProxyCommand::Disable)) => {}
@@ -187,10 +277,106 @@ mod tests {
}
}
+ #[test]
+ fn parses_failover_enable_subcommand() {
+ let cli = Cli::parse_from(["cc-switch", "failover", "enable"]);
+
+ match cli.command {
+ Some(Commands::Failover(super::commands::failover::FailoverCommand::Enable)) => {}
+ _ => panic!("expected failover enable command"),
+ }
+ }
+
+ #[test]
+ fn parses_failover_disable_subcommand() {
+ let cli = Cli::parse_from(["cc-switch", "failover", "disable"]);
+
+ match cli.command {
+ Some(Commands::Failover(super::commands::failover::FailoverCommand::Disable)) => {}
+ _ => panic!("expected failover disable command"),
+ }
+ }
+
+ #[test]
+ fn parses_failover_list_subcommand() {
+ let cli = Cli::parse_from(["cc-switch", "failover", "list"]);
+
+ match cli.command {
+ Some(Commands::Failover(super::commands::failover::FailoverCommand::List)) => {}
+ _ => panic!("expected failover list command"),
+ }
+ }
+
+ #[test]
+ fn parses_failover_add_subcommand() {
+ let cli = Cli::parse_from(["cc-switch", "failover", "add", "p1"]);
+
+ match cli.command {
+ Some(Commands::Failover(super::commands::failover::FailoverCommand::Add { id })) => {
+ assert_eq!(id, "p1");
+ }
+ _ => panic!("expected failover add command"),
+ }
+ }
+
+ #[test]
+ fn parses_failover_remove_subcommand() {
+ let cli = Cli::parse_from(["cc-switch", "failover", "remove", "p1"]);
+
+ match cli.command {
+ Some(Commands::Failover(super::commands::failover::FailoverCommand::Remove { id })) => {
+ assert_eq!(id, "p1");
+ }
+ _ => panic!("expected failover remove command"),
+ }
+ }
+
+ #[test]
+ fn parses_failover_move_subcommand() {
+ let cli = Cli::parse_from(["cc-switch", "failover", "move", "p1", "up"]);
+
+ match cli.command {
+ Some(Commands::Failover(super::commands::failover::FailoverCommand::Move {
+ id,
+ direction,
+ })) => {
+ assert_eq!(id, "p1");
+ assert_eq!(
+ direction,
+ super::commands::failover::FailoverMoveDirection::Up
+ );
+ }
+ _ => panic!("expected failover move command"),
+ }
+ }
+
+ #[test]
+ fn parses_failover_clear_subcommand() {
+ let cli = Cli::parse_from(["cc-switch", "failover", "clear", "--yes"]);
+
+ match cli.command {
+ Some(Commands::Failover(super::commands::failover::FailoverCommand::Clear { yes })) => {
+ assert!(yes);
+ }
+ _ => panic!("expected failover clear command"),
+ }
+ }
+
+ #[test]
+ fn parses_failover_show_with_app() {
+ let cli = Cli::parse_from(["cc-switch", "--app", "codex", "failover", "show"]);
+
+ assert_eq!(cli.app, Some(super::AppType::Codex));
+ match cli.command {
+ Some(Commands::Failover(super::commands::failover::FailoverCommand::Show)) => {}
+ _ => panic!("expected failover show command"),
+ }
+ }
+
#[cfg(unix)]
#[test]
fn parses_start_claude_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "start", "claude", "demo"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "start", "claude", "demo"]);
match cli.command {
Some(Commands::Start(super::commands::start::StartCommand::Claude {
@@ -208,7 +394,7 @@ mod tests {
#[test]
fn parses_start_claude_native_args_after_double_dash() {
let cli = Cli::parse_from([
- "cc-switch",
+ "cc-switch-tui",
"start",
"claude",
"demo",
@@ -235,7 +421,7 @@ mod tests {
#[test]
fn rejects_start_claude_native_args_without_double_dash() {
let result = Cli::try_parse_from([
- "cc-switch",
+ "cc-switch-tui",
"start",
"claude",
"demo",
@@ -252,7 +438,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn parses_start_codex_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "start", "codex", "demo"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "start", "codex", "demo"]);
match cli.command {
Some(Commands::Start(super::commands::start::StartCommand::Codex {
@@ -270,7 +456,7 @@ mod tests {
#[test]
fn parses_start_codex_multiple_native_args_after_double_dash() {
let cli = Cli::parse_from([
- "cc-switch",
+ "cc-switch-tui",
"start",
"codex",
"demo",
@@ -335,7 +521,7 @@ mod tests {
#[test]
fn parses_provider_stream_check_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "provider", "stream-check", "demo"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "provider", "stream-check", "demo"]);
match cli.command {
Some(Commands::Provider(super::commands::provider::ProviderCommand::StreamCheck {
@@ -349,7 +535,7 @@ mod tests {
#[test]
fn parses_provider_fetch_models_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "provider", "fetch-models", "demo"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "provider", "fetch-models", "demo"]);
match cli.command {
Some(Commands::Provider(super::commands::provider::ProviderCommand::FetchModels {
@@ -363,7 +549,7 @@ mod tests {
#[test]
fn parses_provider_export_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "provider", "export", "demo"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "provider", "export", "demo"]);
match cli.command {
Some(Commands::Provider(super::commands::provider::ProviderCommand::Export {
@@ -380,7 +566,7 @@ mod tests {
#[test]
fn parses_provider_export_with_output_subcommand() {
let cli = Cli::parse_from([
- "cc-switch",
+ "cc-switch-tui",
"provider",
"export",
"demo",
@@ -405,7 +591,7 @@ mod tests {
#[test]
fn parses_config_webdav_show_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "config", "webdav", "show"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "config", "webdav", "show"]);
match cli.command {
Some(Commands::Config(super::commands::config::ConfigCommand::WebDav(
@@ -418,7 +604,7 @@ mod tests {
#[test]
fn parses_config_webdav_set_subcommand() {
let cli = Cli::parse_from([
- "cc-switch",
+ "cc-switch-tui",
"config",
"webdav",
"set",
@@ -452,7 +638,7 @@ mod tests {
#[test]
fn parses_config_webdav_check_connection_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "config", "webdav", "check-connection"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "config", "webdav", "check-connection"]);
match cli.command {
Some(Commands::Config(super::commands::config::ConfigCommand::WebDav(
@@ -492,7 +678,7 @@ mod tests {
#[test]
fn parses_config_common_set_legacy_json_alias() {
- let cli = Cli::parse_from(["cc-switch", "config", "common", "set", "--json", "{}"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "config", "common", "set", "--json", "{}"]);
match cli.command {
Some(Commands::Config(super::commands::config::ConfigCommand::Common(_))) => {}
@@ -522,7 +708,7 @@ mod tests {
#[test]
fn parses_env_tools_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "env", "tools"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "env", "tools"]);
match cli.command {
Some(Commands::Env(super::commands::env::EnvCommand::Tools)) => {}
@@ -532,7 +718,7 @@ mod tests {
#[test]
fn parses_skills_repo_enable_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "skills", "repos", "enable", "foo/bar"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "skills", "repos", "enable", "foo/bar"]);
match cli.command {
Some(Commands::Skills(super::commands::skills::SkillsCommand::Repos(
@@ -546,7 +732,7 @@ mod tests {
#[test]
fn parses_skills_repo_disable_subcommand() {
- let cli = Cli::parse_from(["cc-switch", "skills", "repos", "disable", "foo/bar"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "skills", "repos", "disable", "foo/bar"]);
match cli.command {
Some(Commands::Skills(super::commands::skills::SkillsCommand::Repos(
@@ -560,7 +746,7 @@ mod tests {
#[test]
fn parses_completions_bash_generator_path() {
- let cli = Cli::parse_from(["cc-switch", "completions", "bash"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "completions", "bash"]);
match cli.command {
Some(Commands::Completions(command)) => {
@@ -573,7 +759,7 @@ mod tests {
#[test]
fn parses_completions_zsh_generator_path() {
- let cli = Cli::parse_from(["cc-switch", "completions", "zsh"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "completions", "zsh"]);
match cli.command {
Some(Commands::Completions(command)) => {
@@ -586,7 +772,7 @@ mod tests {
#[test]
fn parses_completions_install() {
- let cli = Cli::parse_from(["cc-switch", "completions", "install"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "completions", "install"]);
match cli.command {
Some(Commands::Completions(command)) => match command.action {
@@ -603,7 +789,7 @@ mod tests {
#[test]
fn parses_completions_install_with_shell_and_activate() {
let cli = Cli::parse_from([
- "cc-switch",
+ "cc-switch-tui",
"completions",
"install",
"--shell",
@@ -625,7 +811,7 @@ mod tests {
#[test]
fn parses_completions_status() {
- let cli = Cli::parse_from(["cc-switch", "completions", "status"]);
+ let cli = Cli::parse_from(["cc-switch-tui", "completions", "status"]);
match cli.command {
Some(Commands::Completions(command)) => match command.action {
@@ -640,7 +826,13 @@ mod tests {
#[test]
fn parses_completions_uninstall_with_explicit_shell() {
- let cli = Cli::parse_from(["cc-switch", "completions", "uninstall", "--shell", "bash"]);
+ let cli = Cli::parse_from([
+ "cc-switch-tui",
+ "completions",
+ "uninstall",
+ "--shell",
+ "bash",
+ ]);
match cli.command {
Some(Commands::Completions(command)) => match command.action {
@@ -655,7 +847,8 @@ mod tests {
#[test]
fn rejects_completions_generator_with_activate_flag() {
- let err = match Cli::try_parse_from(["cc-switch", "completions", "bash", "--activate"]) {
+ let err = match Cli::try_parse_from(["cc-switch-tui", "completions", "bash", "--activate"])
+ {
Ok(_) => panic!("generator path should reject lifecycle-only flags"),
Err(err) => err,
};
diff --git a/src-tauri/src/cli/tui/app.rs b/src-tauri/src/cli/tui/app.rs
index d648332c..5ffa1eef 100644
--- a/src-tauri/src/cli/tui/app.rs
+++ b/src-tauri/src/cli/tui/app.rs
@@ -7,7 +7,6 @@ use crate::app_config::AppType;
use crate::cli::i18n::current_language;
use crate::cli::i18n::texts;
use crate::cli::i18n::Language;
-use crate::services::skill::SyncMethod;
use super::data::UiData;
use super::form::{
@@ -15,6 +14,7 @@ use super::form::{
McpTransport, ProviderAddField, ProviderAddFormState,
};
use super::route::{NavItem, Route};
+use super::text_edit::{TextEditCommand, TextInput, TextInputPolicy};
use super::{data, form};
mod app_state;
diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs
index ba4dc483..99252f42 100644
--- a/src-tauri/src/cli/tui/app/app_state.rs
+++ b/src-tauri/src/cli/tui/app/app_state.rs
@@ -13,22 +13,30 @@ pub enum Action {
directory: String,
enabled: bool,
},
+ SkillsToggleMany {
+ directories: Vec,
+ enabled: bool,
+ },
SkillsSetApps {
directory: String,
apps: crate::app_config::SkillApps,
},
+ SkillsSetAppsMany {
+ directories: Vec,
+ apps: crate::app_config::SkillApps,
+ },
SkillsInstall {
spec: String,
},
SkillsUninstall {
directory: String,
},
+ SkillsUninstallMany {
+ directories: Vec,
+ },
SkillsSync {
app: Option,
},
- SkillsSetSyncMethod {
- method: SyncMethod,
- },
SkillsDiscover {
query: String,
},
@@ -45,14 +53,20 @@ pub enum Action {
enabled: bool,
},
SkillsOpenImport,
- SkillsScanUnmanaged,
+ SkillsOpenAgentImport,
SkillsImportFromApps {
directories: Vec,
},
+ SkillsImportFromAgent {
+ directories: Vec,
+ },
ProviderSwitch {
id: String,
},
+ CodexAcceptLiveCurrent {
+ id: String,
+ },
ProviderRemoveFromConfig {
id: String,
},
@@ -103,6 +117,14 @@ pub enum Action {
id: String,
},
McpImport,
+ McpImportLive {
+ app_type: AppType,
+ id: String,
+ },
+ McpPushDbToLive {
+ app_type: AppType,
+ id: String,
+ },
PromptActivate {
id: String,
@@ -169,7 +191,6 @@ pub enum Action {
submit: EditorSubmit,
content: String,
},
- EditorDiscard,
EditorOpenExternal,
SetSkipClaudeOnboarding {
@@ -178,9 +199,6 @@ pub enum Action {
SetClaudePluginIntegration {
enabled: bool,
},
- SetProxyEnabled {
- enabled: bool,
- },
SetProxyListenAddress {
address: String,
},
@@ -194,10 +212,7 @@ pub enum Action {
SetOpenClawConfigDir {
path: Option,
},
- SetProxyTakeover {
- app_type: AppType,
- enabled: bool,
- },
+ SetSkillSyncMethod(crate::services::skill::SyncMethod),
SetManagedProxyForCurrentApp {
app_type: AppType,
enabled: bool,
@@ -223,6 +238,7 @@ pub enum ConfigItem {
Restore,
Validate,
CommonSnippet,
+ #[allow(dead_code)]
Proxy,
OpenClawWorkspace,
OpenClawEnv,
@@ -358,6 +374,7 @@ pub enum SettingsItem {
Language,
VisibleApps,
OpenClawConfigDir,
+ SkillSyncMethod,
SkipClaudeOnboarding,
ClaudePluginIntegration,
Proxy,
@@ -365,10 +382,11 @@ pub enum SettingsItem {
}
impl SettingsItem {
- pub const ALL: [SettingsItem; 7] = [
+ pub const ALL: [SettingsItem; 8] = [
SettingsItem::Language,
SettingsItem::VisibleApps,
SettingsItem::OpenClawConfigDir,
+ SettingsItem::SkillSyncMethod,
SettingsItem::SkipClaudeOnboarding,
SettingsItem::ClaudePluginIntegration,
SettingsItem::Proxy,
@@ -473,6 +491,8 @@ pub struct App {
pub mcp_idx: usize,
pub prompt_idx: usize,
pub skills_idx: usize,
+ pub skills_visual_anchor: Option,
+ pub skills_pending_g: bool,
pub skills_discover_idx: usize,
pub skills_repo_idx: usize,
pub skills_unmanaged_idx: usize,
@@ -490,7 +510,6 @@ pub struct App {
Vec,
pub config_webdav_idx: usize,
pub webdav_quick_setup_username: Option,
- pub language_idx: usize,
pub settings_idx: usize,
pub settings_proxy_idx: usize,
}
diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs
index 338ab22a..ddca94f8 100644
--- a/src-tauri/src/cli/tui/app/content_config.rs
+++ b/src-tauri/src/cli/tui/app/content_config.rs
@@ -19,7 +19,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_openclaw_daily_memory_create_title().to_string(),
prompt: texts::tui_openclaw_daily_memory_create_prompt().to_string(),
- buffer: initial,
+ input: TextInput::new(initial),
submit: TextSubmit::OpenClawDailyMemoryFilename,
secret: false,
});
@@ -79,7 +79,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_config_export_title().to_string(),
prompt: texts::tui_config_export_prompt().to_string(),
- buffer: texts::tui_default_config_export_path().to_string(),
+ input: TextInput::new(texts::tui_default_config_export_path()),
submit: TextSubmit::ConfigExport,
secret: false,
});
@@ -89,7 +89,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_config_import_title().to_string(),
prompt: texts::tui_config_import_prompt().to_string(),
- buffer: texts::tui_default_config_export_path().to_string(),
+ input: TextInput::new(texts::tui_default_config_export_path()),
submit: TextSubmit::ConfigImport,
secret: false,
});
@@ -99,7 +99,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_config_backup_title().to_string(),
prompt: texts::tui_config_backup_prompt().to_string(),
- buffer: String::new(),
+ input: TextInput::new(""),
submit: TextSubmit::ConfigBackupName,
secret: false,
});
@@ -312,7 +312,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: title.to_string(),
prompt: texts::tui_openclaw_tools_pattern_placeholder().to_string(),
- buffer: initial,
+ input: TextInput::new(initial),
submit: TextSubmit::OpenClawToolsRule { section, row },
secret: false,
});
@@ -482,7 +482,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: title.to_string(),
prompt: title.to_string(),
- buffer,
+ input: TextInput::new(buffer),
submit: TextSubmit::OpenClawAgentsRuntimeField { field },
secret: false,
});
@@ -664,7 +664,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_webdav_jianguoyun_setup_title().to_string(),
prompt: texts::tui_webdav_jianguoyun_username_prompt().to_string(),
- buffer: String::new(),
+ input: TextInput::new(""),
submit: TextSubmit::WebDavJianguoyunUsername,
secret: false,
});
@@ -709,12 +709,27 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_settings_openclaw_config_dir_label().to_string(),
prompt: texts::tui_settings_openclaw_config_dir_prompt().to_string(),
- buffer,
+ input: TextInput::new(buffer),
submit: TextSubmit::SettingsOpenClawConfigDir,
secret: false,
});
Action::None
}
+ Some(SettingsItem::SkillSyncMethod) => {
+ let current = crate::settings::get_skill_sync_method();
+ let next = match current {
+ crate::services::skill::SyncMethod::Auto => {
+ crate::services::skill::SyncMethod::Copy
+ }
+ crate::services::skill::SyncMethod::Copy => {
+ crate::services::skill::SyncMethod::Symlink
+ }
+ crate::services::skill::SyncMethod::Symlink => {
+ crate::services::skill::SyncMethod::Auto
+ }
+ };
+ Action::SetSkillSyncMethod(next)
+ }
Some(SettingsItem::SkipClaudeOnboarding) => {
let current = crate::settings::get_skip_claude_onboarding();
let next = !current;
@@ -788,7 +803,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_settings_proxy_title().to_string(),
prompt: texts::tui_settings_proxy_listen_address_prompt().to_string(),
- buffer: data.proxy.configured_listen_address.clone(),
+ input: TextInput::new(data.proxy.configured_listen_address.clone()),
submit: TextSubmit::SettingsProxyListenAddress,
secret: false,
});
@@ -805,7 +820,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_settings_proxy_title().to_string(),
prompt: texts::tui_settings_proxy_listen_port_prompt().to_string(),
- buffer: data.proxy.configured_listen_port.to_string(),
+ input: TextInput::new(data.proxy.configured_listen_port.to_string()),
submit: TextSubmit::SettingsProxyListenPort,
secret: false,
});
@@ -887,14 +902,8 @@ impl App {
None => crate::t!("not supported", "不支持"),
};
let toggle_action = match current_app_routed {
- Some(true) if proxy_action_available => Some(TextViewAction::ProxyToggleTakeover {
- app_type: self.app_type.clone(),
- enabled: false,
- }),
- Some(false) if proxy_action_available => Some(TextViewAction::ProxyToggleTakeover {
- app_type: self.app_type.clone(),
- enabled: true,
- }),
+ Some(true) if proxy_action_available => Some(TextViewAction::ProxyToggleTakeover),
+ Some(false) if proxy_action_available => Some(TextViewAction::ProxyToggleTakeover),
_ => None,
};
@@ -1101,7 +1110,10 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_prompt_create_title().to_string(),
prompt: texts::tui_prompt_create_prompt().to_string(),
- buffer: format!("Prompt {}", chrono::Local::now().format("%Y-%m-%d %H:%M")),
+ input: TextInput::new(format!(
+ "Prompt {}",
+ chrono::Local::now().format("%Y-%m-%d %H:%M")
+ )),
submit: TextSubmit::PromptCreateName,
secret: false,
});
diff --git a/src-tauri/src/cli/tui/app/content_entities.rs b/src-tauri/src/cli/tui/app/content_entities.rs
index 73b08c8f..a19ec9a9 100644
--- a/src-tauri/src/cli/tui/app/content_entities.rs
+++ b/src-tauri/src/cli/tui/app/content_entities.rs
@@ -1,8 +1,37 @@
use super::*;
impl App {
+ fn openclaw_set_default_model_action(&mut self, row: &super::data::ProviderRow) -> Action {
+ if !row.is_in_config {
+ self.push_toast(
+ texts::tui_toast_provider_default_requires_live_config(),
+ ToastKind::Warning,
+ );
+ return Action::None;
+ }
+
+ let Some(model_id) = row.primary_model_id.clone() else {
+ self.push_toast(
+ texts::tui_toast_provider_default_model_missing(),
+ ToastKind::Warning,
+ );
+ return Action::None;
+ };
+
+ Action::ProviderSetDefaultModel {
+ provider_id: row.id.clone(),
+ model_id,
+ }
+ }
+
fn provider_switch_action(&mut self, row: &super::data::ProviderRow, data: &UiData) -> Action {
- if supports_failover_controls(&self.app_type) && data.proxy.auto_failover_enabled {
+ let proxy_failover_active = supports_failover_controls(&self.app_type)
+ && data.proxy.auto_failover_enabled
+ && data
+ .proxy
+ .routes_current_app_through_proxy(&self.app_type)
+ .unwrap_or(false);
+ if proxy_failover_active {
self.push_toast(
crate::t!(
"Manage provider priority in the failover queue while automatic failover is enabled.",
@@ -41,6 +70,36 @@ impl App {
Action::ProviderSwitch { id: row.id.clone() }
}
+ pub(crate) fn provider_speedtest_action(&mut self, row: &super::data::ProviderRow) -> Action {
+ let Some(url) = row.api_url.clone() else {
+ self.push_toast(texts::tui_toast_provider_no_api_url(), ToastKind::Warning);
+ return Action::None;
+ };
+ self.overlay = Overlay::SpeedtestRunning { url: url.clone() };
+ Action::ProviderSpeedtest { url }
+ }
+
+ pub(crate) fn provider_stream_check_action(
+ &mut self,
+ row: &super::data::ProviderRow,
+ ) -> Action {
+ if !supports_provider_stream_check(&self.app_type) {
+ return Action::None;
+ }
+ self.overlay = Overlay::StreamCheckRunning {
+ provider_id: row.id.clone(),
+ provider_name: super::data::provider_display_name(&self.app_type, row),
+ };
+ Action::ProviderStreamCheck { id: row.id.clone() }
+ }
+
+ pub(crate) fn open_provider_test_menu(&mut self, row: &super::data::ProviderRow) {
+ self.overlay = Overlay::ProviderTestMenu {
+ provider_id: row.id.clone(),
+ selected: 0,
+ };
+ }
+
pub(crate) fn on_providers_key(&mut self, key: KeyEvent, data: &UiData) -> Action {
let visible = visible_providers(&self.app_type, &self.filter, data);
match key.code {
@@ -55,6 +114,9 @@ impl App {
Action::None
}
KeyCode::Enter => {
+ if data.providers.rows.is_empty() {
+ return Action::ProviderImportLiveConfig;
+ }
let Some(row) = visible.get(self.provider_idx) else {
return Action::None;
};
@@ -64,12 +126,8 @@ impl App {
self.open_provider_add_form();
Action::None
}
- KeyCode::Char('i') => {
- if data.providers.rows.is_empty() {
- Action::ProviderImportLiveConfig
- } else {
- Action::None
- }
+ KeyCode::Char('i') if matches!(self.app_type, AppType::Codex) => {
+ Action::ProviderImportLiveConfig
}
KeyCode::Char('e') => {
let Some(row) = visible.get(self.provider_idx) else {
@@ -78,37 +136,29 @@ impl App {
self.open_provider_edit_form(row);
Action::None
}
- KeyCode::Char('s') | KeyCode::Char(' ') => {
+ KeyCode::Char('s') => {
let Some(row) = visible.get(self.provider_idx) else {
return Action::None;
};
self.provider_switch_action(row, data)
}
- KeyCode::Char('x') => {
+ KeyCode::Char(' ') => {
let Some(row) = visible.get(self.provider_idx) else {
return Action::None;
};
- if !matches!(self.app_type, AppType::OpenClaw) {
- return Action::None;
+ if matches!(self.app_type, AppType::OpenClaw) && row.is_in_config {
+ return self.openclaw_set_default_model_action(row);
}
- if !row.is_in_config {
- self.push_toast(
- texts::tui_toast_provider_default_requires_live_config(),
- ToastKind::Warning,
- );
- return Action::None;
- }
- let Some(model_id) = row.primary_model_id.clone() else {
- self.push_toast(
- texts::tui_toast_provider_default_model_missing(),
- ToastKind::Warning,
- );
+ self.provider_switch_action(row, data)
+ }
+ KeyCode::Char('x') => {
+ let Some(row) = visible.get(self.provider_idx) else {
return Action::None;
};
- Action::ProviderSetDefaultModel {
- provider_id: row.id.clone(),
- model_id,
+ if !matches!(self.app_type, AppType::OpenClaw) {
+ return Action::None;
}
+ self.openclaw_set_default_model_action(row)
}
KeyCode::Char('d') => {
let Some(row) = visible.get(self.provider_idx) else {
@@ -135,12 +185,8 @@ impl App {
let Some(row) = visible.get(self.provider_idx) else {
return Action::None;
};
- let Some(url) = row.api_url.clone() else {
- self.push_toast(texts::tui_toast_provider_no_api_url(), ToastKind::Warning);
- return Action::None;
- };
- self.overlay = Overlay::SpeedtestRunning { url: url.clone() };
- Action::ProviderSpeedtest { url }
+ self.open_provider_test_menu(row);
+ Action::None
}
KeyCode::Char('o') => {
let Some(row) = visible.get(self.provider_idx) else {
@@ -151,19 +197,6 @@ impl App {
}
Action::ProviderLaunchTemporary { id: row.id.clone() }
}
- KeyCode::Char('c') => {
- if !supports_provider_stream_check(&self.app_type) {
- return Action::None;
- }
- let Some(row) = visible.get(self.provider_idx) else {
- return Action::None;
- };
- self.overlay = Overlay::StreamCheckRunning {
- provider_id: row.id.clone(),
- provider_name: super::data::provider_display_name(&self.app_type, row),
- };
- Action::ProviderStreamCheck { id: row.id.clone() }
- }
KeyCode::Char('f') => {
if !supports_failover_controls(&self.app_type) {
return Action::None;
@@ -210,37 +243,22 @@ impl App {
Action::None
}
KeyCode::Enter => Action::None,
- KeyCode::Char('s') | KeyCode::Char(' ') => self.provider_switch_action(row, data),
+ KeyCode::Char('s') => self.provider_switch_action(row, data),
+ KeyCode::Char(' ') => {
+ if matches!(self.app_type, AppType::OpenClaw) && row.is_in_config {
+ return self.openclaw_set_default_model_action(row);
+ }
+ self.provider_switch_action(row, data)
+ }
KeyCode::Char('x') => {
if !matches!(self.app_type, AppType::OpenClaw) {
return Action::None;
}
- if !row.is_in_config {
- self.push_toast(
- texts::tui_toast_provider_default_requires_live_config(),
- ToastKind::Warning,
- );
- return Action::None;
- }
- let Some(model_id) = row.primary_model_id.clone() else {
- self.push_toast(
- texts::tui_toast_provider_default_model_missing(),
- ToastKind::Warning,
- );
- return Action::None;
- };
- Action::ProviderSetDefaultModel {
- provider_id: row.id.clone(),
- model_id,
- }
+ self.openclaw_set_default_model_action(row)
}
KeyCode::Char('t') => {
- let Some(url) = row.api_url.clone() else {
- self.push_toast(texts::tui_toast_provider_no_api_url(), ToastKind::Warning);
- return Action::None;
- };
- self.overlay = Overlay::SpeedtestRunning { url: url.clone() };
- Action::ProviderSpeedtest { url }
+ self.open_provider_test_menu(row);
+ Action::None
}
KeyCode::Char('o') => {
if !supports_temporary_provider_launch(&self.app_type) {
@@ -248,16 +266,6 @@ impl App {
}
Action::ProviderLaunchTemporary { id: row.id.clone() }
}
- KeyCode::Char('c') => {
- if !supports_provider_stream_check(&self.app_type) {
- return Action::None;
- }
- self.overlay = Overlay::StreamCheckRunning {
- provider_id: row.id.clone(),
- provider_name: super::data::provider_display_name(&self.app_type, row),
- };
- Action::ProviderStreamCheck { id: row.id.clone() }
- }
KeyCode::Char('f') => {
if !supports_failover_controls(&self.app_type) {
return Action::None;
@@ -302,6 +310,9 @@ impl App {
let Some(row) = visible.get(self.mcp_idx) else {
return Action::None;
};
+ let Some(row) = row.db_row() else {
+ return Action::None;
+ };
self.open_mcp_edit_form(row);
Action::None
}
@@ -309,6 +320,9 @@ impl App {
let Some(row) = visible.get(self.mcp_idx) else {
return Action::None;
};
+ let Some(row) = row.db_row() else {
+ return Action::None;
+ };
let enabled = row.server.apps.is_enabled_for(&self.app_type);
Action::McpToggle {
id: row.id.clone(),
@@ -319,6 +333,9 @@ impl App {
let Some(row) = visible.get(self.mcp_idx) else {
return Action::None;
};
+ let Some(row) = row.db_row() else {
+ return Action::None;
+ };
self.overlay = Overlay::McpAppsPicker {
id: row.id.clone(),
name: row.server.name.clone(),
@@ -328,10 +345,36 @@ impl App {
Action::None
}
KeyCode::Char('i') => Action::McpImport,
+ KeyCode::Char('r') => {
+ let Some(row) = visible.get(self.mcp_idx) else {
+ return Action::None;
+ };
+ let Some(kind) = row.drift_kind(&data.mcp).cloned() else {
+ return Action::None;
+ };
+ if !matches!(
+ kind,
+ crate::services::McpLiveDriftKind::Changed
+ | crate::services::McpLiveDriftKind::DbOnly
+ | crate::services::McpLiveDriftKind::LiveOnly
+ ) {
+ return Action::None;
+ }
+ self.overlay = Overlay::McpLiveDriftResolve {
+ app_type: self.app_type.clone(),
+ id: row.id().to_string(),
+ kind,
+ selected: 0,
+ };
+ Action::None
+ }
KeyCode::Char('d') => {
let Some(row) = visible.get(self.mcp_idx) else {
return Action::None;
};
+ let Some(row) = row.db_row() else {
+ return Action::None;
+ };
self.overlay = Overlay::Confirm(ConfirmOverlay {
title: texts::tui_confirm_delete_mcp_title().to_string(),
message: texts::tui_confirm_delete_mcp_message(&row.server.name, &row.id),
@@ -429,7 +472,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_prompt_rename_title().to_string(),
prompt: texts::tui_prompt_rename_prompt().to_string(),
- buffer: row.prompt.name.clone(),
+ input: TextInput::new(row.prompt.name.clone()),
submit: TextSubmit::PromptRename { id: row.id.clone() },
secret: false,
});
diff --git a/src-tauri/src/cli/tui/app/content_skills.rs b/src-tauri/src/cli/tui/app/content_skills.rs
index 80570545..e88eaf01 100644
--- a/src-tauri/src/cli/tui/app/content_skills.rs
+++ b/src-tauri/src/cli/tui/app/content_skills.rs
@@ -1,6 +1,39 @@
use super::*;
impl App {
+ pub(crate) fn skills_visual_range(&self, len: usize) -> Option<(usize, usize)> {
+ let anchor = self.skills_visual_anchor?;
+ if len == 0 {
+ return None;
+ }
+ let current = self.skills_idx.min(len - 1);
+ let anchor = anchor.min(len - 1);
+ Some((anchor.min(current), anchor.max(current)))
+ }
+
+ fn selected_installed_skill_indices(&self, len: usize) -> Vec {
+ if len == 0 {
+ return Vec::new();
+ }
+
+ if let Some((start, end)) = self.skills_visual_range(len) {
+ return (start..=end).collect();
+ }
+
+ vec![self.skills_idx.min(len - 1)]
+ }
+
+ fn selected_installed_skill_directories(
+ &self,
+ visible: &[&crate::services::skill::InstalledSkill],
+ ) -> Vec {
+ self.selected_installed_skill_indices(visible.len())
+ .into_iter()
+ .filter_map(|idx| visible.get(idx))
+ .map(|skill| skill.directory.clone())
+ .collect()
+ }
+
pub(crate) fn main_proxy_action(&self, data: &UiData) -> Action {
let Some(current_app_routed) = data.proxy.routes_current_app_through_proxy(&self.app_type)
else {
@@ -22,16 +55,46 @@ impl App {
match key.code {
KeyCode::Up => {
+ self.skills_pending_g = false;
self.skills_idx = self.skills_idx.saturating_sub(1);
Action::None
}
KeyCode::Down => {
+ self.skills_pending_g = false;
if !visible.is_empty() {
self.skills_idx = (self.skills_idx + 1).min(visible.len() - 1);
}
Action::None
}
+ KeyCode::Char('g') => {
+ if self.skills_pending_g {
+ self.skills_idx = 0;
+ self.skills_pending_g = false;
+ } else {
+ self.skills_pending_g = true;
+ }
+ Action::None
+ }
+ KeyCode::Char('G') => {
+ self.skills_pending_g = false;
+ if !visible.is_empty() {
+ self.skills_idx = visible.len() - 1;
+ }
+ Action::None
+ }
+ KeyCode::Char('v') => {
+ self.skills_pending_g = false;
+ if visible.is_empty() {
+ self.skills_visual_anchor = None;
+ } else if self.skills_visual_anchor.is_some() {
+ self.skills_visual_anchor = None;
+ } else {
+ self.skills_visual_anchor = Some(self.skills_idx.min(visible.len() - 1));
+ }
+ Action::None
+ }
KeyCode::Enter => {
+ self.skills_pending_g = false;
let Some(skill) = visible.get(self.skills_idx) else {
return Action::None;
};
@@ -40,46 +103,84 @@ impl App {
})
}
KeyCode::Char('x') | KeyCode::Char(' ') => {
+ self.skills_pending_g = false;
let Some(skill) = visible.get(self.skills_idx) else {
return Action::None;
};
let enabled = !skill.apps.is_enabled_for(&self.app_type);
- Action::SkillsToggle {
- directory: skill.directory.clone(),
- enabled,
+ let directories = self.selected_installed_skill_directories(&visible);
+ self.skills_visual_anchor = None;
+ if directories.len() > 1 {
+ Action::SkillsToggleMany {
+ directories,
+ enabled,
+ }
+ } else {
+ Action::SkillsToggle {
+ directory: skill.directory.clone(),
+ enabled,
+ }
}
}
KeyCode::Char('m') => {
+ self.skills_pending_g = false;
let Some(skill) = visible.get(self.skills_idx) else {
return Action::None;
};
+ let directories = self.selected_installed_skill_directories(&visible);
+ let name = if directories.len() > 1 {
+ texts::tui_skills_batch_selection_name(directories.len())
+ } else {
+ skill.name.clone()
+ };
self.overlay = Overlay::SkillsAppsPicker {
directory: skill.directory.clone(),
- name: skill.name.clone(),
+ directories,
+ name,
selected: four_app_picker_index(&self.app_type),
apps: skill.apps.clone(),
};
Action::None
}
KeyCode::Char('d') => {
+ self.skills_pending_g = false;
let Some(skill) = visible.get(self.skills_idx) else {
return Action::None;
};
+ let directories = self.selected_installed_skill_directories(&visible);
self.overlay = Overlay::Confirm(ConfirmOverlay {
title: texts::tui_skills_uninstall_title().to_string(),
- message: texts::tui_confirm_uninstall_skill_message(
- &skill.name,
- &skill.directory,
- ),
- action: ConfirmAction::SkillsUninstall {
- directory: skill.directory.clone(),
+ message: if directories.len() > 1 {
+ texts::tui_confirm_uninstall_skills_message(directories.len())
+ } else {
+ texts::tui_confirm_uninstall_skill_message(&skill.name, &skill.directory)
+ },
+ action: if directories.len() > 1 {
+ ConfirmAction::SkillsUninstallMany { directories }
+ } else {
+ ConfirmAction::SkillsUninstall {
+ directory: skill.directory.clone(),
+ }
},
});
Action::None
}
- KeyCode::Char('i') => Action::SkillsOpenImport,
- KeyCode::Char('f') => self.push_route_and_switch(Route::SkillsDiscover),
- _ => Action::None,
+ KeyCode::Char('i') => {
+ self.skills_pending_g = false;
+ Action::SkillsOpenImport
+ }
+ KeyCode::Char('s') => {
+ self.skills_pending_g = false;
+ Action::SkillsOpenAgentImport
+ }
+ KeyCode::Char('f') => {
+ self.skills_pending_g = false;
+ self.push_route_and_switch(Route::SkillsDiscover)
+ }
+ _ => {
+ self.skills_pending_g = false;
+ Action::None
+ }
}
}
@@ -101,7 +202,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_skills_discover_title().to_string(),
prompt: texts::tui_skills_discover_prompt().to_string(),
- buffer: self.skills_discover_query.clone(),
+ input: TextInput::new(self.skills_discover_query.clone()),
submit: TextSubmit::SkillsDiscoverQuery,
secret: false,
});
@@ -142,7 +243,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_skills_repos_add_title().to_string(),
prompt: texts::tui_skills_repos_add_prompt().to_string(),
- buffer: String::new(),
+ input: TextInput::new(""),
submit: TextSubmit::SkillsRepoAdd,
secret: false,
});
@@ -199,6 +300,7 @@ impl App {
KeyCode::Char('m') => {
self.overlay = Overlay::SkillsAppsPicker {
directory: skill.directory.clone(),
+ directories: vec![skill.directory.clone()],
name: skill.name.clone(),
selected: four_app_picker_index(&self.app_type),
apps: skill.apps.clone(),
diff --git a/src-tauri/src/cli/tui/app/editor_handlers.rs b/src-tauri/src/cli/tui/app/editor_handlers.rs
index 6ef43370..1f040476 100644
--- a/src-tauri/src/cli/tui/app/editor_handlers.rs
+++ b/src-tauri/src/cli/tui/app/editor_handlers.rs
@@ -20,6 +20,12 @@ impl App {
return Action::EditorOpenExternal;
}
+ if let Some(command) = TextEditCommand::from_key(key) {
+ editor.apply_text_command(command);
+ editor.ensure_cursor_visible(viewport);
+ return Action::None;
+ }
+
match key.code {
KeyCode::Esc => {
if editor.is_dirty() {
@@ -52,37 +58,6 @@ impl App {
editor.ensure_cursor_visible(viewport);
Action::None
}
- KeyCode::Left => {
- if editor.cursor_col > 0 {
- editor.cursor_col -= 1;
- } else if editor.cursor_row > 0 {
- editor.cursor_row -= 1;
- editor.cursor_col = editor.line_len_chars(editor.cursor_row);
- }
- editor.ensure_cursor_visible(viewport);
- Action::None
- }
- KeyCode::Right => {
- let line_len = editor.line_len_chars(editor.cursor_row);
- if editor.cursor_col < line_len {
- editor.cursor_col += 1;
- } else if editor.cursor_row + 1 < editor.lines.len() {
- editor.cursor_row += 1;
- editor.cursor_col = 0;
- }
- editor.ensure_cursor_visible(viewport);
- Action::None
- }
- KeyCode::Home => {
- editor.cursor_col = 0;
- editor.ensure_cursor_visible(viewport);
- Action::None
- }
- KeyCode::End => {
- editor.cursor_col = editor.line_len_chars(editor.cursor_row);
- editor.ensure_cursor_visible(viewport);
- Action::None
- }
KeyCode::PageUp => {
editor.scroll = editor.scroll.saturating_sub(jump_rows);
editor.cursor_row = editor.cursor_row.saturating_sub(jump_rows);
@@ -103,16 +78,6 @@ impl App {
editor.ensure_cursor_visible(viewport);
Action::None
}
- KeyCode::Backspace => {
- editor.backspace();
- editor.ensure_cursor_visible(viewport);
- Action::None
- }
- KeyCode::Delete => {
- editor.delete();
- editor.ensure_cursor_visible(viewport);
- Action::None
- }
KeyCode::Enter => {
editor.newline();
editor.ensure_cursor_visible(viewport);
@@ -123,13 +88,6 @@ impl App {
editor.ensure_cursor_visible(viewport);
Action::None
}
- KeyCode::Char(c) => {
- if !c.is_control() {
- editor.insert_char(c);
- editor.ensure_cursor_visible(viewport);
- }
- Action::None
- }
_ => Action::None,
}
}
@@ -139,7 +97,7 @@ impl App {
let mut width = self.last_size.width.saturating_sub(30);
let mut height = self.last_size.height.saturating_sub(3).saturating_sub(1);
- if self.filter.active || !self.filter.buffer.trim().is_empty() {
+ if self.filter.active || !self.filter.input.value.trim().is_empty() {
height = height.saturating_sub(5);
}
diff --git a/src-tauri/src/cli/tui/app/editor_state.rs b/src-tauri/src/cli/tui/app/editor_state.rs
index fe401209..71047e88 100644
--- a/src-tauri/src/cli/tui/app/editor_state.rs
+++ b/src-tauri/src/cli/tui/app/editor_state.rs
@@ -228,6 +228,133 @@ impl EditorState {
.unwrap_or(line.len())
}
+ fn apply_current_line_command(&mut self, command: TextEditCommand) -> bool {
+ if self.lines.is_empty() {
+ self.lines.push(String::new());
+ }
+ self.cursor_row = self.cursor_row.min(self.lines.len() - 1);
+
+ let mut input = TextInput {
+ value: self.lines[self.cursor_row].clone(),
+ cursor: self.cursor_col,
+ };
+ let changed = input.apply_command(command, TextInputPolicy::default());
+ self.lines[self.cursor_row] = input.value;
+ self.cursor_col = input.cursor;
+ changed
+ }
+
+ pub(crate) fn apply_text_command(&mut self, command: TextEditCommand) -> bool {
+ match command {
+ TextEditCommand::MoveLeft => self.move_left(),
+ TextEditCommand::MoveRight => self.move_right(),
+ TextEditCommand::MoveLineStart
+ | TextEditCommand::MoveLineEnd
+ | TextEditCommand::DeleteToLineStart
+ | TextEditCommand::DeleteToLineEnd
+ | TextEditCommand::Insert(_) => self.apply_current_line_command(command),
+ TextEditCommand::MoveWordLeft => self.move_word_left(),
+ TextEditCommand::MoveWordRight => self.move_word_right(),
+ TextEditCommand::DeleteBackward => self.backspace(),
+ TextEditCommand::DeleteForward => self.delete(),
+ TextEditCommand::DeleteWordBackward => self.delete_word_backward(),
+ }
+ }
+
+ pub(crate) fn move_left(&mut self) -> bool {
+ if self.lines.is_empty() {
+ self.lines.push(String::new());
+ }
+ self.cursor_row = self.cursor_row.min(self.lines.len() - 1);
+
+ if self.cursor_col > 0 {
+ self.cursor_col -= 1;
+ return true;
+ }
+
+ if self.cursor_row > 0 {
+ self.cursor_row -= 1;
+ self.cursor_col = self.line_len_chars(self.cursor_row);
+ return true;
+ }
+
+ false
+ }
+
+ pub(crate) fn move_right(&mut self) -> bool {
+ if self.lines.is_empty() {
+ self.lines.push(String::new());
+ }
+ self.cursor_row = self.cursor_row.min(self.lines.len() - 1);
+
+ let line_len = self.line_len_chars(self.cursor_row);
+ if self.cursor_col < line_len {
+ self.cursor_col += 1;
+ return true;
+ }
+
+ if self.cursor_row + 1 < self.lines.len() {
+ self.cursor_row += 1;
+ self.cursor_col = 0;
+ return true;
+ }
+
+ false
+ }
+
+ fn move_word_left(&mut self) -> bool {
+ if self.lines.is_empty() {
+ self.lines.push(String::new());
+ }
+ self.cursor_row = self.cursor_row.min(self.lines.len() - 1);
+ let before = (self.cursor_row, self.cursor_col);
+
+ loop {
+ if self.cursor_col > 0 {
+ let line = &self.lines[self.cursor_row];
+ self.cursor_col =
+ super::super::text_edit::previous_word_boundary(line, self.cursor_col);
+ break;
+ }
+
+ if self.cursor_row == 0 {
+ break;
+ }
+
+ self.cursor_row -= 1;
+ self.cursor_col = self.line_len_chars(self.cursor_row);
+ }
+
+ (self.cursor_row, self.cursor_col) != before
+ }
+
+ fn move_word_right(&mut self) -> bool {
+ if self.lines.is_empty() {
+ self.lines.push(String::new());
+ }
+ self.cursor_row = self.cursor_row.min(self.lines.len() - 1);
+ let before = (self.cursor_row, self.cursor_col);
+
+ loop {
+ let line_len = self.line_len_chars(self.cursor_row);
+ if self.cursor_col < line_len {
+ let line = &self.lines[self.cursor_row];
+ self.cursor_col =
+ super::super::text_edit::next_word_boundary(line, self.cursor_col);
+ break;
+ }
+
+ if self.cursor_row + 1 >= self.lines.len() {
+ break;
+ }
+
+ self.cursor_row += 1;
+ self.cursor_col = 0;
+ }
+
+ (self.cursor_row, self.cursor_col) != before
+ }
+
pub(crate) fn insert_char(&mut self, c: char) {
if self.lines.is_empty() {
self.lines.push(String::new());
@@ -259,7 +386,7 @@ impl EditorState {
self.cursor_col = 0;
}
- pub(crate) fn backspace(&mut self) {
+ pub(crate) fn backspace(&mut self) -> bool {
if self.lines.is_empty() {
self.lines.push(String::new());
}
@@ -272,12 +399,13 @@ impl EditorState {
if start < end && end <= line.len() {
line.replace_range(start..end, "");
self.cursor_col -= 1;
+ return true;
}
- return;
+ return false;
}
if self.cursor_row == 0 {
- return;
+ return false;
}
let current = self.lines.remove(self.cursor_row);
@@ -285,9 +413,10 @@ impl EditorState {
let prev = &mut self.lines[self.cursor_row];
self.cursor_col = prev.chars().count();
prev.push_str(¤t);
+ true
}
- pub(crate) fn delete(&mut self) {
+ pub(crate) fn delete(&mut self) -> bool {
if self.lines.is_empty() {
self.lines.push(String::new());
}
@@ -300,15 +429,35 @@ impl EditorState {
let end = Self::byte_index(line, self.cursor_col + 1);
if start < end && end <= line.len() {
line.replace_range(start..end, "");
+ return true;
}
- return;
+ return false;
}
if self.cursor_row + 1 >= self.lines.len() {
- return;
+ return false;
}
let next = self.lines.remove(self.cursor_row + 1);
self.lines[self.cursor_row].push_str(&next);
+ true
+ }
+
+ fn delete_word_backward(&mut self) -> bool {
+ if self.lines.is_empty() {
+ self.lines.push(String::new());
+ }
+ self.cursor_row = self.cursor_row.min(self.lines.len() - 1);
+
+ if self.cursor_col > 0 {
+ return self.apply_current_line_command(TextEditCommand::DeleteWordBackward);
+ }
+
+ if self.cursor_row == 0 {
+ return false;
+ }
+
+ self.backspace();
+ self.apply_current_line_command(TextEditCommand::DeleteWordBackward)
}
}
diff --git a/src-tauri/src/cli/tui/app/form_handlers/mcp.rs b/src-tauri/src/cli/tui/app/form_handlers/mcp.rs
index df147a1b..d4438830 100644
--- a/src-tauri/src/cli/tui/app/form_handlers/mcp.rs
+++ b/src-tauri/src/cli/tui/app/form_handlers/mcp.rs
@@ -95,46 +95,15 @@ impl App {
mcp.editing = false;
Some(Action::None)
}
- KeyCode::Left => {
- if let Some(input) = mcp.input_mut(selected) {
- input.move_left();
- }
- Some(Action::None)
- }
- KeyCode::Right => {
- if let Some(input) = mcp.input_mut(selected) {
- input.move_right();
- }
- Some(Action::None)
- }
- KeyCode::Home => {
- if let Some(input) = mcp.input_mut(selected) {
- input.move_home();
+ _ => {
+ if TextEditCommand::from_key(key).is_none() {
+ return None;
}
- Some(Action::None)
- }
- KeyCode::End => {
if let Some(input) = mcp.input_mut(selected) {
- input.move_end();
+ input.apply_key(key);
}
Some(Action::None)
}
- KeyCode::Backspace => {
- let _ = mcp.input_mut(selected).map(|input| input.backspace());
- Some(Action::None)
- }
- KeyCode::Delete => {
- let _ = mcp.input_mut(selected).map(|input| input.delete());
- Some(Action::None)
- }
- KeyCode::Char(c) => {
- if c.is_control() {
- return Some(Action::None);
- }
- let _ = mcp.input_mut(selected).map(|input| input.insert_char(c));
- Some(Action::None)
- }
- _ => None,
}
}
@@ -177,6 +146,8 @@ impl App {
McpAddField::AppCodex => mcp.apps.codex = !mcp.apps.codex,
McpAddField::AppGemini => mcp.apps.gemini = !mcp.apps.gemini,
McpAddField::AppOpenCode => mcp.apps.opencode = !mcp.apps.opencode,
+ McpAddField::AppOpenClaw => mcp.apps.openclaw = !mcp.apps.openclaw,
+ McpAddField::AppHermes => mcp.apps.hermes = !mcp.apps.hermes,
_ => {
if selected == McpAddField::Id && mcp.locked_id().is_some() {
return Some(Action::None);
diff --git a/src-tauri/src/cli/tui/app/form_handlers/mod.rs b/src-tauri/src/cli/tui/app/form_handlers/mod.rs
index 3b4cc2ba..11580ea3 100644
--- a/src-tauri/src/cli/tui/app/form_handlers/mod.rs
+++ b/src-tauri/src/cli/tui/app/form_handlers/mod.rs
@@ -6,6 +6,10 @@ mod tab;
impl App {
pub(crate) fn on_form_key(&mut self, key: KeyEvent, data: &UiData) -> Action {
+ if is_save_shortcut(key) {
+ return self.handle_form_save_shortcut(data);
+ }
+
if self.handle_form_tab_key(key) {
return Action::None;
}
@@ -26,10 +30,6 @@ impl App {
return action;
}
- if is_save_shortcut(key) {
- return self.handle_form_save_shortcut(data);
- }
-
match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.handle_form_exit_key(),
_ => Action::None,
diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs
index 880a507e..4865e1a8 100644
--- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs
+++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs
@@ -128,69 +128,22 @@ impl App {
provider.editing = false;
Some(Action::None)
}
- KeyCode::Left => {
- if let Some(input) = provider.input_mut(selected) {
- input.move_left();
- }
- Some(Action::None)
- }
- KeyCode::Right => {
- if let Some(input) = provider.input_mut(selected) {
- input.move_right();
- }
- Some(Action::None)
- }
- KeyCode::Home => {
- if let Some(input) = provider.input_mut(selected) {
- input.move_home();
- }
- Some(Action::None)
- }
- KeyCode::End => {
- if let Some(input) = provider.input_mut(selected) {
- input.move_end();
- }
- Some(Action::None)
- }
- KeyCode::Backspace => {
- let changed = provider
- .input_mut(selected)
- .map(|input| input.backspace())
- .unwrap_or(false);
- self.finish_provider_input_change(selected, changed, data);
- Some(Action::None)
- }
- KeyCode::Delete => {
- let changed = provider
- .input_mut(selected)
- .map(|input| input.delete())
- .unwrap_or(false);
- self.finish_provider_input_change(selected, changed, data);
- Some(Action::None)
- }
- KeyCode::Char(c) => {
- if c.is_control() {
- return Some(Action::None);
- }
-
- if selected == ProviderAddField::Notes {
- let can_insert = provider
- .input(selected)
- .map(|input| input.value.chars().count() < PROVIDER_NOTES_MAX_CHARS)
- .unwrap_or(true);
- if !can_insert {
- return Some(Action::None);
- }
+ _ => {
+ if TextEditCommand::from_key(key).is_none() {
+ return None;
}
-
+ let policy = TextInputPolicy {
+ max_chars: (selected == ProviderAddField::Notes)
+ .then_some(PROVIDER_NOTES_MAX_CHARS),
+ };
let changed = provider
.input_mut(selected)
- .map(|input| input.insert_char(c))
+ .and_then(|input| input.apply_key_with_policy(key, policy))
+ .map(|edit| edit.changed)
.unwrap_or(false);
self.finish_provider_input_change(selected, changed, data);
Some(Action::None)
}
- _ => None,
}
}
diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs
index f35ab4bc..f09f1303 100644
--- a/src-tauri/src/cli/tui/app/helpers.rs
+++ b/src-tauri/src/cli/tui/app/helpers.rs
@@ -879,13 +879,6 @@ impl<'a> OpenClawDailyMemoryListItem<'a> {
Self::Search(row) => &row.filename,
}
}
-
- pub(crate) fn preview(&self) -> &str {
- match self {
- Self::File(row) => &row.preview,
- Self::Search(row) => &row.snippet,
- }
- }
}
pub(crate) fn route_has_content_list(route: &Route) -> bool {
@@ -970,23 +963,76 @@ pub(crate) fn supports_provider_stream_check(app_type: &AppType) -> bool {
!matches!(app_type, AppType::OpenClaw)
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ProviderTestMenuItem {
+ Speedtest,
+ StreamCheck,
+}
+
+pub(crate) fn provider_test_menu_items(app_type: &AppType) -> Vec {
+ let mut items = vec![ProviderTestMenuItem::Speedtest];
+ if supports_provider_stream_check(app_type) {
+ items.push(ProviderTestMenuItem::StreamCheck);
+ }
+ items
+}
+
+pub(crate) fn provider_test_menu_item_label(item: ProviderTestMenuItem) -> &'static str {
+ match item {
+ ProviderTestMenuItem::Speedtest => texts::tui_key_speedtest(),
+ ProviderTestMenuItem::StreamCheck => texts::tui_key_stream_check(),
+ }
+}
+
pub(crate) fn visible_mcp<'a>(
filter: &FilterState,
data: &'a UiData,
-) -> Vec<&'a super::data::McpRow> {
+) -> Vec> {
let query = filter.query_lower();
data.mcp
- .rows
- .iter()
+ .display_rows()
+ .into_iter()
.filter(|row| match &query {
None => true,
- Some(q) => {
- row.server.name.to_lowercase().contains(q) || row.id.to_lowercase().contains(q)
- }
+ Some(q) => row.name().to_lowercase().contains(q) || row.id().to_lowercase().contains(q),
})
.collect()
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum McpLiveDriftResolveChoice {
+ ImportLive,
+ PushDbToLive,
+ Cancel,
+}
+
+pub(crate) fn mcp_live_drift_resolve_choices(
+ kind: &crate::services::McpLiveDriftKind,
+) -> Vec {
+ match kind {
+ crate::services::McpLiveDriftKind::LiveOnly => {
+ vec![
+ McpLiveDriftResolveChoice::ImportLive,
+ McpLiveDriftResolveChoice::Cancel,
+ ]
+ }
+ crate::services::McpLiveDriftKind::DbOnly => {
+ vec![
+ McpLiveDriftResolveChoice::PushDbToLive,
+ McpLiveDriftResolveChoice::Cancel,
+ ]
+ }
+ crate::services::McpLiveDriftKind::Changed => {
+ vec![
+ McpLiveDriftResolveChoice::ImportLive,
+ McpLiveDriftResolveChoice::PushDbToLive,
+ McpLiveDriftResolveChoice::Cancel,
+ ]
+ }
+ _ => vec![McpLiveDriftResolveChoice::Cancel],
+ }
+}
+
pub(crate) fn visible_prompts<'a>(
filter: &FilterState,
data: &'a UiData,
@@ -1103,6 +1149,7 @@ pub(crate) fn openclaw_workspace_entry_count() -> usize {
OpenClawWorkspaceRow::all().len()
}
+#[cfg(test)]
pub(crate) fn openclaw_workspace_rows() -> Vec {
OpenClawWorkspaceRow::all()
}
@@ -1165,11 +1212,12 @@ pub(crate) fn app_type_picker_index(app_type: &AppType) -> usize {
AppType::Gemini => 2,
AppType::OpenCode => 3,
AppType::OpenClaw => 4,
+ AppType::Hermes => 5,
}
}
pub(crate) fn four_app_picker_index(app_type: &AppType) -> usize {
- app_type_picker_index(app_type).min(3)
+ app_type_picker_index(app_type).min(crate::app_config::MCP_PICKER_APPS.len() - 1)
}
pub(crate) fn app_type_for_picker_index(index: usize) -> AppType {
@@ -1178,6 +1226,7 @@ pub(crate) fn app_type_for_picker_index(index: usize) -> AppType {
2 => AppType::Gemini,
3 => AppType::OpenCode,
4 => AppType::OpenClaw,
+ 5 => AppType::Hermes,
_ => AppType::Claude,
}
}
@@ -1190,22 +1239,6 @@ pub(crate) fn snippet_picker_app_type(index: usize) -> AppType {
app_type_for_picker_index(index)
}
-pub(crate) fn sync_method_picker_index(method: SyncMethod) -> usize {
- match method {
- SyncMethod::Auto => 0,
- SyncMethod::Symlink => 1,
- SyncMethod::Copy => 2,
- }
-}
-
-pub(crate) fn sync_method_for_picker_index(index: usize) -> SyncMethod {
- match index {
- 1 => SyncMethod::Symlink,
- 2 => SyncMethod::Copy,
- _ => SyncMethod::Auto,
- }
-}
-
pub(crate) fn openclaw_tools_profile_picker_index(profile: Option<&str>) -> Option {
OPENCLAW_TOOLS_PROFILE_PICKER_VALUES
.iter()
diff --git a/src-tauri/src/cli/tui/app/menu.rs b/src-tauri/src/cli/tui/app/menu.rs
index fa10e4e1..c5850537 100644
--- a/src-tauri/src/cli/tui/app/menu.rs
+++ b/src-tauri/src/cli/tui/app/menu.rs
@@ -3,10 +3,18 @@ use super::*;
const PROXY_ACTIVITY_WINDOW: usize = 48;
const PROXY_ACTIVITY_POLL_INTERVAL_TICKS: u64 = 5;
+fn is_prev_app_switch_key(c: char) -> bool {
+ matches!(c, '[' | '[' | '【')
+}
+
+fn is_next_app_switch_key(c: char) -> bool {
+ matches!(c, ']' | ']' | '】')
+}
+
impl App {
pub(crate) fn clear_openclaw_daily_memory_search_state(&mut self) {
self.filter.active = false;
- self.filter.buffer.clear();
+ self.filter.input.set("");
self.openclaw_daily_memory_search_query.clear();
self.openclaw_daily_memory_search_results.clear();
self.daily_memory_idx = 0;
@@ -43,6 +51,8 @@ impl App {
mcp_idx: 0,
prompt_idx: 0,
skills_idx: 0,
+ skills_visual_anchor: None,
+ skills_pending_g: false,
skills_discover_idx: 0,
skills_repo_idx: 0,
skills_unmanaged_idx: 0,
@@ -59,7 +69,6 @@ impl App {
openclaw_daily_memory_search_results: Vec::new(),
config_webdav_idx: 0,
webdav_quick_setup_username: None,
- language_idx: 0,
settings_idx: 0,
settings_proxy_idx: 0,
}
@@ -263,6 +272,22 @@ impl App {
self.overlay = self.pending_overlay.take().unwrap_or(Overlay::None);
}
+ fn cycle_visible_app_type(&mut self, dir: i8) -> Action {
+ match cycle_app_type(&self.app_type, dir) {
+ Some(next) => Action::SetAppType(next),
+ None => {
+ self.push_toast(
+ crate::t!(
+ "Only one app is visible. Enable more apps in Settings to switch.",
+ "只有一个可见 App,请在设置里启用更多 App 后再切换。"
+ ),
+ ToastKind::Info,
+ );
+ Action::None
+ }
+ }
+ }
+
fn structured_form_is_editing_text_field(&self) -> bool {
match self.route {
Route::ConfigOpenClawTools => false,
@@ -334,21 +359,23 @@ impl App {
self.filter.active = true;
return Action::None;
}
- KeyCode::Char('[') => {
- return cycle_app_type(&self.app_type, -1)
- .map(Action::SetAppType)
- .unwrap_or(Action::None);
+ KeyCode::Char(c) if is_prev_app_switch_key(c) => {
+ return self.cycle_visible_app_type(-1);
}
- KeyCode::Char(']') => {
- return cycle_app_type(&self.app_type, 1)
- .map(Action::SetAppType)
- .unwrap_or(Action::None);
+ KeyCode::Char(c) if is_next_app_switch_key(c) => {
+ return self.cycle_visible_app_type(1);
}
KeyCode::Left => {
+ if matches!(self.route, Route::Main) {
+ return self.cycle_visible_app_type(-1);
+ }
self.focus = Focus::Nav;
return Action::None;
}
KeyCode::Right => {
+ if matches!(self.route, Route::Main) {
+ return self.cycle_visible_app_type(1);
+ }
if route_has_content_list(&self.route) {
self.focus = Focus::Content;
} else {
@@ -394,7 +421,7 @@ impl App {
match key.code {
KeyCode::Esc => {
self.filter.active = false;
- self.filter.buffer.clear();
+ self.filter.input.set("");
if is_daily_memory {
self.openclaw_daily_memory_search_results.clear();
self.daily_memory_idx = 0;
@@ -407,24 +434,20 @@ impl App {
self.filter.active = false;
if is_daily_memory {
return Action::OpenClawDailyMemorySearch {
- query: self.filter.buffer.clone(),
+ query: self.filter.input.value.clone(),
};
}
}
- KeyCode::Backspace => {
- self.filter.buffer.pop();
- if is_daily_memory && self.filter.buffer.is_empty() {
+ _ => {
+ let Some(edit) = self.filter.input.apply_key(key) else {
+ return Action::None;
+ };
+ if is_daily_memory && edit.changed && self.filter.input.value.is_empty() {
return Action::OpenClawDailyMemorySearch {
query: String::new(),
};
}
}
- KeyCode::Char(c) => {
- if !c.is_control() {
- self.filter.buffer.push(c);
- }
- }
- _ => {}
}
Action::None
}
@@ -506,8 +529,12 @@ impl App {
let skills_len = visible_skills_installed(&self.filter, data).len();
if skills_len == 0 {
self.skills_idx = 0;
+ self.skills_visual_anchor = None;
} else {
self.skills_idx = self.skills_idx.min(skills_len - 1);
+ if let Some(anchor) = &mut self.skills_visual_anchor {
+ *anchor = (*anchor).min(skills_len - 1);
+ }
}
let discover_len =
diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs
index 7e71afbd..9d8a8428 100644
--- a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs
+++ b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs
@@ -35,6 +35,11 @@ impl App {
ConfirmAction::SkillsUninstall { directory } => Action::SkillsUninstall {
directory: directory.clone(),
},
+ ConfirmAction::SkillsUninstallMany { directories } => {
+ Action::SkillsUninstallMany {
+ directories: directories.clone(),
+ }
+ }
ConfirmAction::SkillsRepoRemove { owner, name } => Action::SkillsRepoRemove {
owner: owner.clone(),
name: name.clone(),
@@ -59,7 +64,6 @@ impl App {
}
}
ConfirmAction::FormSaveBeforeClose => self.handle_form_save_shortcut(data),
- ConfirmAction::EditorDiscard => Action::EditorDiscard,
ConfirmAction::EditorSaveBeforeClose => {
if let Some(editor) = self.editor.as_ref() {
Action::EditorSubmit {
@@ -73,6 +77,13 @@ impl App {
ConfirmAction::WebDavMigrateV1ToV2 => Action::ConfigWebDavMigrateV1ToV2,
};
self.close_overlay();
+ if matches!(
+ confirm.action,
+ ConfirmAction::SkillsUninstall { .. }
+ | ConfirmAction::SkillsUninstallMany { .. }
+ ) {
+ self.skills_visual_anchor = None;
+ }
action
}
KeyCode::Char('n') | KeyCode::Char('N') => {
@@ -114,27 +125,18 @@ impl App {
}
KeyCode::Enter => {
let raw = match &self.overlay {
- Overlay::TextInput(input) => input.buffer.trim().to_string(),
+ Overlay::TextInput(input) => input.input.value.trim().to_string(),
_ => String::new(),
};
self.overlay = Overlay::None;
self.handle_text_input_submit(submit, raw, data)
}
- KeyCode::Backspace => {
+ _ => {
if let Overlay::TextInput(input) = &mut self.overlay {
- input.buffer.pop();
- }
- Action::None
- }
- KeyCode::Char(c) => {
- if !c.is_control() && !key.modifiers.contains(KeyModifiers::CONTROL) {
- if let Overlay::TextInput(input) = &mut self.overlay {
- input.buffer.push(c);
- }
+ let _ = input.input.apply_key(key);
}
Action::None
}
- _ => Action::None,
};
Some(action)
@@ -161,7 +163,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_prompt_create_title().to_string(),
prompt: texts::tui_prompt_create_prompt().to_string(),
- buffer: raw,
+ input: TextInput::new(raw),
submit: TextSubmit::PromptCreateName,
secret: false,
});
@@ -182,7 +184,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_prompt_rename_title().to_string(),
prompt: texts::tui_prompt_rename_prompt().to_string(),
- buffer: raw,
+ input: TextInput::new(raw),
submit: TextSubmit::PromptRename { id },
secret: false,
});
@@ -221,13 +223,6 @@ impl App {
};
Action::SetOpenClawConfigDir { path }
}
- TextSubmit::SkillsInstallSpec => {
- if raw.is_empty() {
- self.push_toast(texts::tui_toast_skill_spec_empty(), ToastKind::Warning);
- return Action::None;
- }
- Action::SkillsInstall { spec: raw }
- }
TextSubmit::SkillsDiscoverQuery => {
self.skills_discover_query = raw.clone();
Action::SkillsDiscover { query: raw }
@@ -301,7 +296,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_webdav_jianguoyun_setup_title().to_string(),
prompt: texts::tui_webdav_jianguoyun_username_prompt().to_string(),
- buffer: String::new(),
+ input: TextInput::new(""),
submit: TextSubmit::WebDavJianguoyunUsername,
secret: false,
});
@@ -312,7 +307,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_webdav_jianguoyun_setup_title().to_string(),
prompt: texts::tui_webdav_jianguoyun_app_password_prompt().to_string(),
- buffer: String::new(),
+ input: TextInput::new(""),
submit: TextSubmit::WebDavJianguoyunPassword,
secret: true,
});
@@ -325,7 +320,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_webdav_jianguoyun_setup_title().to_string(),
prompt: texts::tui_webdav_jianguoyun_app_password_prompt().to_string(),
- buffer: String::new(),
+ input: TextInput::new(""),
submit: TextSubmit::WebDavJianguoyunPassword,
secret: true,
});
@@ -366,7 +361,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_settings_proxy_title().to_string(),
prompt: texts::tui_settings_proxy_listen_address_prompt().to_string(),
- buffer: trimmed,
+ input: TextInput::new(trimmed),
submit: TextSubmit::SettingsProxyListenAddress,
secret: false,
});
@@ -394,7 +389,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_settings_proxy_title().to_string(),
prompt: texts::tui_settings_proxy_listen_port_prompt().to_string(),
- buffer: trimmed,
+ input: TextInput::new(trimmed),
submit: TextSubmit::SettingsProxyListenPort,
secret: false,
});
@@ -409,7 +404,7 @@ impl App {
self.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_settings_proxy_title().to_string(),
prompt: texts::tui_settings_proxy_listen_port_prompt().to_string(),
- buffer: trimmed,
+ input: TextInput::new(trimmed),
submit: TextSubmit::SettingsProxyListenPort,
secret: false,
});
diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/mcp_env.rs b/src-tauri/src/cli/tui/app/overlay_handlers/mcp_env.rs
index cc2f909a..eae3fd77 100644
--- a/src-tauri/src/cli/tui/app/overlay_handlers/mcp_env.rs
+++ b/src-tauri/src/cli/tui/app/overlay_handlers/mcp_env.rs
@@ -150,22 +150,7 @@ impl App {
McpEnvEditorField::Key => &mut editor.key,
McpEnvEditorField::Value => &mut editor.value,
};
- match key.code {
- KeyCode::Left => input.move_left(),
- KeyCode::Right => input.move_right(),
- KeyCode::Home => input.move_home(),
- KeyCode::End => input.move_end(),
- KeyCode::Backspace => {
- input.backspace();
- }
- KeyCode::Delete => {
- input.delete();
- }
- KeyCode::Char(c) if !c.is_control() => {
- input.insert_char(c);
- }
- _ => {}
- }
+ let _ = input.apply_key(key);
}
Some(Action::None)
}
diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs
index b8ffab3d..4b32ea3c 100644
--- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs
+++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs
@@ -6,12 +6,15 @@ impl App {
key: KeyEvent,
data: &UiData,
) -> Option {
- if let Some(action) = self.handle_sync_method_picker_key(key, data) {
+ if let Some(action) = self.handle_codex_current_provider_mismatch_key(key) {
return Some(action);
}
if let Some(action) = self.handle_claude_api_format_picker_key(key, data) {
return Some(action);
}
+ if let Some(action) = self.handle_provider_test_menu_key(key, data) {
+ return Some(action);
+ }
if let Some(action) = self.handle_claude_model_picker_key(key) {
return Some(action);
}
@@ -30,6 +33,9 @@ impl App {
if let Some(action) = self.handle_mcp_apps_picker_key(key, data) {
return Some(action);
}
+ if let Some(action) = self.handle_mcp_live_drift_resolve_key(key) {
+ return Some(action);
+ }
if let Some(action) = self.handle_visible_apps_picker_key(key) {
return Some(action);
}
@@ -39,14 +45,17 @@ impl App {
if let Some(action) = self.handle_skills_import_picker_key(key) {
return Some(action);
}
+ if let Some(action) = self.handle_skills_agent_import_picker_key(key) {
+ return Some(action);
+ }
if let Some(action) = self.handle_failover_queue_manager_key(key, data) {
return Some(action);
}
None
}
- fn handle_sync_method_picker_key(&mut self, key: KeyEvent, data: &UiData) -> Option {
- let Overlay::SkillsSyncMethodPicker { selected } = &mut self.overlay else {
+ fn handle_codex_current_provider_mismatch_key(&mut self, key: KeyEvent) -> Option {
+ let Overlay::CodexCurrentProviderMismatch { selected, mismatch } = &mut self.overlay else {
return None;
};
@@ -60,18 +69,21 @@ impl App {
Action::None
}
KeyCode::Down => {
- *selected = (*selected + 1).min(3);
+ *selected = (*selected + 1).min(1);
Action::None
}
KeyCode::Enter => {
- let method = sync_method_for_picker_index(*selected);
- let unchanged = method == data.skills.sync_method;
- self.overlay = Overlay::None;
- if unchanged {
- Action::None
+ let action = if *selected == 0 {
+ Action::CodexAcceptLiveCurrent {
+ id: mismatch.live_provider_id.clone(),
+ }
} else {
- Action::SkillsSetSyncMethod { method }
- }
+ Action::ProviderSwitch {
+ id: mismatch.stored_provider_id.clone(),
+ }
+ };
+ self.overlay = Overlay::None;
+ action
}
_ => Action::None,
})
@@ -135,6 +147,61 @@ impl App {
})
}
+ fn handle_provider_test_menu_key(&mut self, key: KeyEvent, data: &UiData) -> Option {
+ let Overlay::ProviderTestMenu {
+ provider_id,
+ selected,
+ } = &mut self.overlay
+ else {
+ return None;
+ };
+
+ let items = provider_test_menu_items(&self.app_type);
+ if items.is_empty() {
+ self.overlay = Overlay::None;
+ return Some(Action::None);
+ }
+
+ *selected = (*selected).min(items.len() - 1);
+
+ Some(match key.code {
+ KeyCode::Esc => {
+ self.overlay = Overlay::None;
+ Action::None
+ }
+ KeyCode::Up => {
+ *selected = selected.saturating_sub(1);
+ Action::None
+ }
+ KeyCode::Down => {
+ *selected = (*selected + 1).min(items.len() - 1);
+ Action::None
+ }
+ KeyCode::Enter | KeyCode::Char(' ') => {
+ let provider_id = provider_id.clone();
+ let item = items[*selected];
+ let row = data
+ .providers
+ .rows
+ .iter()
+ .find(|provider_row| provider_row.id == provider_id)
+ .cloned();
+
+ self.overlay = Overlay::None;
+
+ let Some(row) = row else {
+ return Some(Action::None);
+ };
+
+ match item {
+ ProviderTestMenuItem::Speedtest => self.provider_speedtest_action(&row),
+ ProviderTestMenuItem::StreamCheck => self.provider_stream_check_action(&row),
+ }
+ }
+ _ => Action::None,
+ })
+ }
+
fn handle_claude_model_picker_key(&mut self, key: KeyEvent) -> Option {
let Overlay::ClaudeModelPicker { .. } = &self.overlay else {
return None;
@@ -185,58 +252,14 @@ impl App {
}
Action::None
}
- KeyCode::Left => {
- if let Some(input) = provider.claude_model_input_mut(selected) {
- input.move_left();
- }
- Action::None
- }
- KeyCode::Right => {
+ _ => {
if let Some(input) = provider.claude_model_input_mut(selected) {
- input.move_right();
- }
- Action::None
- }
- KeyCode::Home => {
- if let Some(input) = provider.claude_model_input_mut(selected) {
- input.move_home();
- }
- Action::None
- }
- KeyCode::End => {
- if let Some(input) = provider.claude_model_input_mut(selected) {
- input.move_end();
- }
- Action::None
- }
- KeyCode::Backspace => {
- if let Some(input) = provider.claude_model_input_mut(selected) {
- if input.backspace() {
+ if input.apply_key(key).is_some_and(|edit| edit.changed) {
provider.mark_claude_model_config_touched();
}
}
Action::None
}
- KeyCode::Delete => {
- if let Some(input) = provider.claude_model_input_mut(selected) {
- if input.delete() {
- provider.mark_claude_model_config_touched();
- }
- }
- Action::None
- }
- KeyCode::Char(c) => {
- if c.is_control() {
- return Action::None;
- }
- if let Some(input) = provider.claude_model_input_mut(selected) {
- if input.insert_char(c) {
- provider.mark_claude_model_config_touched();
- }
- }
- Action::None
- }
- _ => Action::None,
}
}
@@ -320,7 +343,7 @@ impl App {
KeyCode::Up => {
*selected_idx = selected_idx.saturating_sub(1);
if let Some(model) = filtered.get(*selected_idx) {
- *input = (*model).to_string();
+ input.set((*model).to_string());
}
Action::None
}
@@ -328,35 +351,21 @@ impl App {
if !filtered.is_empty() {
*selected_idx = (*selected_idx + 1).min(filtered.len() - 1);
if let Some(model) = filtered.get(*selected_idx) {
- *input = (*model).to_string();
+ input.set((*model).to_string());
}
}
Action::None
}
KeyCode::Tab => {
if let Some(model) = filtered.get(*selected_idx) {
- *input = (*model).to_string();
- *query = (*model).to_string();
- *selected_idx = 0;
- }
- Action::None
- }
- KeyCode::Backspace => {
- if !input.is_empty() {
- input.pop();
- *query = input.clone();
+ input.set((*model).to_string());
+ *query = input.value.clone();
*selected_idx = 0;
}
Action::None
}
- KeyCode::Char(c) if !c.is_control() => {
- input.push(c);
- *query = input.clone();
- *selected_idx = 0;
- Action::None
- }
KeyCode::Enter => {
- let selected_model = input.trim().to_string();
+ let selected_model = input.value.trim().to_string();
if selected_model.is_empty() {
self.overlay = Overlay::None;
return Some(Action::None);
@@ -380,7 +389,13 @@ impl App {
}
Action::None
}
- _ => Action::None,
+ _ => {
+ if input.apply_key(key).is_some_and(|edit| edit.changed) {
+ *query = input.value.clone();
+ *selected_idx = 0;
+ }
+ Action::None
+ }
})
}
@@ -523,11 +538,11 @@ impl App {
Action::None
}
KeyCode::Down => {
- *selected = (*selected + 1).min(3);
+ *selected = (*selected + 1).min(crate::app_config::MCP_PICKER_APPS.len() - 1);
Action::None
}
KeyCode::Char('x') | KeyCode::Char(' ') => {
- let app_type = app_type_for_picker_index(*selected);
+ let app_type = crate::app_config::MCP_PICKER_APPS[*selected];
let enabled = apps.is_enabled_for(&app_type);
apps.set_enabled_for(&app_type, !enabled);
Action::None
@@ -554,6 +569,51 @@ impl App {
})
}
+ fn handle_mcp_live_drift_resolve_key(&mut self, key: KeyEvent) -> Option {
+ let Overlay::McpLiveDriftResolve {
+ app_type,
+ id,
+ kind,
+ selected,
+ } = &mut self.overlay
+ else {
+ return None;
+ };
+
+ let choices = mcp_live_drift_resolve_choices(kind);
+ let max_selected = choices.len().saturating_sub(1);
+ *selected = (*selected).min(max_selected);
+
+ Some(match key.code {
+ KeyCode::Esc => {
+ self.overlay = Overlay::None;
+ Action::None
+ }
+ KeyCode::Up => {
+ *selected = selected.saturating_sub(1);
+ Action::None
+ }
+ KeyCode::Down => {
+ *selected = (*selected + 1).min(max_selected);
+ Action::None
+ }
+ KeyCode::Enter => {
+ let choice = choices[*selected];
+ let app_type = app_type.clone();
+ let id = id.clone();
+ self.overlay = Overlay::None;
+ match choice {
+ McpLiveDriftResolveChoice::ImportLive => Action::McpImportLive { app_type, id },
+ McpLiveDriftResolveChoice::PushDbToLive => {
+ Action::McpPushDbToLive { app_type, id }
+ }
+ McpLiveDriftResolveChoice::Cancel => Action::None,
+ }
+ }
+ _ => Action::None,
+ })
+ }
+
fn handle_mcp_type_picker_key(&mut self, key: KeyEvent) -> Option {
let Overlay::McpTypePicker { selected } = &mut self.overlay else {
return None;
@@ -604,11 +664,11 @@ impl App {
Action::None
}
KeyCode::Down => {
- *selected = (*selected + 1).min(4);
+ *selected = (*selected + 1).min(crate::app_config::VISIBLE_PICKER_APPS.len() - 1);
Action::None
}
KeyCode::Char('x') | KeyCode::Char(' ') => {
- let app_type = app_type_for_picker_index(*selected);
+ let app_type = crate::app_config::VISIBLE_PICKER_APPS[*selected];
let enabled = apps.is_enabled_for(&app_type);
apps.set_enabled_for(&app_type, !enabled);
Action::None
@@ -638,6 +698,7 @@ impl App {
fn handle_skills_apps_picker_key(&mut self, key: KeyEvent, data: &UiData) -> Option {
let Overlay::SkillsAppsPicker {
directory,
+ directories,
selected,
apps,
..
@@ -656,29 +717,46 @@ impl App {
Action::None
}
KeyCode::Down => {
- *selected = (*selected + 1).min(3);
+ *selected = (*selected + 1).min(crate::app_config::SKILLS_PICKER_APPS.len() - 1);
Action::None
}
KeyCode::Char('x') | KeyCode::Char(' ') => {
- let app_type = app_type_for_picker_index(*selected);
+ let app_type = crate::app_config::SKILLS_PICKER_APPS[*selected];
let enabled = apps.is_enabled_for(&app_type);
apps.set_enabled_for(&app_type, !enabled);
Action::None
}
KeyCode::Enter => {
let directory = directory.clone();
+ let directories = directories.clone();
let next = apps.clone();
- let unchanged = data
- .skills
- .installed
- .iter()
- .find(|skill| skill.directory == directory)
- .map(|skill| skill.apps == next)
- .unwrap_or(false);
+ let unchanged = if directories.len() > 1 {
+ directories.iter().all(|directory| {
+ data.skills
+ .installed
+ .iter()
+ .find(|skill| skill.directory == *directory)
+ .map(|skill| skill.apps == next)
+ .unwrap_or(false)
+ })
+ } else {
+ data.skills
+ .installed
+ .iter()
+ .find(|skill| skill.directory == directory)
+ .map(|skill| skill.apps == next)
+ .unwrap_or(false)
+ };
self.overlay = Overlay::None;
+ self.skills_visual_anchor = None;
if unchanged {
Action::None
+ } else if directories.len() > 1 {
+ Action::SkillsSetAppsMany {
+ directories,
+ apps: next,
+ }
} else {
Action::SkillsSetApps {
directory,
@@ -745,6 +823,61 @@ impl App {
})
}
+ fn handle_skills_agent_import_picker_key(&mut self, key: KeyEvent) -> Option {
+ let Overlay::SkillsAgentImportPicker {
+ skills,
+ selected_idx,
+ selected,
+ } = &mut self.overlay
+ else {
+ return None;
+ };
+
+ Some(match key.code {
+ KeyCode::Esc => {
+ self.overlay = Overlay::None;
+ Action::None
+ }
+ KeyCode::Up => {
+ *selected_idx = selected_idx.saturating_sub(1);
+ Action::None
+ }
+ KeyCode::Down => {
+ if !skills.is_empty() {
+ *selected_idx = (*selected_idx + 1).min(skills.len() - 1);
+ }
+ Action::None
+ }
+ KeyCode::Char('x') | KeyCode::Char(' ') => {
+ let Some(skill) = skills.get(*selected_idx) else {
+ return Some(Action::None);
+ };
+ if selected.contains(&skill.directory) {
+ selected.remove(&skill.directory);
+ } else {
+ selected.insert(skill.directory.clone());
+ }
+ Action::None
+ }
+ KeyCode::Char('r') => Action::SkillsOpenAgentImport,
+ KeyCode::Char('i') | KeyCode::Enter => {
+ if selected.is_empty() {
+ self.push_toast(texts::tui_toast_no_unmanaged_selected(), ToastKind::Info);
+ return Some(Action::None);
+ }
+
+ let directories = skills
+ .iter()
+ .filter(|skill| selected.contains(&skill.directory))
+ .map(|skill| skill.directory.clone())
+ .collect();
+ self.overlay = Overlay::None;
+ Action::SkillsImportFromAgent { directories }
+ }
+ _ => Action::None,
+ })
+ }
+
fn handle_failover_queue_manager_key(
&mut self,
key: KeyEvent,
diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/views.rs b/src-tauri/src/cli/tui/app/overlay_handlers/views.rs
index b1e80749..61440841 100644
--- a/src-tauri/src/cli/tui/app/overlay_handlers/views.rs
+++ b/src-tauri/src/cli/tui/app/overlay_handlers/views.rs
@@ -127,7 +127,7 @@ impl App {
let has_action = matches!(
&self.overlay,
Overlay::TextView(TextViewState {
- action: Some(TextViewAction::ProxyToggleTakeover { .. }),
+ action: Some(TextViewAction::ProxyToggleTakeover),
..
})
);
diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs
index 9e12c27d..344a60e0 100644
--- a/src-tauri/src/cli/tui/app/tests.rs
+++ b/src-tauri/src/cli/tui/app/tests.rs
@@ -12,7 +12,7 @@ mod tests {
use tempfile::TempDir;
use crate::cli::i18n::{texts, use_test_language, Language};
- use crate::cli::tui::data::ProviderRow;
+ use crate::cli::tui::data::{McpLiveOnlyRow, ProviderRow};
use crate::cli::tui::form::{McpEnvVarRow, McpTransport, TextInput};
use crate::cli::tui::runtime_actions::handle_action;
use crate::cli::tui::runtime_systems::RequestTracker;
@@ -21,7 +21,7 @@ mod tests {
use crate::error::AppError;
use crate::prompt::Prompt;
use crate::provider::Provider;
- use crate::services::PromptService;
+ use crate::services::{McpLiveDriftEntry, McpLiveDriftKind, PromptService};
use crate::settings::{get_settings, update_settings, AppSettings};
use crate::test_support::{
lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock,
@@ -38,8 +38,10 @@ mod tests {
let lock = lock_test_home_and_settings();
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
- std::env::set_var("HOME", home);
- std::env::set_var("USERPROFILE", home);
+ unsafe {
+ std::env::set_var("HOME", home);
+ std::env::set_var("USERPROFILE", home);
+ }
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
@@ -53,12 +55,12 @@ mod tests {
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
+ Some(value) => unsafe { std::env::set_var("HOME", value) },
+ None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
- Some(value) => std::env::set_var("USERPROFILE", value),
- None => std::env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
+ None => unsafe { std::env::remove_var("USERPROFILE") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
@@ -93,10 +95,57 @@ mod tests {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
+ fn alt(code: KeyCode) -> KeyEvent {
+ KeyEvent::new(code, KeyModifiers::ALT)
+ }
+
fn data() -> UiData {
UiData::default()
}
+ fn installed_skill(directory: &str, name: &str) -> crate::services::skill::InstalledSkill {
+ crate::services::skill::InstalledSkill {
+ id: format!("local:{directory}"),
+ name: name.to_string(),
+ description: None,
+ directory: directory.to_string(),
+ repo_owner: None,
+ repo_name: None,
+ repo_branch: None,
+ readme_url: None,
+ apps: crate::app_config::SkillApps::default(),
+ installed_at: 0,
+ }
+ }
+
+ fn skills_data(directories: &[&str]) -> UiData {
+ let mut data = UiData::default();
+ data.skills.installed = directories
+ .iter()
+ .map(|directory| installed_skill(directory, directory))
+ .collect();
+ data
+ }
+
+ fn claude_provider_row(id: &str) -> ProviderRow {
+ ProviderRow {
+ id: id.to_string(),
+ provider: Provider::with_id(
+ id.to_string(),
+ "Provider One".to_string(),
+ json!({"env":{"ANTHROPIC_BASE_URL":"https://example.com","ANTHROPIC_AUTH_TOKEN":"sk-demo"}}),
+ None,
+ ),
+ api_url: Some("https://example.com".to_string()),
+ is_current: false,
+ is_in_config: true,
+ is_saved: true,
+ is_default_model: false,
+ primary_model_id: None,
+ default_model_id: None,
+ }
+ }
+
fn nav_index(app: &App, item: NavItem) -> usize {
app.nav_items()
.iter()
@@ -207,6 +256,19 @@ mod tests {
);
}
+ #[test]
+ fn skills_s_requests_agent_import_picker() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Skills;
+ app.focus = Focus::Content;
+
+ let action = app.on_key(key(KeyCode::Char('s')), &data());
+ assert!(
+ matches!(action, Action::SkillsOpenAgentImport),
+ "s in Skills page should open the agent-installed skill import flow"
+ );
+ }
+
#[test]
fn skills_f_opens_discover_page() {
let mut app = App::new(Some(AppType::Claude));
@@ -220,6 +282,98 @@ mod tests {
);
}
+ #[test]
+ fn skills_gg_and_g_jump_to_edges() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Skills;
+ app.focus = Focus::Content;
+ app.skills_idx = 2;
+ let data = skills_data(&["alpha", "beta", "gamma"]);
+
+ assert!(matches!(
+ app.on_key(key(KeyCode::Char('g')), &data),
+ Action::None
+ ));
+ assert_eq!(app.skills_idx, 2);
+ assert!(matches!(
+ app.on_key(key(KeyCode::Char('g')), &data),
+ Action::None
+ ));
+ assert_eq!(app.skills_idx, 0);
+
+ assert!(matches!(
+ app.on_key(key(KeyCode::Char('G')), &data),
+ Action::None
+ ));
+ assert_eq!(app.skills_idx, 2);
+ }
+
+ #[test]
+ fn skills_visual_mode_batch_toggles_selected_range() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Skills;
+ app.focus = Focus::Content;
+ app.skills_idx = 1;
+ let data = skills_data(&["alpha", "beta", "gamma", "delta"]);
+
+ assert!(matches!(
+ app.on_key(key(KeyCode::Char('v')), &data),
+ Action::None
+ ));
+ assert!(matches!(
+ app.on_key(key(KeyCode::Down), &data),
+ Action::None
+ ));
+
+ let action = app.on_key(key(KeyCode::Char('x')), &data);
+ assert!(matches!(
+ action,
+ Action::SkillsToggleMany {
+ directories,
+ enabled: true
+ } if directories == vec!["beta".to_string(), "gamma".to_string()]
+ ));
+ }
+
+ #[test]
+ fn skills_visual_mode_batch_apps_picker_and_delete() {
+ let mut app = App::new(Some(AppType::Codex));
+ app.route = Route::Skills;
+ app.focus = Focus::Content;
+ let data = skills_data(&["alpha", "beta", "gamma"]);
+
+ assert!(matches!(
+ app.on_key(key(KeyCode::Char('v')), &data),
+ Action::None
+ ));
+ assert!(matches!(
+ app.on_key(key(KeyCode::Down), &data),
+ Action::None
+ ));
+
+ let action = app.on_key(key(KeyCode::Char('m')), &data);
+ assert!(matches!(action, Action::None));
+ assert!(matches!(
+ &app.overlay,
+ Overlay::SkillsAppsPicker {
+ directories,
+ selected: 1,
+ ..
+ } if directories == &vec!["alpha".to_string(), "beta".to_string()]
+ ));
+
+ app.overlay = Overlay::None;
+ let action = app.on_key(key(KeyCode::Char('d')), &data);
+ assert!(matches!(action, Action::None));
+ assert!(matches!(
+ &app.overlay,
+ Overlay::Confirm(ConfirmOverlay {
+ action: ConfirmAction::SkillsUninstallMany { directories },
+ ..
+ }) if directories == &vec!["alpha".to_string(), "beta".to_string()]
+ ));
+ }
+
#[test]
fn skills_m_opens_apps_picker_overlay() {
let mut app = App::new(Some(AppType::Codex));
@@ -248,10 +402,11 @@ mod tests {
&app.overlay,
Overlay::SkillsAppsPicker {
directory,
+ directories,
name,
selected: 1,
..
- } if directory == "hello-skill" && name == "Hello Skill"
+ } if directory == "hello-skill" && directories == &vec!["hello-skill".to_string()] && name == "Hello Skill"
));
}
@@ -295,7 +450,7 @@ mod tests {
}
#[test]
- fn skills_apps_picker_from_openclaw_targets_opencode_last_visible_row() {
+ fn skills_apps_picker_from_openclaw_targets_openclaw_row() {
let mut app = App::new(Some(AppType::OpenClaw));
app.route = Route::Skills;
app.focus = Focus::Content;
@@ -320,7 +475,7 @@ mod tests {
assert!(matches!(action, Action::None));
assert!(matches!(
&app.overlay,
- Overlay::SkillsAppsPicker { selected, .. } if *selected == 3
+ Overlay::SkillsAppsPicker { selected, .. } if *selected == 4
));
let action = app.on_key(key(KeyCode::Char('x')), &data);
@@ -328,11 +483,13 @@ mod tests {
assert!(matches!(
&app.overlay,
Overlay::SkillsAppsPicker { selected, apps, .. }
- if *selected == 3
+ if *selected == 4
&& !apps.claude
&& !apps.codex
&& !apps.gemini
- && apps.opencode
+ && !apps.opencode
+ && apps.openclaw
+ && !apps.hermes
));
}
@@ -395,6 +552,7 @@ mod tests {
gemini: true,
opencode: true,
openclaw: true,
+ hermes: false,
})
.expect("save visible apps");
let mut app = App::new(Some(AppType::Claude));
@@ -408,6 +566,65 @@ mod tests {
));
}
+ #[test]
+ #[serial(home_settings)]
+ fn app_cycles_with_full_width_bracket_keys() {
+ let temp_home = TempDir::new().expect("create temp home");
+ let _env = EnvGuard::set_home(temp_home.path());
+ crate::settings::set_visible_apps(crate::settings::VisibleApps {
+ claude: true,
+ codex: true,
+ gemini: true,
+ opencode: true,
+ openclaw: true,
+ hermes: false,
+ })
+ .expect("save visible apps");
+
+ for previous_key in ['[', '【'] {
+ let mut app = App::new(Some(AppType::Claude));
+ assert!(matches!(
+ app.on_key(key(KeyCode::Char(previous_key)), &data()),
+ Action::SetAppType(AppType::OpenClaw)
+ ));
+ }
+
+ for next_key in [']', '】'] {
+ let mut app = App::new(Some(AppType::Claude));
+ assert!(matches!(
+ app.on_key(key(KeyCode::Char(next_key)), &data()),
+ Action::SetAppType(AppType::Codex)
+ ));
+ }
+ }
+
+ #[test]
+ #[serial(home_settings)]
+ fn app_cycles_with_arrow_keys_on_main_route() {
+ let temp_home = TempDir::new().expect("create temp home");
+ let _env = EnvGuard::set_home(temp_home.path());
+ crate::settings::set_visible_apps(crate::settings::VisibleApps {
+ claude: true,
+ codex: true,
+ gemini: true,
+ opencode: true,
+ openclaw: true,
+ hermes: false,
+ })
+ .expect("save visible apps");
+
+ let mut app = App::new(Some(AppType::Claude));
+ assert!(matches!(app.route, Route::Main));
+ assert!(matches!(
+ app.on_key(key(KeyCode::Right), &data()),
+ Action::SetAppType(AppType::Codex)
+ ));
+ assert!(matches!(
+ app.on_key(key(KeyCode::Left), &data()),
+ Action::SetAppType(AppType::OpenClaw)
+ ));
+ }
+
#[test]
#[serial(home_settings)]
fn app_cycles_through_opencode() {
@@ -419,6 +636,7 @@ mod tests {
gemini: true,
opencode: true,
openclaw: true,
+ hermes: false,
})
.expect("save visible apps");
let mut app = App::new(Some(AppType::Gemini));
@@ -459,6 +677,7 @@ mod tests {
gemini: false,
opencode: true,
openclaw: true,
+ hermes: false,
})
.expect("save visible apps");
@@ -481,6 +700,7 @@ mod tests {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
})
.expect("save visible apps");
@@ -490,6 +710,10 @@ mod tests {
app.on_key(key(KeyCode::Char(']')), &data()),
Action::None
));
+ assert!(matches!(
+ app.toast.as_ref(),
+ Some(toast) if toast.kind == ToastKind::Info
+ ));
assert!(matches!(
app.on_key(key(KeyCode::Char('[')), &data()),
Action::None
@@ -507,6 +731,7 @@ mod tests {
gemini: false,
opencode: false,
openclaw: true,
+ hermes: false,
})
.expect("save visible apps");
@@ -529,6 +754,7 @@ mod tests {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
})
.expect("save visible apps");
@@ -709,13 +935,194 @@ mod tests {
assert_eq!(app.filter.active, true);
app.on_key(key(KeyCode::Char('a')), &data());
app.on_key(key(KeyCode::Char('b')), &data());
- assert_eq!(app.filter.buffer, "ab");
+ assert_eq!(app.filter.input.value, "ab");
app.on_key(key(KeyCode::Backspace), &data());
- assert_eq!(app.filter.buffer, "a");
+ assert_eq!(app.filter.input.value, "a");
app.on_key(key(KeyCode::Enter), &data());
assert_eq!(app.filter.active, false);
}
+ #[test]
+ fn filter_mode_supports_readline_shortcuts() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.on_key(key(KeyCode::Char('/')), &data());
+ for ch in "alpha beta".chars() {
+ app.on_key(key(KeyCode::Char(ch)), &data());
+ }
+
+ app.on_key(ctrl(KeyCode::Char('a')), &data());
+ app.on_key(key(KeyCode::Char('>')), &data());
+ app.on_key(ctrl(KeyCode::Char('e')), &data());
+ app.on_key(ctrl(KeyCode::Char('w')), &data());
+
+ assert_eq!(app.filter.input.value, ">alpha ");
+ assert_eq!(app.filter.input.cursor, ">alpha ".chars().count());
+ }
+
+ #[test]
+ fn text_input_overlay_supports_readline_shortcuts() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.overlay = Overlay::TextInput(TextInputState {
+ title: "Demo".to_string(),
+ prompt: "Value".to_string(),
+ input: TextInput::new("alpha beta"),
+ submit: TextSubmit::ConfigBackupName,
+ secret: false,
+ });
+
+ app.on_key(ctrl(KeyCode::Char('a')), &data());
+ app.on_key(key(KeyCode::Char('>')), &data());
+ app.on_key(ctrl(KeyCode::Char('e')), &data());
+ app.on_key(ctrl(KeyCode::Char('w')), &data());
+
+ assert!(matches!(
+ app.overlay,
+ Overlay::TextInput(TextInputState { input, .. })
+ if input.value == ">alpha " && input.cursor == ">alpha ".chars().count()
+ ));
+ }
+
+ #[test]
+ fn provider_field_editor_supports_readline_shortcuts() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+ app.on_key(key(KeyCode::Char('a')), &data());
+ app.on_key(key(KeyCode::Enter), &data());
+
+ let name_idx = match app.form.as_ref() {
+ Some(FormState::ProviderAdd(form)) => form
+ .fields()
+ .iter()
+ .position(|field| *field == ProviderAddField::Name)
+ .expect("name field should exist"),
+ _ => panic!("provider form should be open"),
+ };
+
+ if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() {
+ form.field_idx = name_idx;
+ form.editing = true;
+ form.name.set("alpha beta");
+ }
+
+ app.on_key(ctrl(KeyCode::Char('a')), &data());
+ app.on_key(key(KeyCode::Char('>')), &data());
+ app.on_key(ctrl(KeyCode::Char('e')), &data());
+ app.on_key(ctrl(KeyCode::Char('w')), &data());
+
+ let form = match app.form.as_ref() {
+ Some(FormState::ProviderAdd(form)) => form,
+ _ => panic!("provider form should stay open"),
+ };
+ assert_eq!(form.name.value, ">alpha ");
+ }
+
+ #[test]
+ fn mcp_field_editor_supports_readline_shortcuts() {
+ let mut app = App::new(Some(AppType::Claude));
+ let mut form = McpAddFormState::new();
+ form.focus = FormFocus::Fields;
+ form.field_idx = form
+ .fields()
+ .iter()
+ .position(|field| *field == McpAddField::Name)
+ .expect("name field should exist");
+ form.editing = true;
+ form.name.set("alpha beta");
+ app.form = Some(FormState::McpAdd(form));
+
+ app.on_key(ctrl(KeyCode::Char('a')), &data());
+ app.on_key(key(KeyCode::Char('>')), &data());
+ app.on_key(ctrl(KeyCode::Char('e')), &data());
+ app.on_key(ctrl(KeyCode::Char('w')), &data());
+
+ let form = match app.form.as_ref() {
+ Some(FormState::McpAdd(form)) => form,
+ _ => panic!("mcp form should stay open"),
+ };
+ assert_eq!(form.name.value, ">alpha ");
+ }
+
+ #[test]
+ fn mcp_env_entry_editor_supports_readline_shortcuts() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.form = Some(FormState::McpAdd(McpAddFormState::new()));
+ app.overlay = Overlay::McpEnvEntryEditor(McpEnvEntryEditorState {
+ row: None,
+ return_selected: 0,
+ field: McpEnvEditorField::Key,
+ key: TextInput::new("alpha beta"),
+ value: TextInput::new(""),
+ });
+
+ app.on_key(ctrl(KeyCode::Char('a')), &data());
+ app.on_key(key(KeyCode::Char('>')), &data());
+ app.on_key(ctrl(KeyCode::Char('e')), &data());
+ app.on_key(ctrl(KeyCode::Char('w')), &data());
+
+ assert!(matches!(
+ app.overlay,
+ Overlay::McpEnvEntryEditor(McpEnvEntryEditorState { key, .. })
+ if key.value == ">alpha " && key.cursor == ">alpha ".chars().count()
+ ));
+ }
+
+ #[test]
+ fn model_fetch_picker_supports_readline_shortcuts() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.overlay = Overlay::ModelFetchPicker {
+ request_id: 1,
+ field: ProviderAddField::Name,
+ claude_idx: None,
+ input: TextInput::new("alpha beta"),
+ query: "alpha beta".to_string(),
+ fetching: false,
+ models: vec!["alpha beta".to_string()],
+ error: None,
+ selected_idx: 0,
+ };
+
+ app.on_key(ctrl(KeyCode::Char('a')), &data());
+ app.on_key(key(KeyCode::Char('>')), &data());
+ app.on_key(ctrl(KeyCode::Char('e')), &data());
+ app.on_key(ctrl(KeyCode::Char('w')), &data());
+
+ assert!(matches!(
+ app.overlay,
+ Overlay::ModelFetchPicker { input, query, .. }
+ if input.value == ">alpha "
+ && input.cursor == ">alpha ".chars().count()
+ && query == ">alpha "
+ ));
+ }
+
+ #[test]
+ fn multiline_editor_supports_readline_shortcuts() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.open_editor(
+ "Prompt",
+ EditorKind::Plain,
+ "first line\nalpha beta",
+ EditorSubmit::PromptCreate {
+ name: "Demo".to_string(),
+ },
+ );
+ if let Some(editor) = app.editor.as_mut() {
+ editor.cursor_row = 1;
+ editor.cursor_col = "alpha beta".chars().count();
+ }
+
+ app.on_key(ctrl(KeyCode::Char('a')), &data());
+ assert_eq!(app.editor.as_ref().unwrap().cursor_col, 0);
+
+ app.on_key(ctrl(KeyCode::Char('e')), &data());
+ app.on_key(ctrl(KeyCode::Char('w')), &data());
+ assert_eq!(app.editor.as_ref().unwrap().lines[1], "alpha ");
+
+ app.on_key(alt(KeyCode::Char('b')), &data());
+ assert_eq!(app.editor.as_ref().unwrap().cursor_col, 0);
+ }
+
#[test]
fn tab_key_is_noop() {
let mut app = App::new(Some(AppType::Claude));
@@ -800,6 +1207,42 @@ mod tests {
));
}
+ #[test]
+ fn providers_enter_key_imports_current_config_when_empty() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+
+ let action = app.on_key(key(KeyCode::Enter), &UiData::default());
+
+ assert!(matches!(action, Action::ProviderImportLiveConfig));
+ assert!(matches!(app.overlay, Overlay::None));
+ }
+
+ #[test]
+ fn providers_i_key_is_noop() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+
+ let action = app.on_key(key(KeyCode::Char('i')), &UiData::default());
+
+ assert!(matches!(action, Action::None));
+ assert!(matches!(app.overlay, Overlay::None));
+ }
+
+ #[test]
+ fn codex_providers_i_key_imports_current_config() {
+ let mut app = App::new(Some(AppType::Codex));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+
+ let action = app.on_key(key(KeyCode::Char('i')), &UiData::default());
+
+ assert!(matches!(action, Action::ProviderImportLiveConfig));
+ assert!(matches!(app.overlay, Overlay::None));
+ }
+
#[test]
fn providers_s_key_triggers_switch_action() {
let mut app = App::new(Some(AppType::Claude));
@@ -894,34 +1337,17 @@ mod tests {
}
#[test]
- fn providers_c_key_requests_stream_check() {
+ fn providers_c_key_is_noop() {
let mut app = App::new(Some(AppType::Claude));
app.route = Route::Providers;
app.focus = Focus::Content;
let mut data = UiData::default();
- data.providers.rows.push(super::super::data::ProviderRow {
- id: "p1".to_string(),
- provider: crate::provider::Provider::with_id(
- "p1".to_string(),
- "Provider One".to_string(),
- json!({"env":{"ANTHROPIC_BASE_URL":"https://example.com","ANTHROPIC_AUTH_TOKEN":"sk-demo"}}),
- None,
- ),
- api_url: Some("https://example.com".to_string()),
- is_current: false,
- is_in_config: true,
- is_saved: true,
- is_default_model: false,
- primary_model_id: None,
- default_model_id: None,
- });
+ data.providers.rows.push(claude_provider_row("p1"));
let action = app.on_key(key(KeyCode::Char('c')), &data);
- assert!(matches!(action, Action::ProviderStreamCheck { id } if id == "p1"));
- assert!(
- matches!(app.overlay, Overlay::StreamCheckRunning { ref provider_name, .. } if provider_name == "Provider One")
- );
+ assert!(matches!(action, Action::None));
+ assert!(matches!(app.overlay, Overlay::None));
}
#[test]
@@ -950,7 +1376,122 @@ mod tests {
let action = app.on_key(key(KeyCode::Char('c')), &data);
assert!(matches!(action, Action::None));
- assert!(matches!(app.overlay, Overlay::None));
+ assert!(matches!(app.overlay, Overlay::None));
+ }
+
+ #[test]
+ fn providers_t_key_opens_test_menu() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+
+ let mut data = UiData::default();
+ data.providers.rows.push(claude_provider_row("p1"));
+
+ let action = app.on_key(key(KeyCode::Char('t')), &data);
+
+ assert!(matches!(action, Action::None));
+ assert!(matches!(
+ app.overlay,
+ Overlay::ProviderTestMenu {
+ ref provider_id,
+ selected: 0
+ } if provider_id == "p1"
+ ));
+ }
+
+ #[test]
+ fn provider_test_menu_enter_runs_speedtest() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+ app.overlay = Overlay::ProviderTestMenu {
+ provider_id: "p1".to_string(),
+ selected: 0,
+ };
+
+ let mut data = UiData::default();
+ data.providers.rows.push(claude_provider_row("p1"));
+
+ let action = app.on_key(key(KeyCode::Enter), &data);
+
+ assert!(
+ matches!(action, Action::ProviderSpeedtest { ref url } if url == "https://example.com")
+ );
+ assert!(
+ matches!(app.overlay, Overlay::SpeedtestRunning { ref url } if url == "https://example.com")
+ );
+ }
+
+ #[test]
+ fn provider_test_menu_second_item_runs_stream_check() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+ app.overlay = Overlay::ProviderTestMenu {
+ provider_id: "p1".to_string(),
+ selected: 1,
+ };
+
+ let mut data = UiData::default();
+ data.providers.rows.push(claude_provider_row("p1"));
+
+ let action = app.on_key(key(KeyCode::Enter), &data);
+
+ assert!(matches!(action, Action::ProviderStreamCheck { ref id } if id == "p1"));
+ assert!(
+ matches!(app.overlay, Overlay::StreamCheckRunning { ref provider_name, .. } if provider_name == "Provider One")
+ );
+ }
+
+ #[test]
+ fn provider_test_menu_t_key_is_noop() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+ app.overlay = Overlay::ProviderTestMenu {
+ provider_id: "p1".to_string(),
+ selected: 0,
+ };
+
+ let mut data = UiData::default();
+ data.providers.rows.push(claude_provider_row("p1"));
+
+ let action = app.on_key(key(KeyCode::Char('t')), &data);
+
+ assert!(matches!(action, Action::None));
+ assert!(matches!(
+ app.overlay,
+ Overlay::ProviderTestMenu {
+ ref provider_id,
+ selected: 0
+ } if provider_id == "p1"
+ ));
+ }
+
+ #[test]
+ fn provider_test_menu_c_key_is_noop() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+ app.overlay = Overlay::ProviderTestMenu {
+ provider_id: "p1".to_string(),
+ selected: 1,
+ };
+
+ let mut data = UiData::default();
+ data.providers.rows.push(claude_provider_row("p1"));
+
+ let action = app.on_key(key(KeyCode::Char('c')), &data);
+
+ assert!(matches!(action, Action::None));
+ assert!(matches!(
+ app.overlay,
+ Overlay::ProviderTestMenu {
+ ref provider_id,
+ selected: 1
+ } if provider_id == "p1"
+ ));
}
#[test]
@@ -1082,6 +1623,38 @@ mod tests {
));
}
+ #[test]
+ fn openclaw_providers_space_key_sets_default_model_from_selected_config_provider() {
+ let mut app = App::new(Some(AppType::OpenClaw));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+
+ let mut data = UiData::default();
+ data.providers.rows.push(super::super::data::ProviderRow {
+ id: "p1".to_string(),
+ provider: crate::provider::Provider::with_id(
+ "p1".to_string(),
+ "Provider One".to_string(),
+ json!({"apiKey":"sk-demo","baseUrl":"https://example.com"}),
+ None,
+ ),
+ api_url: Some("https://example.com".to_string()),
+ is_current: false,
+ is_in_config: true,
+ is_saved: true,
+ is_default_model: false,
+ primary_model_id: Some("claude-sonnet-4".to_string()),
+ default_model_id: None,
+ });
+
+ let action = app.on_key(key(KeyCode::Char(' ')), &data);
+ assert!(matches!(
+ action,
+ Action::ProviderSetDefaultModel { provider_id, model_id }
+ if provider_id == "p1" && model_id == "claude-sonnet-4"
+ ));
+ }
+
#[test]
fn openclaw_providers_s_key_allows_removing_fallback_only_default_provider() {
let mut app = App::new(Some(AppType::OpenClaw));
@@ -1276,40 +1849,77 @@ mod tests {
}
#[test]
- fn provider_detail_c_key_requests_stream_check() {
+ fn provider_detail_c_key_is_noop() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::ProviderDetail {
+ id: "p1".to_string(),
+ };
+ app.focus = Focus::Content;
+
+ let mut data = UiData::default();
+ data.providers.rows.push(claude_provider_row("p1"));
+
+ let action = app.on_key(key(KeyCode::Char('c')), &data);
+ assert!(matches!(action, Action::None));
+ assert!(matches!(app.overlay, Overlay::None));
+ }
+
+ #[test]
+ fn provider_detail_t_key_opens_test_menu() {
let mut app = App::new(Some(AppType::Claude));
app.route = Route::ProviderDetail {
id: "p1".to_string(),
};
app.focus = Focus::Content;
+ let mut data = UiData::default();
+ data.providers.rows.push(claude_provider_row("p1"));
+
+ let action = app.on_key(key(KeyCode::Char('t')), &data);
+
+ assert!(matches!(action, Action::None));
+ assert!(matches!(
+ app.overlay,
+ Overlay::ProviderTestMenu {
+ ref provider_id,
+ selected: 0
+ } if provider_id == "p1"
+ ));
+ }
+
+ #[test]
+ fn provider_detail_c_key_is_noop_for_openclaw() {
+ let mut app = App::new(Some(AppType::OpenClaw));
+ app.route = Route::ProviderDetail {
+ id: "p1".to_string(),
+ };
+ app.focus = Focus::Content;
+
let mut data = UiData::default();
data.providers.rows.push(super::super::data::ProviderRow {
id: "p1".to_string(),
provider: crate::provider::Provider::with_id(
"p1".to_string(),
"Provider One".to_string(),
- json!({"env":{"ANTHROPIC_BASE_URL":"https://example.com","ANTHROPIC_AUTH_TOKEN":"sk-demo"}}),
+ json!({"apiKey":"sk-demo","baseUrl":"https://example.com"}),
None,
),
api_url: Some("https://example.com".to_string()),
is_current: false,
- is_in_config: true,
+ is_in_config: false,
is_saved: true,
is_default_model: false,
- primary_model_id: None,
+ primary_model_id: Some("claude-sonnet-4".to_string()),
default_model_id: None,
});
let action = app.on_key(key(KeyCode::Char('c')), &data);
- assert!(matches!(action, Action::ProviderStreamCheck { id } if id == "p1"));
- assert!(
- matches!(app.overlay, Overlay::StreamCheckRunning { ref provider_name, .. } if provider_name == "Provider One")
- );
+ assert!(matches!(action, Action::None));
+ assert!(matches!(app.overlay, Overlay::None));
}
#[test]
- fn provider_detail_c_key_is_noop_for_openclaw() {
+ fn openclaw_provider_detail_x_key_sets_default_model() {
let mut app = App::new(Some(AppType::OpenClaw));
app.route = Route::ProviderDetail {
id: "p1".to_string(),
@@ -1327,20 +1937,23 @@ mod tests {
),
api_url: Some("https://example.com".to_string()),
is_current: false,
- is_in_config: false,
+ is_in_config: true,
is_saved: true,
is_default_model: false,
primary_model_id: Some("claude-sonnet-4".to_string()),
default_model_id: None,
});
- let action = app.on_key(key(KeyCode::Char('c')), &data);
- assert!(matches!(action, Action::None));
- assert!(matches!(app.overlay, Overlay::None));
+ let action = app.on_key(key(KeyCode::Char('x')), &data);
+ assert!(matches!(
+ action,
+ Action::ProviderSetDefaultModel { provider_id, model_id }
+ if provider_id == "p1" && model_id == "claude-sonnet-4"
+ ));
}
#[test]
- fn openclaw_provider_detail_x_key_sets_default_model() {
+ fn openclaw_provider_detail_space_key_sets_default_model() {
let mut app = App::new(Some(AppType::OpenClaw));
app.route = Route::ProviderDetail {
id: "p1".to_string(),
@@ -1365,7 +1978,7 @@ mod tests {
default_model_id: None,
});
- let action = app.on_key(key(KeyCode::Char('x')), &data);
+ let action = app.on_key(key(KeyCode::Char(' ')), &data);
assert!(matches!(
action,
Action::ProviderSetDefaultModel { provider_id, model_id }
@@ -1697,6 +2310,110 @@ mod tests {
));
}
+ #[test]
+ fn mcp_r_opens_resolve_overlay_for_changed_db_row() {
+ let mut app = App::new(Some(AppType::Codex));
+ app.route = Route::Mcp;
+ app.focus = Focus::Content;
+
+ let mut data = UiData::default();
+ data.mcp.rows.push(super::super::data::McpRow {
+ id: "m1".to_string(),
+ server: crate::app_config::McpServer {
+ id: "m1".to_string(),
+ name: "Server".to_string(),
+ server: json!({"command":"db"}),
+ apps: crate::app_config::McpApps {
+ codex: true,
+ ..crate::app_config::McpApps::default()
+ },
+ description: None,
+ homepage: None,
+ docs: None,
+ tags: vec![],
+ },
+ });
+ data.mcp.drift_by_id.insert(
+ "m1".to_string(),
+ McpLiveDriftEntry {
+ app: AppType::Codex,
+ id: "m1".to_string(),
+ kind: McpLiveDriftKind::Changed,
+ db_spec: Some(json!({"command":"db"})),
+ live_spec: Some(json!({"command":"live"})),
+ message: None,
+ },
+ );
+
+ let action = app.on_key(key(KeyCode::Char('r')), &data);
+
+ assert!(matches!(action, Action::None));
+ assert!(matches!(
+ &app.overlay,
+ Overlay::McpLiveDriftResolve {
+ app_type,
+ id,
+ kind: McpLiveDriftKind::Changed,
+ selected: 0,
+ } if *app_type == AppType::Codex && id == "m1"
+ ));
+ }
+
+ #[test]
+ fn mcp_r_on_live_only_row_opens_import_only_resolve_overlay() {
+ let mut app = App::new(Some(AppType::Codex));
+ app.route = Route::Mcp;
+ app.focus = Focus::Content;
+
+ let mut data = UiData::default();
+ data.mcp.live_only.push(McpLiveOnlyRow {
+ id: "live-only".to_string(),
+ app: AppType::Codex,
+ live_spec: json!({"type":"http","url":"https://live.example.com/mcp"}),
+ });
+
+ let action = app.on_key(key(KeyCode::Char('r')), &data);
+
+ assert!(matches!(action, Action::None));
+ assert!(matches!(
+ &app.overlay,
+ Overlay::McpLiveDriftResolve {
+ app_type,
+ id,
+ kind: McpLiveDriftKind::LiveOnly,
+ selected: 0,
+ } if *app_type == AppType::Codex && id == "live-only"
+ ));
+
+ let action = app.on_key(key(KeyCode::Enter), &data);
+ assert!(matches!(
+ action,
+ Action::McpImportLive { app_type, id }
+ if app_type == AppType::Codex && id == "live-only"
+ ));
+ }
+
+ #[test]
+ fn mcp_resolve_overlay_can_choose_push_db_to_live() {
+ let mut app = App::new(Some(AppType::Codex));
+ app.overlay = Overlay::McpLiveDriftResolve {
+ app_type: AppType::Codex,
+ id: "m1".to_string(),
+ kind: McpLiveDriftKind::Changed,
+ selected: 0,
+ };
+
+ let action = app.on_key(key(KeyCode::Down), &UiData::default());
+ assert!(matches!(action, Action::None));
+
+ let action = app.on_key(key(KeyCode::Enter), &UiData::default());
+ assert!(matches!(
+ action,
+ Action::McpPushDbToLive { app_type, id }
+ if app_type == AppType::Codex && id == "m1"
+ ));
+ }
+
#[test]
fn mcp_apps_picker_x_toggles_selected_app_and_enter_emits_action() {
let mut app = App::new(Some(AppType::Codex));
@@ -1776,7 +2493,7 @@ mod tests {
}
#[test]
- fn mcp_apps_picker_from_openclaw_targets_opencode_last_visible_row() {
+ fn mcp_apps_picker_from_openclaw_targets_openclaw_row() {
let mut app = App::new(Some(AppType::OpenClaw));
app.route = Route::Mcp;
app.focus = Focus::Content;
@@ -1800,7 +2517,7 @@ mod tests {
assert!(matches!(action, Action::None));
assert!(matches!(
&app.overlay,
- Overlay::McpAppsPicker { selected, .. } if *selected == 3
+ Overlay::McpAppsPicker { selected, .. } if *selected == 4
));
let action = app.on_key(key(KeyCode::Char('x')), &data);
@@ -1808,11 +2525,48 @@ mod tests {
assert!(matches!(
&app.overlay,
Overlay::McpAppsPicker { selected, apps, .. }
- if *selected == 3
+ if *selected == 4
&& !apps.claude
&& !apps.codex
&& !apps.gemini
- && apps.opencode
+ && !apps.opencode
+ && apps.openclaw
+ && !apps.hermes
+ ));
+ }
+
+ #[test]
+ fn mcp_apps_picker_can_select_hermes_after_openclaw() {
+ let mut app = App::new(Some(AppType::OpenClaw));
+ app.route = Route::Mcp;
+ app.focus = Focus::Content;
+
+ let mut data = UiData::default();
+ data.mcp.rows.push(super::super::data::McpRow {
+ id: "m1".to_string(),
+ server: crate::app_config::McpServer {
+ id: "m1".to_string(),
+ name: "Server".to_string(),
+ server: json!({}),
+ apps: crate::app_config::McpApps::default(),
+ description: None,
+ homepage: None,
+ docs: None,
+ tags: vec![],
+ },
+ });
+
+ app.on_key(key(KeyCode::Char('m')), &data);
+ app.on_key(key(KeyCode::Down), &data);
+
+ let action = app.on_key(key(KeyCode::Char('x')), &data);
+ assert!(matches!(action, Action::None));
+ assert!(matches!(
+ &app.overlay,
+ Overlay::McpAppsPicker { selected, apps, .. }
+ if *selected == 5
+ && !apps.openclaw
+ && apps.hermes
));
}
@@ -2791,7 +3545,7 @@ mod tests {
app.route_stack = vec![Route::Config];
app.focus = Focus::Content;
app.workspace_idx = workspace_row_index(OpenClawWorkspaceRow::DailyMemory);
- app.filter.buffer = "workspace".to_string();
+ app.filter.input.set("workspace".to_string());
app.openclaw_daily_memory_search_results = vec![DailyMemorySearchResult {
filename: "2026-03-20.md".to_string(),
date: "2026-03-20".to_string(),
@@ -2809,7 +3563,7 @@ mod tests {
));
assert_eq!(app.route, Route::ConfigOpenClawDailyMemory);
assert!(!app.filter.active);
- assert!(app.filter.buffer.is_empty());
+ assert!(app.filter.input.value.is_empty());
assert!(app.openclaw_daily_memory_search_results.is_empty());
}
@@ -2836,7 +3590,7 @@ mod tests {
app.route = Route::ConfigOpenClawDailyMemory;
app.route_stack = vec![Route::Main, Route::ConfigOpenClawWorkspace];
app.focus = Focus::Content;
- app.filter.buffer = "focus".to_string();
+ app.filter.input.set("focus".to_string());
app.openclaw_daily_memory_search_results = vec![DailyMemorySearchResult {
filename: "2026-03-20.md".to_string(),
date: "2026-03-20".to_string(),
@@ -2854,7 +3608,7 @@ mod tests {
));
assert_eq!(app.route, Route::ConfigOpenClawWorkspace);
assert!(!app.filter.active);
- assert!(app.filter.buffer.is_empty());
+ assert!(app.filter.input.value.is_empty());
assert!(app.openclaw_daily_memory_search_results.is_empty());
}
@@ -2872,9 +3626,9 @@ mod tests {
assert!(matches!(action, Action::None));
assert!(matches!(
&app.overlay,
- Overlay::TextInput(TextInputState { submit, buffer, .. })
+ Overlay::TextInput(TextInputState { submit, input, .. })
if *submit == TextSubmit::OpenClawDailyMemoryFilename
- && (buffer == &before || buffer == &after)
+ && (input.value == before || input.value == after)
));
}
@@ -2886,7 +3640,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_openclaw_daily_memory_create_title().to_string(),
prompt: texts::tui_openclaw_daily_memory_create_prompt().to_string(),
- buffer: "bad-name.md".to_string(),
+ input: TextInput::new("bad-name.md".to_string()),
submit: TextSubmit::OpenClawDailyMemoryFilename,
secret: false,
});
@@ -2900,7 +3654,7 @@ mod tests {
);
assert!(matches!(
&app.overlay,
- Overlay::TextInput(TextInputState { buffer, .. }) if buffer == "bad-name.md"
+ Overlay::TextInput(TextInputState { input, .. }) if input.value == "bad-name.md"
));
assert!(matches!(
app.toast.as_ref(),
@@ -2999,7 +3753,7 @@ mod tests {
let action = app.on_key(key(KeyCode::Char('m')), &UiData::default());
assert!(matches!(action, Action::None));
- assert_eq!(app.filter.buffer, "m");
+ assert_eq!(app.filter.input.value, "m");
}
#[test]
@@ -3007,7 +3761,7 @@ mod tests {
let mut app = App::new(Some(AppType::OpenClaw));
app.route = Route::ConfigOpenClawDailyMemory;
app.filter.active = true;
- app.filter.buffer = "focus".to_string();
+ app.filter.input.set("focus".to_string());
let action = app.on_key(key(KeyCode::Enter), &UiData::default());
@@ -3133,6 +3887,7 @@ mod tests {
#[test]
#[serial(home_settings)]
+ #[cfg(unix)]
fn openclaw_workspace_open_failure_is_localized() {
let temp_home = TempDir::new().expect("create temp home");
let openclaw_dir = temp_home.path().join(".openclaw");
@@ -3190,7 +3945,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_openclaw_daily_memory_create_title().to_string(),
prompt: texts::tui_openclaw_daily_memory_create_prompt().to_string(),
- buffer: "2026-03-20.md".to_string(),
+ input: TextInput::new("2026-03-20.md".to_string()),
submit: TextSubmit::OpenClawDailyMemoryFilename,
secret: false,
});
@@ -3227,7 +3982,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_openclaw_daily_memory_create_title().to_string(),
prompt: texts::tui_openclaw_daily_memory_create_prompt().to_string(),
- buffer: "2026-03-20.md".to_string(),
+ input: TextInput::new("2026-03-20.md".to_string()),
submit: TextSubmit::OpenClawDailyMemoryFilename,
secret: false,
});
@@ -3258,6 +4013,7 @@ mod tests {
#[test]
#[serial(home_settings)]
+ #[cfg(unix)]
fn openclaw_daily_memory_save_failure_is_localized() {
let temp_home = TempDir::new().expect("create temp home");
let openclaw_dir = temp_home.path().join(".openclaw");
@@ -3314,7 +4070,7 @@ mod tests {
let mut app = App::new(Some(AppType::OpenClaw));
app.route = Route::ConfigOpenClawDailyMemory;
- app.filter.buffer = "focus".to_string();
+ app.filter.input.set("focus".to_string());
app.openclaw_daily_memory_search_query = "focus".to_string();
app.openclaw_daily_memory_search_results = vec![DailyMemorySearchResult {
filename: "2026-03-19.md".to_string(),
@@ -3373,7 +4129,7 @@ mod tests {
let mut app = App::new(Some(AppType::OpenClaw));
app.route = Route::ConfigOpenClawDailyMemory;
- app.filter.buffer = "focus".to_string();
+ app.filter.input.set("focus".to_string());
app.openclaw_daily_memory_search_query = "focus".to_string();
app.daily_memory_idx = 1;
let mut data = UiData::load(&AppType::OpenClaw).expect("load openclaw ui data");
@@ -3967,7 +4723,7 @@ mod tests {
));
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer.is_empty()
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value.is_empty()
));
for ch in ['/', '?', '[', ']', 'q'] {
@@ -3979,7 +4735,7 @@ mod tests {
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer == "/?[]q"
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value == "/?[]q"
));
assert_eq!(app.route, Route::ConfigOpenClawTools);
assert_eq!(app.app_type, AppType::OpenClaw);
@@ -4045,7 +4801,7 @@ mod tests {
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer == "hjkl"
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value == "hjkl"
));
}
@@ -4073,7 +4829,7 @@ mod tests {
));
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer == "Read"
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value == "Read"
));
assert!(matches!(app.on_key(key(KeyCode::Esc), &data), Action::None));
@@ -4089,7 +4845,7 @@ mod tests {
));
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer.is_empty()
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value.is_empty()
));
}
@@ -4117,7 +4873,7 @@ mod tests {
assert!(matches!(action, Action::None));
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer == "Read"
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value == "Read"
));
}
@@ -4149,7 +4905,7 @@ mod tests {
assert!(matches!(action, Action::None));
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer.is_empty()
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value.is_empty()
));
}
@@ -4169,7 +4925,9 @@ mod tests {
Overlay::OpenClawToolsProfilePicker { selected } => {
format!("profile-picker:{selected:?}")
}
- Overlay::TextInput(TextInputState { buffer, .. }) => format!("text-input:{buffer}"),
+ Overlay::TextInput(TextInputState { input, .. }) => {
+ format!("text-input:{}", input.value)
+ }
other => panic!("expected tools picker or popup editor, got {other:?}"),
};
@@ -4286,7 +5044,7 @@ mod tests {
));
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer.is_empty()
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value.is_empty()
));
assert!(matches!(
app.on_key(key(KeyCode::Char('G')), &data),
@@ -4324,7 +5082,7 @@ mod tests {
));
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer.is_empty()
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value.is_empty()
));
let action = app.on_key(key(KeyCode::Enter), &data);
@@ -4361,7 +5119,7 @@ mod tests {
assert!(matches!(action, Action::None));
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer.is_empty()
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value.is_empty()
));
}
@@ -5589,14 +6347,14 @@ mod tests {
Overlay::TextInput(TextInputState {
ref title,
ref prompt,
- ref buffer,
+ ref input,
submit: TextSubmit::OpenClawAgentsRuntimeField {
field: OpenClawAgentsRuntimeField::Timeout,
},
..
}) if title == texts::tui_openclaw_agents_timeout()
&& prompt == texts::tui_openclaw_agents_timeout()
- && buffer.is_empty()
+ && input.value.is_empty()
));
}
@@ -5623,7 +6381,7 @@ mod tests {
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer == "hjkl"
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value == "hjkl"
));
let form = app
@@ -5658,7 +6416,7 @@ mod tests {
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer == "/?[]q"
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value == "/?[]q"
));
let form = app
@@ -5696,7 +6454,7 @@ mod tests {
assert!(matches!(action, Action::None));
assert!(matches!(
app.overlay,
- Overlay::TextInput(TextInputState { ref buffer, .. }) if buffer == "x"
+ Overlay::TextInput(TextInputState { ref input, .. }) if input.value == "x"
));
let form = app
@@ -5839,7 +6597,7 @@ mod tests {
Action::None
));
if let Overlay::TextInput(ref mut input) = app.overlay {
- input.buffer = " ".to_string();
+ input.input.set(" ".to_string());
} else {
panic!("expected runtime text input overlay");
}
@@ -5883,7 +6641,7 @@ mod tests {
Action::None
));
if let Overlay::TextInput(ref mut input) = app.overlay {
- input.buffer.clear();
+ input.input.set("");
} else {
panic!("expected timeout text input overlay");
}
@@ -7095,6 +7853,16 @@ mod tests {
);
}
+ #[test]
+ fn settings_menu_exposes_skill_sync_method_item() {
+ assert!(
+ SettingsItem::ALL
+ .iter()
+ .any(|item| matches!(item, SettingsItem::SkillSyncMethod)),
+ "Settings should expose a skill sync method entry"
+ );
+ }
+
#[test]
#[serial(home_settings)]
fn settings_openclaw_config_dir_item_opens_text_input() {
@@ -7118,9 +7886,9 @@ mod tests {
app.overlay,
Overlay::TextInput(TextInputState {
submit: TextSubmit::SettingsOpenClawConfigDir,
- buffer,
+ input,
..
- }) if buffer == r"\\wsl$\Ubuntu\home\demo\.openclaw"
+ }) if input.value == r"\\wsl$\Ubuntu\home\demo\.openclaw"
));
}
@@ -7133,7 +7901,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: "OpenClaw Config Directory".to_string(),
prompt: "path".to_string(),
- buffer: r"\\wsl$\Ubuntu\home\demo\.openclaw".to_string(),
+ input: TextInput::new(r"\\wsl$\Ubuntu\home\demo\.openclaw".to_string()),
submit: TextSubmit::SettingsOpenClawConfigDir,
secret: false,
});
@@ -7148,7 +7916,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: "OpenClaw Config Directory".to_string(),
prompt: "path".to_string(),
- buffer: " ".to_string(),
+ input: TextInput::new(" ".to_string()),
submit: TextSubmit::SettingsOpenClawConfigDir,
secret: false,
});
@@ -7160,6 +7928,37 @@ mod tests {
));
}
+ #[test]
+ #[serial(home_settings)]
+ fn settings_skill_sync_method_item_cycles_to_next_method() {
+ let temp_home = TempDir::new().expect("create temp home");
+ let _env = EnvGuard::set_home(temp_home.path());
+ crate::settings::set_skill_sync_method(crate::services::skill::SyncMethod::Auto)
+ .expect("seed sync method");
+
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Settings;
+ app.focus = Focus::Content;
+ app.settings_idx = SettingsItem::ALL
+ .iter()
+ .position(|item| matches!(item, SettingsItem::SkillSyncMethod))
+ .expect("SkillSyncMethod missing from SettingsItem::ALL");
+
+ let action = app.on_key(key(KeyCode::Enter), &UiData::default());
+ assert!(matches!(
+ action,
+ Action::SetSkillSyncMethod(crate::services::skill::SyncMethod::Copy)
+ ));
+
+ crate::settings::set_skill_sync_method(crate::services::skill::SyncMethod::Copy)
+ .expect("seed copy sync method");
+ let action = app.on_key(key(KeyCode::Enter), &UiData::default());
+ assert!(matches!(
+ action,
+ Action::SetSkillSyncMethod(crate::services::skill::SyncMethod::Symlink)
+ ));
+ }
+
#[test]
#[serial(home_settings)]
fn settings_visible_apps_item_opens_picker_overlay() {
@@ -7195,6 +7994,7 @@ mod tests {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
})
.expect("save visible apps");
@@ -7332,7 +8132,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: "Listen Address".to_string(),
prompt: "address".to_string(),
- buffer: "127.0.0.1".to_string(),
+ input: TextInput::new("127.0.0.1".to_string()),
submit: TextSubmit::SettingsProxyListenAddress,
secret: false,
});
@@ -7346,7 +8146,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: "Listen Port".to_string(),
prompt: "port".to_string(),
- buffer: "15721".to_string(),
+ input: TextInput::new("15721".to_string()),
submit: TextSubmit::SettingsProxyListenPort,
secret: false,
});
@@ -7366,7 +8166,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: "Listen Address".to_string(),
prompt: "address".to_string(),
- buffer: "bad host".to_string(),
+ input: TextInput::new("bad host".to_string()),
submit: TextSubmit::SettingsProxyListenAddress,
secret: false,
});
@@ -7384,7 +8184,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: "Listen Port".to_string(),
prompt: "port".to_string(),
- buffer: "80".to_string(),
+ input: TextInput::new("80".to_string()),
submit: TextSubmit::SettingsProxyListenPort,
secret: false,
});
@@ -7407,7 +8207,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: "Listen Address".to_string(),
prompt: "address".to_string(),
- buffer: "127.0.0.1".to_string(),
+ input: TextInput::new("127.0.0.1".to_string()),
submit: TextSubmit::SettingsProxyListenAddress,
secret: false,
});
@@ -7531,7 +8331,7 @@ mod tests {
));
if let Overlay::TextInput(ref mut input) = app.overlay {
- input.buffer = "demo@nutstore.com".to_string();
+ input.input.set("demo@nutstore.com".to_string());
}
let action = app.on_key(key(KeyCode::Enter), &data);
assert!(matches!(action, Action::None));
@@ -7545,7 +8345,7 @@ mod tests {
));
if let Overlay::TextInput(ref mut input) = app.overlay {
- input.buffer = "app-password".to_string();
+ input.input.set("app-password".to_string());
}
let action = app.on_key(key(KeyCode::Enter), &data);
assert!(matches!(
@@ -7580,7 +8380,7 @@ mod tests {
));
if let Overlay::TextInput(ref mut input) = app.overlay {
- input.buffer = " ".to_string();
+ input.input.set(" ".to_string());
}
let action = app.on_key(key(KeyCode::Enter), &data);
assert!(matches!(action, Action::None));
@@ -7593,11 +8393,11 @@ mod tests {
));
if let Overlay::TextInput(ref mut input) = app.overlay {
- input.buffer = "demo@nutstore.com".to_string();
+ input.input.set("demo@nutstore.com".to_string());
}
let _ = app.on_key(key(KeyCode::Enter), &data);
if let Overlay::TextInput(ref mut input) = app.overlay {
- input.buffer = " ".to_string();
+ input.input.set(" ".to_string());
}
let action = app.on_key(key(KeyCode::Enter), &data);
assert!(matches!(action, Action::None));
@@ -7684,7 +8484,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_prompt_create_title().to_string(),
prompt: texts::tui_prompt_create_prompt().to_string(),
- buffer: "Prompt One".to_string(),
+ input: TextInput::new("Prompt One".to_string()),
submit: TextSubmit::PromptCreateName,
secret: false,
});
@@ -7706,7 +8506,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_prompt_create_title().to_string(),
prompt: texts::tui_prompt_create_prompt().to_string(),
- buffer: " ".to_string(),
+ input: TextInput::new(" ".to_string()),
submit: TextSubmit::PromptCreateName,
secret: false,
});
@@ -7748,9 +8548,9 @@ mod tests {
app.overlay,
Overlay::TextInput(TextInputState {
submit: TextSubmit::PromptRename { ref id },
- ref buffer,
+ ref input,
..
- }) if id == "pr1" && buffer == "Demo"
+ }) if id == "pr1" && input.value == "Demo"
));
}
@@ -7763,7 +8563,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_prompt_rename_title().to_string(),
prompt: texts::tui_prompt_rename_prompt().to_string(),
- buffer: " ".to_string(),
+ input: TextInput::new(" ".to_string()),
submit: TextSubmit::PromptRename {
id: "pr1".to_string(),
},
@@ -7790,7 +8590,7 @@ mod tests {
app.overlay = Overlay::TextInput(TextInputState {
title: texts::tui_prompt_rename_title().to_string(),
prompt: texts::tui_prompt_rename_prompt().to_string(),
- buffer: "Renamed".to_string(),
+ input: TextInput::new("Renamed".to_string()),
submit: TextSubmit::PromptRename {
id: "pr1".to_string(),
},
@@ -7814,7 +8614,7 @@ mod tests {
let mut app = App::new(Some(AppType::Claude));
app.route = Route::Prompts;
app.focus = Focus::Content;
- app.filter.buffer = "focus".to_string();
+ app.filter.input.set("focus".to_string());
app.prompt_idx = 0;
let mut data = UiData::load(&app.app_type).expect("load ui data");
@@ -7831,7 +8631,7 @@ mod tests {
.expect("create prompt");
assert!(!app.filter.active);
- assert!(app.filter.buffer.is_empty());
+ assert!(app.filter.input.value.is_empty());
assert_eq!(app.prompt_idx, 0);
assert_eq!(data.prompts.rows.len(), 1);
assert_eq!(data.prompts.rows[0].id, "prompt-one");
@@ -7863,7 +8663,7 @@ mod tests {
let mut app = App::new(Some(AppType::Claude));
app.route = Route::Prompts;
app.focus = Focus::Content;
- app.filter.buffer = "demo".to_string();
+ app.filter.input.set("demo".to_string());
app.prompt_idx = 0;
let mut data = UiData::load(&app.app_type).expect("load ui data");
@@ -7878,7 +8678,7 @@ mod tests {
.expect("rename prompt");
assert!(!app.filter.active);
- assert!(app.filter.buffer.is_empty());
+ assert!(app.filter.input.value.is_empty());
assert_eq!(app.prompt_idx, 0);
assert_eq!(data.prompts.rows.len(), 1);
assert_eq!(data.prompts.rows[0].id, "pr1");
@@ -9501,6 +10301,8 @@ mod tests {
let mut data = UiData::default();
data.proxy.auto_failover_enabled = true;
+ data.proxy.running = true;
+ data.proxy.claude_takeover = true;
data.providers.rows.push(failover_provider_row(
"p1",
"Provider One",
@@ -9514,6 +10316,28 @@ mod tests {
assert!(matches!(app.toast.as_ref(), Some(toast) if toast.kind == ToastKind::Info));
}
+ #[test]
+ fn providers_space_switches_provider_when_failover_enabled_but_proxy_inactive() {
+ let mut app = App::new(Some(AppType::Claude));
+ app.route = Route::Providers;
+ app.focus = Focus::Content;
+
+ let mut data = UiData::default();
+ data.proxy.auto_failover_enabled = true;
+ data.proxy.running = false;
+ data.proxy.claude_takeover = true;
+ data.providers.rows.push(failover_provider_row(
+ "p1",
+ "Provider One",
+ json!({"env":{"ANTHROPIC_BASE_URL":"https://example.com"}}),
+ true,
+ Some(0),
+ ));
+
+ let action = app.on_key(key(KeyCode::Char(' ')), &data);
+ assert!(matches!(action, Action::ProviderSwitch { id } if id == "p1"));
+ }
+
#[test]
fn providers_s_key_is_blocked_when_failover_enabled() {
let mut app = App::new(Some(AppType::Codex));
@@ -9522,6 +10346,8 @@ mod tests {
let mut data = UiData::default();
data.proxy.auto_failover_enabled = true;
+ data.proxy.running = true;
+ data.proxy.codex_takeover = true;
data.providers.rows.push(failover_provider_row(
"p1",
"Provider One",
diff --git a/src-tauri/src/cli/tui/app/types.rs b/src-tauri/src/cli/tui/app/types.rs
index 97093a88..889e2171 100644
--- a/src-tauri/src/cli/tui/app/types.rs
+++ b/src-tauri/src/cli/tui/app/types.rs
@@ -3,19 +3,19 @@ use super::*;
#[derive(Debug, Clone)]
pub struct FilterState {
pub active: bool,
- pub buffer: String,
+ pub input: TextInput,
}
impl FilterState {
pub fn new() -> Self {
Self {
active: false,
- buffer: String::new(),
+ input: TextInput::new(""),
}
}
pub fn query_lower(&self) -> Option {
- let trimmed = self.buffer.trim();
+ let trimmed = self.input.value.trim();
if trimmed.is_empty() {
return None;
}
@@ -61,6 +61,7 @@ pub enum ConfirmAction {
McpDelete { id: String },
PromptDelete { id: String },
SkillsUninstall { directory: String },
+ SkillsUninstallMany { directories: Vec },
SkillsRepoRemove { owner: String, name: String },
ConfigImport { path: String },
ConfigRestoreBackup { id: String },
@@ -70,7 +71,6 @@ pub enum ConfirmAction {
ProviderApiFormatProxyNotice,
OpenClawDailyMemoryDelete { filename: String },
FormSaveBeforeClose,
- EditorDiscard,
EditorSaveBeforeClose,
WebDavMigrateV1ToV2,
}
@@ -94,7 +94,6 @@ pub enum TextSubmit {
SettingsProxyListenAddress,
SettingsProxyListenPort,
SettingsOpenClawConfigDir,
- SkillsInstallSpec,
SkillsDiscoverQuery,
SkillsRepoAdd,
OpenClawDailyMemoryFilename,
@@ -113,7 +112,7 @@ pub enum TextSubmit {
pub struct TextInputState {
pub title: String,
pub prompt: String,
- pub buffer: String,
+ pub input: TextInput,
pub submit: TextSubmit,
pub secret: bool,
}
@@ -128,16 +127,7 @@ pub struct TextViewState {
#[derive(Debug, Clone)]
pub enum TextViewAction {
- ProxyToggleTakeover { app_type: AppType, enabled: bool },
-}
-
-impl TextViewAction {
- pub fn key_label(&self) -> &'static str {
- match self {
- TextViewAction::ProxyToggleTakeover { enabled: true, .. } => texts::tui_key_takeover(),
- TextViewAction::ProxyToggleTakeover { enabled: false, .. } => texts::tui_key_restore(),
- }
- }
+ ProxyToggleTakeover,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -186,6 +176,10 @@ pub enum Overlay {
CommonSnippetPicker {
selected: usize,
},
+ ProviderTestMenu {
+ provider_id: String,
+ selected: usize,
+ },
FailoverQueueManager {
selected: usize,
},
@@ -200,11 +194,15 @@ pub enum Overlay {
ClaudeApiFormatPicker {
selected: usize,
},
+ CodexCurrentProviderMismatch {
+ selected: usize,
+ mismatch: crate::services::provider::CodexCurrentProviderMismatch,
+ },
ModelFetchPicker {
request_id: u64,
field: ProviderAddField,
claude_idx: Option,
- input: String,
+ input: TextInput,
query: String,
fetching: bool,
models: Vec,
@@ -225,12 +223,19 @@ pub enum Overlay {
selected: usize,
apps: crate::app_config::McpApps,
},
+ McpLiveDriftResolve {
+ app_type: AppType,
+ id: String,
+ kind: crate::services::McpLiveDriftKind,
+ selected: usize,
+ },
VisibleAppsPicker {
selected: usize,
apps: crate::settings::VisibleApps,
},
SkillsAppsPicker {
directory: String,
+ directories: Vec,
name: String,
selected: usize,
apps: crate::app_config::SkillApps,
@@ -240,8 +245,10 @@ pub enum Overlay {
selected_idx: usize,
selected: HashSet,
},
- SkillsSyncMethodPicker {
- selected: usize,
+ SkillsAgentImportPicker {
+ skills: Vec,
+ selected_idx: usize,
+ selected: HashSet,
},
McpEnvPicker {
selected: usize,
diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs
index dabd47c3..a035c4aa 100644
--- a/src-tauri/src/cli/tui/data.rs
+++ b/src-tauri/src/cli/tui/data.rs
@@ -14,7 +14,10 @@ use crate::prompt::Prompt;
use crate::provider::Provider;
use crate::services::config::BackupInfo;
use crate::services::SubscriptionQuota;
-use crate::services::{ConfigService, McpService, PromptService, ProviderService, SkillService};
+use crate::services::{
+ ConfigService, McpLiveDriftEntry, McpLiveDriftKind, McpService, PromptService, ProviderService,
+ SkillService,
+};
use crate::store::AppState;
#[derive(Debug, Clone)]
@@ -151,6 +154,8 @@ impl QuotaSnapshot {
pub struct ProvidersSnapshot {
pub current_id: String,
pub rows: Vec,
+ pub(crate) codex_current_mismatch:
+ Option,
}
#[derive(Debug, Clone)]
@@ -162,6 +167,120 @@ pub struct McpRow {
#[derive(Debug, Clone, Default)]
pub struct McpSnapshot {
pub rows: Vec,
+ pub drift_by_id: HashMap,
+ pub live_only: Vec,
+ pub live_warning: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct McpLiveOnlyRow {
+ pub id: String,
+ pub app: AppType,
+ pub live_spec: Value,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum McpDisplayRow<'a> {
+ Db(&'a McpRow),
+ LiveOnly(&'a McpLiveOnlyRow),
+}
+
+impl<'a> McpDisplayRow<'a> {
+ pub fn id(&self) -> &'a str {
+ match self {
+ Self::Db(row) => &row.id,
+ Self::LiveOnly(row) => &row.id,
+ }
+ }
+
+ pub fn name(&self) -> &'a str {
+ match self {
+ Self::Db(row) => &row.server.name,
+ Self::LiveOnly(row) => &row.id,
+ }
+ }
+
+ pub fn db_row(&self) -> Option<&'a McpRow> {
+ match self {
+ Self::Db(row) => Some(row),
+ Self::LiveOnly(_) => None,
+ }
+ }
+
+ pub fn app_enabled(&self, app_type: &AppType) -> bool {
+ match self {
+ Self::Db(row) => row.server.apps.is_enabled_for(app_type),
+ Self::LiveOnly(row) => &row.app == app_type,
+ }
+ }
+
+ pub fn live_spec_summary(&self) -> Option {
+ let Self::LiveOnly(row) = self else {
+ return None;
+ };
+
+ if let Some(url) = row.live_spec.get("url").and_then(Value::as_str) {
+ return Some(format!("({url})"));
+ }
+
+ if let Some(command) = row.live_spec.get("command").and_then(Value::as_str) {
+ return Some(format!("({command})"));
+ }
+
+ row.live_spec
+ .get("type")
+ .and_then(Value::as_str)
+ .map(|server_type| format!("({server_type})"))
+ }
+
+ pub fn drift_kind(&self, snapshot: &'a McpSnapshot) -> Option<&'a McpLiveDriftKind> {
+ match self {
+ Self::Db(row) => snapshot.drift_by_id.get(&row.id).map(|entry| &entry.kind),
+ Self::LiveOnly(_) => Some(&McpLiveDriftKind::LiveOnly),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct McpLiveDriftCounts {
+ pub changed: usize,
+ pub live_only: usize,
+ pub db_only: usize,
+ pub invalid: usize,
+}
+
+impl McpLiveDriftCounts {
+ pub fn has_drift(&self) -> bool {
+ self.changed > 0 || self.live_only > 0 || self.db_only > 0 || self.invalid > 0
+ }
+}
+
+impl McpSnapshot {
+ pub fn display_rows(&self) -> Vec> {
+ self.rows
+ .iter()
+ .map(McpDisplayRow::Db)
+ .chain(self.live_only.iter().map(McpDisplayRow::LiveOnly))
+ .collect()
+ }
+
+ pub fn live_drift_counts(&self) -> McpLiveDriftCounts {
+ let mut counts = McpLiveDriftCounts {
+ live_only: self.live_only.len(),
+ invalid: usize::from(self.live_warning.is_some()),
+ ..McpLiveDriftCounts::default()
+ };
+
+ for entry in self.drift_by_id.values() {
+ match entry.kind {
+ McpLiveDriftKind::Changed => counts.changed += 1,
+ McpLiveDriftKind::DbOnly => counts.db_only += 1,
+ _ => {}
+ }
+ }
+
+ counts
+ }
}
#[derive(Debug, Clone)]
@@ -184,6 +303,7 @@ pub struct ConfigSnapshot {
pub common_snippets: CommonConfigSnippets,
pub webdav_sync: Option,
pub openclaw_config_path: Option,
+ #[allow(dead_code)]
pub openclaw_config_dir: Option,
pub openclaw_env: Option,
pub openclaw_tools: Option,
@@ -203,11 +323,13 @@ pub struct OpenClawWorkspaceSnapshot {
pub struct SkillsSnapshot {
pub installed: Vec,
pub repos: Vec,
+ #[allow(dead_code)]
pub sync_method: crate::services::skill::SyncMethod,
}
#[derive(Debug, Clone, Default)]
pub struct ProxyTargetSnapshot {
+ #[allow(dead_code)]
pub provider_name: String,
}
@@ -220,18 +342,23 @@ pub struct ProxySnapshot {
pub claude_takeover: bool,
pub codex_takeover: bool,
pub gemini_takeover: bool,
+ #[allow(dead_code)]
pub default_cost_multiplier: Option,
pub configured_listen_address: String,
pub configured_listen_port: u16,
pub listen_address: String,
pub listen_port: u16,
pub uptime_seconds: u64,
+ #[allow(dead_code)]
pub total_requests: u64,
pub estimated_input_tokens_total: u64,
pub estimated_output_tokens_total: u64,
+ #[allow(dead_code)]
pub success_rate: Option,
+ #[allow(dead_code)]
pub current_provider: Option,
pub last_error: Option,
+ #[allow(dead_code)]
pub current_app_target: Option,
}
@@ -243,6 +370,7 @@ impl ProxySnapshot {
AppType::Gemini => Some(self.gemini_takeover),
AppType::OpenCode => None,
AppType::OpenClaw => None,
+ AppType::Hermes => None,
}
}
@@ -272,7 +400,7 @@ impl UiData {
let state = load_state()?;
let providers = load_providers(&state, app_type)?;
- let mcp = load_mcp(&state)?;
+ let mcp = load_mcp(&state, app_type)?;
let prompts = load_prompts(&state, app_type)?;
let config = load_config_snapshot(&state, app_type)?;
let skills = load_skills_snapshot()?;
@@ -576,7 +704,17 @@ fn load_providers(state: &AppState, app_type: &AppType) -> Result) -> Vec<(String, Provider)> {
@@ -637,6 +775,11 @@ fn extract_api_url(settings_config: &Value, app_type: &AppType) -> Option settings_config
+ .get("baseUrl")
+ .or_else(|| settings_config.get("base_url"))?
+ .as_str()
+ .map(|s| s.to_string()),
}
}
@@ -703,7 +846,7 @@ fn openclaw_default_model_ref_parts(default_ref: &str) -> Option<(&str, &str)> {
default_ref.split_once('/')
}
-fn load_mcp(state: &AppState) -> Result {
+fn load_mcp(state: &AppState, app_type: &AppType) -> Result {
let servers = McpService::get_all_servers(state)?;
let mut rows = servers
.into_iter()
@@ -712,7 +855,47 @@ fn load_mcp(state: &AppState) -> Result {
rows.sort_by(|a, b| a.id.cmp(&b.id));
- Ok(McpSnapshot { rows })
+ if !matches!(app_type, AppType::Codex) {
+ return Ok(McpSnapshot {
+ rows,
+ ..McpSnapshot::default()
+ });
+ }
+
+ let report = McpService::get_live_drift(state, app_type.clone())?;
+ let mut drift_by_id = HashMap::new();
+ let mut live_only = Vec::new();
+ let mut live_warning = None;
+
+ for entry in report.entries {
+ match entry.kind {
+ McpLiveDriftKind::LiveOnly => {
+ if let Some(live_spec) = entry.live_spec.clone() {
+ live_only.push(McpLiveOnlyRow {
+ id: entry.id,
+ app: entry.app,
+ live_spec,
+ });
+ }
+ }
+ McpLiveDriftKind::LiveInvalid => {
+ live_warning = entry.message;
+ }
+ McpLiveDriftKind::Unknown => {}
+ _ => {
+ drift_by_id.insert(entry.id.clone(), entry);
+ }
+ }
+ }
+
+ live_only.sort_by(|a, b| a.id.cmp(&b.id));
+
+ Ok(McpSnapshot {
+ rows,
+ drift_by_id,
+ live_only,
+ live_warning,
+ })
}
fn load_prompts(state: &AppState, app_type: &AppType) -> Result {
@@ -722,14 +905,19 @@ fn load_prompts(state: &AppState, app_type: &AppType) -> Result>();
+ sort_prompt_rows(&mut rows);
+
+ Ok(PromptsSnapshot { rows })
+}
+
+fn sort_prompt_rows(rows: &mut [PromptRow]) {
rows.sort_by(|a, b| {
- b.prompt
- .updated_at
+ a.prompt
+ .created_at
.unwrap_or(0)
- .cmp(&a.prompt.updated_at.unwrap_or(0))
+ .cmp(&b.prompt.created_at.unwrap_or(0))
+ .then_with(|| a.id.cmp(&b.id))
});
-
- Ok(PromptsSnapshot { rows })
}
fn load_config_snapshot(state: &AppState, app_type: &AppType) -> Result {
@@ -997,6 +1185,7 @@ fn load_skills_snapshot() -> Result {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::prompt::Prompt;
use crate::provider::{AuthBinding, AuthBindingSource, ProviderMeta};
use serde_json::json;
use serial_test::serial;
@@ -1017,9 +1206,11 @@ mod tests {
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
- std::env::set_var("HOME", home);
- std::env::set_var("USERPROFILE", home);
- std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ unsafe {
+ std::env::set_var("HOME", home);
+ std::env::set_var("USERPROFILE", home);
+ std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ }
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
@@ -1033,16 +1224,16 @@ mod tests {
impl Drop for HomeGuard {
fn drop(&mut self) {
match &self.old_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
+ Some(value) => unsafe { std::env::set_var("HOME", value) },
+ None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
- Some(value) => std::env::set_var("USERPROFILE", value),
- None => std::env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
+ None => unsafe { std::env::remove_var("USERPROFILE") },
}
match &self.old_config_dir {
- Some(value) => std::env::set_var("CC_SWITCH_CONFIG_DIR", value),
- None => std::env::remove_var("CC_SWITCH_CONFIG_DIR"),
+ Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
+ None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
@@ -1091,6 +1282,42 @@ mod tests {
}
}
+ fn prompt_row(id: &str, created_at: Option, updated_at: Option) -> PromptRow {
+ PromptRow {
+ id: id.to_string(),
+ prompt: Prompt {
+ id: id.to_string(),
+ name: id.to_string(),
+ content: String::new(),
+ description: None,
+ enabled: false,
+ created_at,
+ updated_at,
+ },
+ }
+ }
+
+ #[test]
+ fn prompt_rows_sort_by_stable_created_time_not_updated_time() {
+ let mut rows = vec![
+ prompt_row("first", Some(1), Some(300)),
+ prompt_row("second", Some(2), Some(200)),
+ prompt_row("third", Some(3), Some(100)),
+ ];
+
+ sort_prompt_rows(&mut rows);
+ let initial_order = rows.iter().map(|row| row.id.as_str()).collect::>();
+ assert_eq!(initial_order, vec!["first", "second", "third"]);
+
+ rows[0].prompt.updated_at = Some(1);
+ rows[1].prompt.updated_at = Some(999);
+ rows[2].prompt.updated_at = Some(500);
+
+ sort_prompt_rows(&mut rows);
+ let refreshed_order = rows.iter().map(|row| row.id.as_str()).collect::>();
+ assert_eq!(refreshed_order, vec!["first", "second", "third"]);
+ }
+
#[test]
#[serial]
fn load_proxy_snapshot_reads_app_auto_failover_state() {
@@ -1745,6 +1972,51 @@ mod tests {
);
}
+ #[test]
+ #[serial]
+ fn load_openclaw_ui_data_accepts_string_default_model() {
+ let _guard = lock_test_home_and_settings();
+ let temp = tempdir().expect("create tempdir");
+ let openclaw_dir = temp.path().join(".openclaw");
+ std::fs::create_dir_all(&openclaw_dir).expect("create openclaw dir");
+ let _home = HomeGuard::set(temp.path());
+ let _settings = SettingsGuard::with_openclaw_dir(&openclaw_dir);
+
+ std::fs::write(
+ openclaw_dir.join("openclaw.json"),
+ r#"{
+ models: {
+ mode: 'merge',
+ providers: {
+ demo: {
+ baseUrl: 'https://api.example.com/v1',
+ models: [{ id: 'model-a' }],
+ },
+ },
+ },
+ agents: {
+ defaults: {
+ model: 'demo/model-a',
+ },
+ },
+}
+"#,
+ )
+ .expect("write openclaw config");
+
+ let data = UiData::load(&AppType::OpenClaw)
+ .expect("string default model should not abort OpenClaw UI loading");
+
+ let row = data
+ .providers
+ .rows
+ .iter()
+ .find(|row| row.id == "demo")
+ .expect("demo provider should be visible");
+ assert!(row.is_default_model);
+ assert_eq!(row.default_model_id.as_deref(), Some("model-a"));
+ }
+
#[test]
#[serial]
fn load_providers_openclaw_skips_modeless_live_provider() {
@@ -2381,4 +2653,125 @@ mod tests {
"Saved Snapshot Name"
);
}
+
+ #[test]
+ #[serial]
+ fn load_mcp_includes_codex_live_drift_and_live_only_rows() {
+ let _guard = lock_test_home_and_settings();
+ let temp = tempdir().expect("create tempdir");
+ let _home = HomeGuard::set(temp.path());
+
+ let codex_dir = temp.path().join(".codex");
+ std::fs::create_dir_all(&codex_dir).expect("create codex dir");
+ std::fs::write(
+ codex_dir.join("config.toml"),
+ r#"[mcp_servers.changed]
+type = "stdio"
+command = "live-command"
+
+[mcp_servers.live_only]
+type = "http"
+url = "https://live.example.com/mcp"
+"#,
+ )
+ .expect("write codex config");
+
+ let state = load_state().expect("load state");
+ {
+ let mut cfg = state.config.write().expect("lock config");
+ cfg.mcp.servers = Some(HashMap::from([(
+ "changed".to_string(),
+ McpServer {
+ id: "changed".to_string(),
+ name: "Changed".to_string(),
+ server: json!({
+ "type": "stdio",
+ "command": "db-command"
+ }),
+ apps: crate::app_config::McpApps {
+ claude: false,
+ codex: true,
+ gemini: false,
+ opencode: false,
+ openclaw: false,
+ hermes: false,
+ },
+ description: None,
+ homepage: None,
+ docs: None,
+ tags: Vec::new(),
+ },
+ )]));
+ }
+
+ let snapshot = load_mcp(&state, &AppType::Codex).expect("load MCP snapshot");
+
+ assert_eq!(snapshot.rows.len(), 1);
+ assert_eq!(
+ snapshot
+ .drift_by_id
+ .get("changed")
+ .expect("changed drift")
+ .kind,
+ crate::services::McpLiveDriftKind::Changed
+ );
+ assert_eq!(snapshot.live_only.len(), 1);
+ assert_eq!(snapshot.live_only[0].id, "live_only");
+ assert_eq!(
+ snapshot.live_only[0].live_spec["url"],
+ "https://live.example.com/mcp"
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn load_mcp_keeps_db_rows_when_codex_live_config_is_invalid() {
+ let _guard = lock_test_home_and_settings();
+ let temp = tempdir().expect("create tempdir");
+ let _home = HomeGuard::set(temp.path());
+
+ let codex_dir = temp.path().join(".codex");
+ std::fs::create_dir_all(&codex_dir).expect("create codex dir");
+ std::fs::write(codex_dir.join("config.toml"), "model_provider = [")
+ .expect("write invalid codex config");
+
+ let state = load_state().expect("load state");
+ {
+ let mut cfg = state.config.write().expect("lock config");
+ cfg.mcp.servers = Some(HashMap::from([(
+ "db_server".to_string(),
+ McpServer {
+ id: "db_server".to_string(),
+ name: "DB Server".to_string(),
+ server: json!({
+ "type": "stdio",
+ "command": "db-command"
+ }),
+ apps: crate::app_config::McpApps {
+ claude: false,
+ codex: true,
+ gemini: false,
+ opencode: false,
+ openclaw: false,
+ hermes: false,
+ },
+ description: None,
+ homepage: None,
+ docs: None,
+ tags: Vec::new(),
+ },
+ )]));
+ }
+
+ let snapshot = load_mcp(&state, &AppType::Codex).expect("load MCP snapshot");
+
+ assert_eq!(snapshot.rows.len(), 1);
+ assert_eq!(snapshot.rows[0].id, "db_server");
+ assert!(
+ snapshot.live_warning.as_deref().is_some_and(|message| {
+ message.contains("config.toml") || message.contains("TOML")
+ }),
+ "live parse warning should be retained"
+ );
+ }
}
diff --git a/src-tauri/src/cli/tui/form.rs b/src-tauri/src/cli/tui/form.rs
index bf11ec71..98d23c7c 100644
--- a/src-tauri/src/cli/tui/form.rs
+++ b/src-tauri/src/cli/tui/form.rs
@@ -14,6 +14,7 @@ mod tests;
#[cfg(test)]
pub(crate) use provider_json::strip_provider_internal_fields;
+pub(crate) use super::text_edit::TextInput;
pub(crate) use codex_config::parse_codex_config_snippet;
pub(crate) use provider_json::claude_hide_attribution_enabled;
pub(crate) use provider_json::strip_common_config_from_settings;
@@ -30,82 +31,6 @@ pub const OPENCLAW_API_PROTOCOLS: [&str; 5] = [
"bedrock-converse-stream",
];
-#[derive(Debug, Clone, Default)]
-pub struct TextInput {
- pub value: String,
- pub cursor: usize,
-}
-
-impl TextInput {
- pub fn new(value: impl Into) -> Self {
- let value = value.into();
- let cursor = value.chars().count();
- Self { value, cursor }
- }
-
- pub fn set(&mut self, value: impl Into) {
- self.value = value.into();
- self.cursor = self.value.chars().count();
- }
-
- pub fn is_blank(&self) -> bool {
- self.value.trim().is_empty()
- }
-
- fn byte_index(line: &str, col: usize) -> usize {
- line.char_indices()
- .nth(col)
- .map(|(i, _)| i)
- .unwrap_or(line.len())
- }
-
- pub fn move_left(&mut self) {
- self.cursor = self.cursor.saturating_sub(1);
- }
-
- pub fn move_right(&mut self) {
- let len = self.value.chars().count();
- self.cursor = (self.cursor + 1).min(len);
- }
-
- pub fn move_home(&mut self) {
- self.cursor = 0;
- }
-
- pub fn move_end(&mut self) {
- self.cursor = self.value.chars().count();
- }
-
- pub fn insert_char(&mut self, c: char) -> bool {
- let idx = Self::byte_index(&self.value, self.cursor);
- self.value.insert(idx, c);
- self.cursor += 1;
- true
- }
-
- pub fn backspace(&mut self) -> bool {
- if self.cursor == 0 || self.value.is_empty() {
- return false;
- }
- let start = Self::byte_index(&self.value, self.cursor.saturating_sub(1));
- let end = Self::byte_index(&self.value, self.cursor);
- self.value.replace_range(start..end, "");
- self.cursor = self.cursor.saturating_sub(1);
- true
- }
-
- pub fn delete(&mut self) -> bool {
- let len = self.value.chars().count();
- if self.value.is_empty() || self.cursor >= len {
- return false;
- }
- let start = Self::byte_index(&self.value, self.cursor);
- let end = Self::byte_index(&self.value, self.cursor + 1);
- self.value.replace_range(start..end, "");
- true
- }
-}
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeminiAuthType {
OAuth,
@@ -199,15 +124,6 @@ pub enum CodexPreviewSection {
Config,
}
-impl CodexPreviewSection {
- pub fn toggle(self) -> Self {
- match self {
- Self::Auth => Self::Config,
- Self::Config => Self::Auth,
- }
- }
-}
-
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormMode {
Add,
@@ -233,8 +149,11 @@ pub enum ProviderAddField {
ClaudeHideAttribution,
CodexBaseUrl,
CodexModel,
+ #[allow(dead_code)]
CodexWireApi,
+ #[allow(dead_code)]
CodexRequiresOpenaiAuth,
+ #[allow(dead_code)]
CodexEnvKey,
CodexApiKey,
GeminiAuthType,
@@ -269,6 +188,8 @@ pub enum McpAddField {
AppCodex,
AppGemini,
AppOpenCode,
+ AppOpenClaw,
+ AppHermes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
diff --git a/src-tauri/src/cli/tui/form/mcp.rs b/src-tauri/src/cli/tui/form/mcp.rs
index c6fcd7ae..b38c7e9c 100644
--- a/src-tauri/src/cli/tui/form/mcp.rs
+++ b/src-tauri/src/cli/tui/form/mcp.rs
@@ -181,6 +181,8 @@ impl McpAddFormState {
McpAddField::AppCodex,
McpAddField::AppGemini,
McpAddField::AppOpenCode,
+ McpAddField::AppOpenClaw,
+ McpAddField::AppHermes,
]);
fields
@@ -198,7 +200,9 @@ impl McpAddFormState {
| McpAddField::AppClaude
| McpAddField::AppCodex
| McpAddField::AppGemini
- | McpAddField::AppOpenCode => None,
+ | McpAddField::AppOpenCode
+ | McpAddField::AppOpenClaw
+ | McpAddField::AppHermes => None,
}
}
@@ -214,7 +218,9 @@ impl McpAddFormState {
| McpAddField::AppClaude
| McpAddField::AppCodex
| McpAddField::AppGemini
- | McpAddField::AppOpenCode => None,
+ | McpAddField::AppOpenCode
+ | McpAddField::AppOpenClaw
+ | McpAddField::AppHermes => None,
}
}
diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs
index 349a205f..7fa09394 100644
--- a/src-tauri/src/cli/tui/form/provider_json.rs
+++ b/src-tauri/src/cli/tui/form/provider_json.rs
@@ -393,6 +393,118 @@ impl ProviderAddFormState {
}
}
+ if models.is_empty() {
+ settings_obj.remove("models");
+ } else {
+ settings_obj.insert("models".to_string(), Value::Array(models));
+ }
+ }
+ AppType::Hermes => {
+ settings_obj.remove("npm");
+ settings_obj.remove("options");
+ settings_obj.remove("api_key");
+ settings_obj.remove("base_url");
+
+ set_or_remove_trimmed(settings_obj, "apiKey", &self.opencode_api_key.value);
+ set_or_remove_trimmed(settings_obj, "baseUrl", &self.opencode_base_url.value);
+
+ let api_value = self.opencode_npm_package.value.trim();
+ settings_obj.insert(
+ "api".to_string(),
+ json!(if api_value.is_empty() {
+ OPENCLAW_DEFAULT_API_PROTOCOL
+ } else {
+ api_value
+ }),
+ );
+
+ let mut headers_obj = match settings_obj.remove("headers") {
+ Some(Value::Object(map)) => map,
+ _ => serde_json::Map::new(),
+ };
+ if self.openclaw_user_agent {
+ headers_obj
+ .entry("User-Agent".to_string())
+ .or_insert_with(|| json!(OPENCLAW_DEFAULT_USER_AGENT));
+ } else {
+ headers_obj.remove("User-Agent");
+ }
+ if headers_obj.is_empty() {
+ settings_obj.remove("headers");
+ } else {
+ settings_obj.insert("headers".to_string(), Value::Object(headers_obj));
+ }
+
+ let mut models = if self.openclaw_models.is_empty() {
+ match settings_obj.remove("models") {
+ Some(Value::Array(items)) => items,
+ _ => Vec::new(),
+ }
+ } else {
+ self.openclaw_models.clone()
+ };
+
+ let model_id = self.openclaw_primary_model_id();
+ match model_id {
+ Some(model_id) => {
+ let mut original_index = self
+ .opencode_model_original_id
+ .as_deref()
+ .and_then(|original_id| openclaw_model_index(&models, original_id));
+
+ if let Some(existing_index) = openclaw_model_index(&models, &model_id) {
+ if Some(existing_index) != original_index {
+ models.remove(existing_index);
+ if let Some(index) = original_index.as_mut() {
+ if existing_index < *index {
+ *index = index.saturating_sub(1);
+ }
+ }
+ }
+ }
+
+ let target_index =
+ original_index.or_else(|| openclaw_model_index(&models, &model_id));
+
+ let mut model_obj = target_index
+ .and_then(|index| models.get(index).cloned())
+ .and_then(|value| value.as_object().cloned())
+ .unwrap_or_default();
+
+ model_obj.insert("id".to_string(), json!(model_id.clone()));
+
+ let model_name = self.opencode_model_name.value.trim();
+ if model_name.is_empty() {
+ model_obj.remove("name");
+ } else {
+ model_obj.insert("name".to_string(), json!(model_name));
+ }
+
+ let context_value = self.opencode_model_context_limit.value.trim();
+ if context_value.is_empty() {
+ model_obj.remove("contextWindow");
+ model_obj.remove("context_window");
+ } else if let Ok(context_window) = context_value.parse::() {
+ model_obj.remove("context_window");
+ model_obj.insert("contextWindow".to_string(), json!(context_window));
+ }
+
+ let updated_model = Value::Object(model_obj);
+ if let Some(index) = target_index {
+ models[index] = updated_model;
+ } else {
+ models.push(updated_model);
+ }
+ }
+ None => {
+ if let Some(original_id) = self.opencode_model_original_id.as_deref() {
+ if let Some(index) = openclaw_model_index(&models, original_id) {
+ models.remove(index);
+ }
+ }
+ }
+ }
+
if models.is_empty() {
settings_obj.remove("models");
} else {
@@ -413,7 +525,10 @@ impl ProviderAddFormState {
if snippet.is_empty() {
return Ok(provider_value);
}
- if matches!(self.app_type, AppType::OpenCode | AppType::OpenClaw) {
+ if matches!(
+ self.app_type,
+ AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
+ ) {
return Ok(provider_value);
}
@@ -567,7 +682,7 @@ pub(crate) fn strip_common_config_from_settings(
)
.map_err(|e| e.to_string())?;
}
- AppType::OpenCode | AppType::OpenClaw => {}
+ AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {}
AppType::Codex => {
*settings_value = ProviderService::remove_common_config_from_settings_for_preview(
app_type,
diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs
index a3176dce..ec2f2cd3 100644
--- a/src-tauri/src/cli/tui/form/provider_state.rs
+++ b/src-tauri/src/cli/tui/form/provider_state.rs
@@ -194,9 +194,16 @@ impl ProviderAddFormState {
fields.push(ProviderAddField::OpenClawUserAgent);
fields.push(ProviderAddField::OpenClawModels);
}
+ AppType::Hermes => {
+ fields.push(ProviderAddField::OpenClawApiProtocol);
+ fields.push(ProviderAddField::OpenCodeApiKey);
+ fields.push(ProviderAddField::OpenCodeBaseUrl);
+ fields.push(ProviderAddField::OpenClawUserAgent);
+ fields.push(ProviderAddField::OpenClawModels);
+ }
}
- if !matches!(self.app_type, AppType::OpenClaw) {
+ if !matches!(self.app_type, AppType::OpenClaw | AppType::Hermes) {
fields.push(ProviderAddField::CommonConfigDivider);
fields.push(ProviderAddField::CommonSnippet);
fields.push(ProviderAddField::IncludeCommonConfig);
diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs
index 49f19d37..ca82ab45 100644
--- a/src-tauri/src/cli/tui/form/provider_state_loading.rs
+++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs
@@ -19,6 +19,7 @@ pub(super) fn populate_form_from_provider(
AppType::Gemini => populate_gemini_form(form, provider),
AppType::OpenCode => populate_opencode_form(form, provider),
AppType::OpenClaw => populate_openclaw_form(form, provider),
+ AppType::Hermes => populate_openclaw_form(form, provider), // Hermes uses same additive-mode fields as OpenClaw
}
}
diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs
index 0bfbf50a..c46ac441 100644
--- a/src-tauri/src/cli/tui/form/provider_templates.rs
+++ b/src-tauri/src/cli/tui/form/provider_templates.rs
@@ -162,6 +162,11 @@ static PROVIDER_TEMPLATE_DEFS_OPENCLAW: [ProviderTemplateDef; 1] = [ProviderTemp
label: "Custom",
}];
+static PROVIDER_TEMPLATE_DEFS_HERMES: [ProviderTemplateDef; 1] = [ProviderTemplateDef {
+ id: ProviderTemplateId::Custom,
+ label: "Custom",
+}];
+
pub(super) fn provider_builtin_template_defs(app_type: &AppType) -> &'static [ProviderTemplateDef] {
match app_type {
AppType::Claude => &PROVIDER_TEMPLATE_DEFS_CLAUDE,
@@ -169,6 +174,7 @@ pub(super) fn provider_builtin_template_defs(app_type: &AppType) -> &'static [Pr
AppType::Gemini => &PROVIDER_TEMPLATE_DEFS_GEMINI,
AppType::OpenCode => &PROVIDER_TEMPLATE_DEFS_OPENCODE,
AppType::OpenClaw => &PROVIDER_TEMPLATE_DEFS_OPENCLAW,
+ AppType::Hermes => &PROVIDER_TEMPLATE_DEFS_HERMES,
}
}
@@ -179,6 +185,7 @@ pub(super) fn provider_sponsor_presets(app_type: &AppType) -> &'static [SponsorP
AppType::Gemini => &SPONSOR_PROVIDER_PRESETS_GEMINI,
AppType::OpenCode => &SPONSOR_PROVIDER_PRESETS_OPENCODE,
AppType::OpenClaw => &SPONSOR_PROVIDER_PRESETS_OPENCLAW,
+ AppType::Hermes => &[],
}
}
@@ -410,6 +417,38 @@ impl ProviderAddFormState {
self.opencode_model_original_id = Some("claude-opus-4-6".to_string());
}
}
+ AppType::Hermes => {
+ if preset.id == "aicodemirror" {
+ self.opencode_api_key.set("");
+ self.opencode_base_url.set(preset.claude_base_url);
+ self.opencode_npm_package.set("anthropic-messages");
+ self.openclaw_user_agent = false;
+ self.openclaw_models = vec![
+ json!({
+ "id": "claude-opus-4-6",
+ "name": "Claude Opus 4.6",
+ "contextWindow": 200000,
+ "cost": {
+ "input": 5,
+ "output": 25,
+ },
+ }),
+ json!({
+ "id": "claude-sonnet-4-6",
+ "name": "Claude Sonnet 4.6",
+ "contextWindow": 200000,
+ "cost": {
+ "input": 3,
+ "output": 15,
+ },
+ }),
+ ];
+ self.opencode_model_id.set("claude-opus-4-6");
+ self.opencode_model_name.set("Claude Opus 4.6");
+ self.opencode_model_context_limit.set("200000");
+ self.opencode_model_original_id = Some("claude-opus-4-6".to_string());
+ }
+ }
}
}
}
diff --git a/src-tauri/src/cli/tui/mod.rs b/src-tauri/src/cli/tui/mod.rs
index 215b740a..d6a5939c 100644
--- a/src-tauri/src/cli/tui/mod.rs
+++ b/src-tauri/src/cli/tui/mod.rs
@@ -8,6 +8,7 @@ mod runtime_systems;
mod terminal;
#[cfg(test)]
mod tests;
+mod text_edit;
mod theme;
mod ui;
@@ -24,12 +25,13 @@ use app::{Action, App, ToastKind};
use runtime_actions::handle_action;
#[cfg(test)]
use runtime_actions::{
- import_mcp_for_current_app_with, open_proxy_help_overlay_with, queue_managed_proxy_action,
- run_external_editor_for_current_editor,
+ apply_preloaded_app_switch, import_mcp_for_current_app_with, open_proxy_help_overlay_with,
+ queue_managed_proxy_action, run_external_editor_for_current_editor,
};
#[cfg(test)]
use runtime_skills::{
- finish_skills_import_with, open_skills_import_picker_with, scan_unmanaged_skills_with,
+ finish_skills_import_with, open_agent_skills_import_picker_with,
+ open_skills_import_picker_with, scan_unmanaged_skills_with,
};
pub(crate) use runtime_systems::build_stream_check_result_lines;
#[cfg(test)]
@@ -70,11 +72,27 @@ where
F: FnOnce(&AppType) -> Result,
{
let app_type = resolve_initial_app_type(app_override);
- let app = App::new(Some(app_type));
+ let mut app = App::new(Some(app_type));
let data = load_data(&app.app_type)?;
+ maybe_open_initial_codex_current_mismatch(&mut app, &data);
Ok((app, data))
}
+fn maybe_open_initial_codex_current_mismatch(app: &mut App, data: &data::UiData) {
+ if !matches!(app.app_type, AppType::Codex) || !matches!(&app.overlay, app::Overlay::None) {
+ return;
+ }
+
+ let Some(mismatch) = data.providers.codex_current_mismatch.clone() else {
+ return;
+ };
+
+ app.overlay = app::Overlay::CodexCurrentProviderMismatch {
+ selected: 0,
+ mismatch,
+ };
+}
+
#[cfg(test)]
fn initialize_app_state_for_test(
app_override: Option,
diff --git a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs
index 3479f5b1..91539828 100644
--- a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs
+++ b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs
@@ -142,6 +142,7 @@ mod tests {
providers: ProvidersSnapshot {
current_id: current_id.to_string(),
rows,
+ codex_current_mismatch: None,
},
..UiData::default()
},
@@ -182,8 +183,10 @@ mod tests {
let lock = lock_test_home_and_settings();
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
- std::env::set_var("HOME", home);
- std::env::set_var("USERPROFILE", home);
+ unsafe {
+ std::env::set_var("HOME", home);
+ std::env::set_var("USERPROFILE", home);
+ }
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
@@ -197,12 +200,12 @@ mod tests {
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
+ Some(value) => unsafe { std::env::set_var("HOME", value) },
+ None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
- Some(value) => std::env::set_var("USERPROFILE", value),
- None => std::env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
+ None => unsafe { std::env::remove_var("USERPROFILE") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
diff --git a/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs b/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs
index 5a9f68a1..8bc35b03 100644
--- a/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs
+++ b/src-tauri/src/cli/tui/runtime_actions/codex_temp_launch.rs
@@ -137,6 +137,7 @@ mod tests {
providers: ProvidersSnapshot {
current_id: current_id.to_string(),
rows,
+ codex_current_mismatch: None,
},
..UiData::default()
},
diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs
index 23a7eaec..d6bcf7d9 100644
--- a/src-tauri/src/cli/tui/runtime_actions/editor.rs
+++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs
@@ -854,9 +854,11 @@ mod tests {
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
- std::env::set_var("HOME", home);
- std::env::set_var("USERPROFILE", home);
- std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ unsafe {
+ std::env::set_var("HOME", home);
+ std::env::set_var("USERPROFILE", home);
+ std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ }
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
@@ -871,16 +873,16 @@ mod tests {
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
+ Some(value) => unsafe { std::env::set_var("HOME", value) },
+ None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
- Some(value) => std::env::set_var("USERPROFILE", value),
- None => std::env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
+ None => unsafe { std::env::remove_var("USERPROFILE") },
}
match &self.old_config_dir {
- Some(value) => std::env::set_var("CC_SWITCH_CONFIG_DIR", value),
- None => std::env::remove_var("CC_SWITCH_CONFIG_DIR"),
+ Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
+ None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs
index 6eb6b3c3..9b76ef98 100644
--- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs
+++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs
@@ -8,7 +8,7 @@ use crate::error::AppError;
use crate::services::McpService;
use super::super::app::visible_prompts;
-use super::super::app::{App, LoadingKind, Overlay, TextViewState, ToastKind};
+use super::super::app::{App, LoadingKind, Overlay, ToastKind};
use super::super::data::{load_proxy_config, load_state, UiData};
use super::super::runtime_systems::{ProxyReq, RequestTracker};
@@ -40,6 +40,7 @@ pub(crate) fn import_mcp_for_current_app(app: &mut App, data: &mut UiData) -> Re
AppType::Gemini => McpService::import_from_gemini(&state),
AppType::OpenCode => McpService::import_from_opencode(&state),
AppType::OpenClaw => Ok(0),
+ AppType::Hermes => Ok(0),
}
},
UiData::load,
@@ -66,6 +67,7 @@ pub(crate) fn app_display_name(app_type: &AppType) -> &'static str {
AppType::Gemini => "Gemini",
AppType::OpenCode => "OpenCode",
AppType::OpenClaw => "OpenClaw",
+ AppType::Hermes => "Hermes",
}
}
@@ -186,15 +188,6 @@ pub(super) fn refresh_openclaw_daily_memory_search_results(app: &mut App) -> Res
Ok(())
}
-pub(super) fn text_view(title: String, content: String) -> Overlay {
- Overlay::TextView(TextViewState {
- title,
- lines: content.lines().map(|s| s.to_string()).collect(),
- scroll: 0,
- action: None,
- })
-}
-
pub(super) fn select_prompt_by_id(app: &mut App, data: &UiData, id: &str) {
let visible = visible_prompts(&app.filter, data);
if let Some(idx) = visible.iter().position(|row| row.id == id) {
@@ -202,9 +195,9 @@ pub(super) fn select_prompt_by_id(app: &mut App, data: &UiData, id: &str) {
return;
}
- if app.filter.active || !app.filter.buffer.trim().is_empty() {
+ if app.filter.active || !app.filter.input.value.trim().is_empty() {
app.filter.active = false;
- app.filter.buffer.clear();
+ app.filter.input.set("");
let visible = visible_prompts(&app.filter, data);
if let Some(idx) = visible.iter().position(|row| row.id == id) {
app.prompt_idx = idx;
diff --git a/src-tauri/src/cli/tui/runtime_actions/mcp.rs b/src-tauri/src/cli/tui/runtime_actions/mcp.rs
index e928d4ce..75f47e79 100644
--- a/src-tauri/src/cli/tui/runtime_actions/mcp.rs
+++ b/src-tauri/src/cli/tui/runtime_actions/mcp.rs
@@ -57,6 +57,7 @@ pub(super) fn set_apps(
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
+ AppType::Hermes,
] {
let next_enabled = apps.is_enabled_for(&app_type);
if before.is_enabled_for(&app_type) == next_enabled {
@@ -101,3 +102,29 @@ pub(super) fn delete(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<(
pub(super) fn import_current_app(ctx: &mut RuntimeActionContext<'_>) -> Result<(), AppError> {
import_mcp_for_current_app(ctx.app, ctx.data)
}
+
+pub(super) fn import_live(
+ ctx: &mut RuntimeActionContext<'_>,
+ app_type: AppType,
+ id: String,
+) -> Result<(), AppError> {
+ let state = load_state()?;
+ McpService::import_live_server(&state, app_type, &id)?;
+ ctx.app
+ .push_toast(texts::tui_toast_mcp_live_imported(), ToastKind::Success);
+ *ctx.data = UiData::load(&ctx.app.app_type)?;
+ Ok(())
+}
+
+pub(super) fn push_db_to_live(
+ ctx: &mut RuntimeActionContext<'_>,
+ app_type: AppType,
+ id: String,
+) -> Result<(), AppError> {
+ let state = load_state()?;
+ McpService::push_db_server_to_live(&state, app_type, &id)?;
+ ctx.app
+ .push_toast(texts::tui_toast_mcp_live_pushed(), ToastKind::Success);
+ *ctx.data = UiData::load(&ctx.app.app_type)?;
+ Ok(())
+}
diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs
index 6e543a4b..522d84b7 100644
--- a/src-tauri/src/cli/tui/runtime_actions/mod.rs
+++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs
@@ -57,7 +57,12 @@ fn normalize_route_for_app(app_type: &AppType, route: &super::route::Route) -> s
}
}
-fn apply_preloaded_app_switch(app: &mut App, data: &mut UiData, next: AppType, next_data: UiData) {
+pub(super) fn apply_preloaded_app_switch(
+ app: &mut App,
+ data: &mut UiData,
+ next: AppType,
+ next_data: UiData,
+) {
app.clear_openclaw_daily_memory_search_state();
app.app_type = next;
let original_route = app.route.clone();
@@ -83,6 +88,7 @@ fn apply_preloaded_app_switch(app: &mut App, data: &mut UiData, next: AppType, n
};
}
*data = next_data;
+ super::maybe_open_initial_codex_current_mismatch(app, data);
app.reset_proxy_activity(
data.proxy.estimated_input_tokens_total,
data.proxy.estimated_output_tokens_total,
@@ -180,11 +186,20 @@ pub(crate) fn handle_action(
Ok(())
}
Action::SkillsToggle { directory, enabled } => skills::toggle(&mut ctx, directory, enabled),
+ Action::SkillsToggleMany {
+ directories,
+ enabled,
+ } => skills::toggle_many(&mut ctx, directories, enabled),
Action::SkillsSetApps { directory, apps } => skills::set_apps(&mut ctx, directory, apps),
+ Action::SkillsSetAppsMany { directories, apps } => {
+ skills::set_apps_many(&mut ctx, directories, apps)
+ }
Action::SkillsInstall { spec } => skills::install(&mut ctx, spec),
Action::SkillsUninstall { directory } => skills::uninstall(&mut ctx, directory),
+ Action::SkillsUninstallMany { directories } => {
+ skills::uninstall_many(&mut ctx, directories)
+ }
Action::SkillsSync { app: scope } => skills::sync(&mut ctx, scope),
- Action::SkillsSetSyncMethod { method } => skills::set_sync_method(&mut ctx, method),
Action::SkillsDiscover { query } => skills::discover(&mut ctx, query),
Action::SkillsRepoAdd { spec } => skills::repo_add(&mut ctx, spec),
Action::SkillsRepoRemove { owner, name } => skills::repo_remove(&mut ctx, owner, name),
@@ -194,17 +209,17 @@ pub(crate) fn handle_action(
enabled,
} => skills::repo_toggle_enabled(&mut ctx, owner, name, enabled),
Action::SkillsOpenImport => skills::open_import(&mut ctx),
- Action::SkillsScanUnmanaged => skills::scan_unmanaged(&mut ctx),
+ Action::SkillsOpenAgentImport => skills::open_agent_import(&mut ctx),
Action::SkillsImportFromApps { directories } => {
skills::import_from_apps(&mut ctx, directories)
}
- Action::EditorDiscard => {
- ctx.app.editor = None;
- Ok(())
+ Action::SkillsImportFromAgent { directories } => {
+ skills::import_from_agent(&mut ctx, directories)
}
Action::EditorOpenExternal => editor::open_external(&mut ctx),
Action::EditorSubmit { submit, content } => editor::submit(&mut ctx, submit, content),
Action::ProviderSwitch { id } => providers::switch(&mut ctx, id),
+ Action::CodexAcceptLiveCurrent { id } => providers::accept_codex_live_current(&mut ctx, id),
Action::ProviderRemoveFromConfig { id } => providers::remove_from_config(&mut ctx, id),
Action::ProviderSetDefaultModel {
provider_id,
@@ -236,6 +251,8 @@ pub(crate) fn handle_action(
Action::McpSetApps { id, apps } => mcp::set_apps(&mut ctx, id, apps),
Action::McpDelete { id } => mcp::delete(&mut ctx, id),
Action::McpImport => mcp::import_current_app(&mut ctx),
+ Action::McpImportLive { app_type, id } => mcp::import_live(&mut ctx, app_type, id),
+ Action::McpPushDbToLive { app_type, id } => mcp::push_db_to_live(&mut ctx, app_type, id),
Action::PromptActivate { id } => prompts::activate(&mut ctx, id),
Action::PromptDeactivate { id } => prompts::deactivate(&mut ctx, id),
Action::PromptRename { id, name } => prompts::rename(&mut ctx, id, name),
@@ -299,7 +316,6 @@ pub(crate) fn handle_action(
);
Ok(())
}
- Action::SetProxyEnabled { enabled } => settings::set_proxy_enabled(&mut ctx, enabled),
Action::SetProxyListenAddress { address } => {
settings::set_proxy_listen_address(&mut ctx, address)
}
@@ -308,8 +324,15 @@ pub(crate) fn handle_action(
settings::set_proxy_auto_failover(&mut ctx, app_type, enabled)
}
Action::SetOpenClawConfigDir { path } => settings::set_openclaw_config_dir(&mut ctx, path),
- Action::SetProxyTakeover { app_type, enabled } => {
- settings::set_proxy_takeover(&mut ctx, app_type, enabled)
+ Action::SetSkillSyncMethod(method) => {
+ crate::services::SkillService::set_sync_method(method)?;
+ ctx.data.skills.sync_method = method;
+ let method_name = texts::tui_skills_sync_method_name(method);
+ ctx.app.push_toast(
+ texts::tui_toast_skills_sync_method_set(method_name),
+ ToastKind::Success,
+ );
+ Ok(())
}
Action::SetManagedProxyForCurrentApp { app_type, enabled } => queue_managed_proxy_action(
ctx.app,
@@ -357,6 +380,7 @@ mod tests {
_lock: TestHomeSettingsLock,
old_home: Option,
old_userprofile: Option,
+ old_tui_config_dir: Option,
old_config_dir: Option,
}
@@ -365,16 +389,21 @@ mod tests {
let lock = lock_test_home_and_settings();
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
+ let old_tui_config_dir = std::env::var_os("CC_SWITCH_TUI_CONFIG_DIR");
let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
- std::env::set_var("HOME", home);
- std::env::set_var("USERPROFILE", home);
- std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ unsafe {
+ std::env::set_var("HOME", home);
+ std::env::set_var("USERPROFILE", home);
+ std::env::set_var("CC_SWITCH_TUI_CONFIG_DIR", home.join(".cc-switch-tui"));
+ std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ }
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
_lock: lock,
old_home,
old_userprofile,
+ old_tui_config_dir,
old_config_dir,
}
}
@@ -383,16 +412,20 @@ mod tests {
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
+ Some(value) => unsafe { std::env::set_var("HOME", value) },
+ None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
- Some(value) => std::env::set_var("USERPROFILE", value),
- None => std::env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
+ None => unsafe { std::env::remove_var("USERPROFILE") },
+ }
+ match &self.old_tui_config_dir {
+ Some(value) => unsafe { std::env::set_var("CC_SWITCH_TUI_CONFIG_DIR", value) },
+ None => unsafe { std::env::remove_var("CC_SWITCH_TUI_CONFIG_DIR") },
}
match &self.old_config_dir {
- Some(value) => std::env::set_var("CC_SWITCH_CONFIG_DIR", value),
- None => std::env::remove_var("CC_SWITCH_CONFIG_DIR"),
+ Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
+ None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
@@ -429,6 +462,12 @@ mod tests {
fs::create_dir_all(&config_dir).expect("create config dir");
fs::write(config_dir.join("config.json"), "{ not valid json }")
.expect("write invalid legacy config");
+
+ let active_config_path = crate::config::get_app_config_path();
+ if let Some(parent) = active_config_path.parent() {
+ fs::create_dir_all(parent).expect("create active config dir");
+ }
+ fs::write(active_config_path, "{ not valid json }").expect("write invalid active config");
}
#[test]
@@ -441,7 +480,7 @@ mod tests {
app.route = Route::ConfigOpenClawTools;
app.route_stack.push(Route::Config);
app.filter.active = true;
- app.filter.buffer = "focus".to_string();
+ app.filter.input.set("focus".to_string());
app.openclaw_daily_memory_search_query = "focus".to_string();
app.daily_memory_idx = 1;
app.openclaw_daily_memory_search_results =
@@ -484,7 +523,7 @@ mod tests {
"route stack should be normalized too so Back does not land on a duplicate config route"
);
assert!(!app.filter.active);
- assert!(app.filter.buffer.is_empty());
+ assert!(app.filter.input.value.is_empty());
assert!(app.openclaw_daily_memory_search_query.is_empty());
assert!(app.openclaw_daily_memory_search_results.is_empty());
assert_eq!(app.daily_memory_idx, 0);
@@ -523,6 +562,7 @@ mod tests {
gemini: true,
opencode: true,
openclaw: true,
+ hermes: false,
})
.expect("save initial visible apps");
@@ -532,12 +572,13 @@ mod tests {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
};
let mut app = App::new(Some(AppType::OpenClaw));
app.route = Route::ConfigOpenClawTools;
app.route_stack.push(Route::Config);
app.filter.active = true;
- app.filter.buffer = "focus".to_string();
+ app.filter.input.set("focus".to_string());
app.openclaw_daily_memory_search_query = "focus".to_string();
app.daily_memory_idx = 1;
app.openclaw_daily_memory_search_results =
@@ -574,7 +615,7 @@ mod tests {
"route stack should normalize the same way as SetAppType"
);
assert!(!app.filter.active);
- assert!(app.filter.buffer.is_empty());
+ assert!(app.filter.input.value.is_empty());
assert!(app.openclaw_daily_memory_search_query.is_empty());
assert!(app.openclaw_daily_memory_search_results.is_empty());
assert_eq!(app.daily_memory_idx, 0);
@@ -591,6 +632,7 @@ mod tests {
gemini: false,
opencode: true,
openclaw: true,
+ hermes: false,
};
crate::settings::set_visible_apps(initial_visible_apps.clone())
.expect("save initial visible apps");
@@ -612,6 +654,7 @@ mod tests {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
},
},
)
@@ -639,6 +682,7 @@ mod tests {
gemini: false,
opencode: true,
openclaw: true,
+ hermes: false,
})
.expect("save initial visible apps");
write_invalid_legacy_config(temp_home.path());
@@ -649,6 +693,7 @@ mod tests {
gemini: false,
opencode: true,
openclaw: false,
+ hermes: false,
};
let mut app = App::new(Some(AppType::Claude));
let mut data = UiData::default();
@@ -674,6 +719,45 @@ mod tests {
));
}
+ #[test]
+ #[serial(home_settings)]
+ fn set_skill_sync_method_persists_and_updates_ui_data() {
+ let temp_home = TempDir::new().expect("create temp home");
+ let _env = EnvGuard::set_home(temp_home.path());
+ crate::settings::set_skill_sync_method(crate::services::skill::SyncMethod::Auto)
+ .expect("seed skill sync method");
+
+ let mut app = App::new(Some(AppType::Claude));
+ let mut data = UiData::default();
+ data.skills.sync_method = crate::services::skill::SyncMethod::Auto;
+
+ run_action(
+ &mut app,
+ &mut data,
+ Action::SetSkillSyncMethod(crate::services::skill::SyncMethod::Copy),
+ )
+ .expect("set skill sync method");
+
+ assert_eq!(
+ crate::settings::get_skill_sync_method(),
+ crate::services::skill::SyncMethod::Copy
+ );
+ assert_eq!(
+ data.skills.sync_method,
+ crate::services::skill::SyncMethod::Copy
+ );
+ assert!(matches!(
+ app.toast.as_ref(),
+ Some(toast)
+ if toast.kind == super::super::app::ToastKind::Success
+ && toast.message == texts::tui_toast_skills_sync_method_set(
+ texts::tui_skills_sync_method_name(
+ crate::services::skill::SyncMethod::Copy
+ )
+ )
+ ));
+ }
+
#[test]
#[serial(home_settings)]
fn set_visible_apps_zero_selection_shows_warning_and_keeps_state_unchanged() {
@@ -685,6 +769,7 @@ mod tests {
gemini: false,
opencode: true,
openclaw: true,
+ hermes: false,
};
crate::settings::set_visible_apps(initial_visible_apps.clone())
.expect("save initial visible apps");
@@ -703,6 +788,7 @@ mod tests {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
},
},
)
diff --git a/src-tauri/src/cli/tui/runtime_actions/providers.rs b/src-tauri/src/cli/tui/runtime_actions/providers.rs
index 885dbe4e..3803226a 100644
--- a/src-tauri/src/cli/tui/runtime_actions/providers.rs
+++ b/src-tauri/src/cli/tui/runtime_actions/providers.rs
@@ -11,6 +11,7 @@ use super::super::app::{ConfirmAction, ConfirmOverlay, Overlay, ToastKind};
use super::super::data::{load_state, UiData};
use super::super::form::ProviderAddField;
use super::super::runtime_systems::{next_model_fetch_request_id, ModelFetchReq, StreamCheckReq};
+use super::super::text_edit::TextInput;
use super::RuntimeActionContext;
fn active_proxy_failover_queue_guard_message() -> &'static str {
@@ -73,9 +74,31 @@ pub(super) fn switch(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<(
do_switch(ctx, id)
}
+pub(super) fn accept_codex_live_current(
+ ctx: &mut RuntimeActionContext<'_>,
+ id: String,
+) -> Result<(), AppError> {
+ let state = load_state()?;
+ ProviderService::accept_codex_live_current_provider(&state, &id)?;
+ *ctx.data = UiData::load(&ctx.app.app_type)?;
+ ctx.app.pending_overlay = None;
+ ctx.app.overlay = Overlay::None;
+ ctx.app.push_toast(
+ crate::t!(
+ "Codex current provider now follows config.toml.",
+ "Codex 当前供应商已跟随 config.toml。"
+ ),
+ ToastKind::Success,
+ );
+ Ok(())
+}
+
pub(super) fn import_live_config(ctx: &mut RuntimeActionContext<'_>) -> Result<(), AppError> {
let state = load_state()?;
let imported = match ctx.app.app_type {
+ crate::app_config::AppType::Codex => {
+ ProviderService::import_codex_providers_from_live(&state)?.imported_any()
+ }
crate::app_config::AppType::OpenCode => {
ProviderService::import_opencode_providers_from_live(&state)? > 0
}
@@ -501,7 +524,7 @@ pub(super) fn model_fetch(
request_id,
field: field.clone(),
claude_idx,
- input: String::new(),
+ input: TextInput::new(""),
query: String::new(),
fetching: true,
models: Vec::new(),
@@ -562,9 +585,11 @@ mod tests {
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
- std::env::set_var("HOME", home);
- std::env::set_var("USERPROFILE", home);
- std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ unsafe {
+ std::env::set_var("HOME", home);
+ std::env::set_var("USERPROFILE", home);
+ std::env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
+ }
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
@@ -579,16 +604,16 @@ mod tests {
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
+ Some(value) => unsafe { std::env::set_var("HOME", value) },
+ None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
- Some(value) => std::env::set_var("USERPROFILE", value),
- None => std::env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
+ None => unsafe { std::env::remove_var("USERPROFILE") },
}
match &self.old_config_dir {
- Some(value) => std::env::set_var("CC_SWITCH_CONFIG_DIR", value),
- None => std::env::remove_var("CC_SWITCH_CONFIG_DIR"),
+ Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
+ None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
diff --git a/src-tauri/src/cli/tui/runtime_actions/settings.rs b/src-tauri/src/cli/tui/runtime_actions/settings.rs
index 165b71d2..fdb6eb98 100644
--- a/src-tauri/src/cli/tui/runtime_actions/settings.rs
+++ b/src-tauri/src/cli/tui/runtime_actions/settings.rs
@@ -2,32 +2,9 @@ use crate::app_config::AppType;
use crate::cli::i18n::texts;
use crate::error::AppError;
-use super::super::data::{load_proxy_config, load_state, UiData};
-use super::helpers::open_proxy_help_overlay_with;
+use super::super::data::{load_state, UiData};
use super::RuntimeActionContext;
-pub(super) fn set_proxy_enabled(
- ctx: &mut RuntimeActionContext<'_>,
- enabled: bool,
-) -> Result<(), AppError> {
- let state = load_state()?;
- let runtime = tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- .map_err(|e| AppError::Message(format!("failed to create async runtime: {e}")))?;
- runtime.block_on(state.proxy_service.set_global_enabled(enabled))?;
- *ctx.data = UiData::load(&ctx.app.app_type)?;
- ctx.app.push_toast(
- if enabled {
- crate::t!("Local proxy enabled.", "本地代理已开启。")
- } else {
- crate::t!("Local proxy disabled.", "本地代理已关闭。")
- },
- super::super::app::ToastKind::Success,
- );
- Ok(())
-}
-
pub(super) fn set_proxy_listen_address(
ctx: &mut RuntimeActionContext<'_>,
address: String,
@@ -121,43 +98,6 @@ pub(super) fn set_openclaw_config_dir(
Ok(())
}
-pub(super) fn set_proxy_takeover(
- ctx: &mut RuntimeActionContext<'_>,
- app_type: AppType,
- enabled: bool,
-) -> Result<(), AppError> {
- let state = load_state()?;
- let runtime = tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- .map_err(|e| AppError::Message(format!("failed to create async runtime: {e}")))?;
-
- let status = runtime.block_on(state.proxy_service.get_status());
- if enabled && !status.running {
- ctx.app.push_toast(
- texts::tui_toast_proxy_takeover_requires_running(),
- super::super::app::ToastKind::Warning,
- );
- return Ok(());
- }
-
- runtime
- .block_on(
- state
- .proxy_service
- .set_takeover_for_app(app_type.as_str(), enabled),
- )
- .map_err(AppError::Message)?;
-
- *ctx.data = UiData::load(&ctx.app.app_type)?;
- open_proxy_help_overlay_with(ctx.app, ctx.data, load_proxy_config)?;
- ctx.app.push_toast(
- texts::tui_toast_proxy_takeover_updated(app_type.as_str(), enabled),
- super::super::app::ToastKind::Success,
- );
- Ok(())
-}
-
pub(super) fn set_visible_apps(
ctx: &mut RuntimeActionContext<'_>,
apps: crate::settings::VisibleApps,
@@ -267,8 +207,10 @@ mod tests {
let lock = lock_test_home_and_settings();
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
- std::env::set_var("HOME", home);
- std::env::set_var("USERPROFILE", home);
+ unsafe {
+ std::env::set_var("HOME", home);
+ std::env::set_var("USERPROFILE", home);
+ }
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
@@ -282,12 +224,12 @@ mod tests {
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
+ Some(value) => unsafe { std::env::set_var("HOME", value) },
+ None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
- Some(value) => std::env::set_var("USERPROFILE", value),
- None => std::env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
+ None => unsafe { std::env::remove_var("USERPROFILE") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
diff --git a/src-tauri/src/cli/tui/runtime_actions/skills.rs b/src-tauri/src/cli/tui/runtime_actions/skills.rs
index 618af206..ca0cf0a7 100644
--- a/src-tauri/src/cli/tui/runtime_actions/skills.rs
+++ b/src-tauri/src/cli/tui/runtime_actions/skills.rs
@@ -1,12 +1,13 @@
use crate::app_config::{AppType, SkillApps};
use crate::cli::i18n::texts;
use crate::error::AppError;
-use crate::services::{skill::SyncMethod, SkillService};
+use crate::services::SkillService;
use super::super::app::{LoadingKind, Overlay, ToastKind};
use super::super::route::Route;
use super::super::runtime_skills::{
- finish_skills_import_with, open_skills_import_picker, parse_repo_spec, scan_unmanaged_skills,
+ finish_skills_import_with, open_agent_skills_import_picker, open_skills_import_picker,
+ parse_repo_spec,
};
use super::RuntimeActionContext;
@@ -24,6 +25,22 @@ pub(super) fn toggle(
Ok(())
}
+pub(super) fn toggle_many(
+ ctx: &mut RuntimeActionContext<'_>,
+ directories: Vec,
+ enabled: bool,
+) -> Result<(), AppError> {
+ for directory in &directories {
+ SkillService::toggle_app(directory, &ctx.app.app_type, enabled)?;
+ }
+ *ctx.data = super::super::data::UiData::load(&ctx.app.app_type)?;
+ ctx.app.push_toast(
+ texts::tui_toast_skills_toggled(directories.len(), enabled),
+ ToastKind::Success,
+ );
+ Ok(())
+}
+
pub(super) fn set_apps(
ctx: &mut RuntimeActionContext<'_>,
directory: String,
@@ -60,6 +77,42 @@ pub(super) fn set_apps(
Ok(())
}
+pub(super) fn set_apps_many(
+ ctx: &mut RuntimeActionContext<'_>,
+ directories: Vec,
+ apps: SkillApps,
+) -> Result<(), AppError> {
+ let mut changed = false;
+ for directory in &directories {
+ let Some(before) = ctx
+ .data
+ .skills
+ .installed
+ .iter()
+ .find(|skill| skill.directory == *directory)
+ .map(|skill| skill.apps.clone())
+ else {
+ continue;
+ };
+
+ for app_type in AppType::all() {
+ let next_enabled = apps.is_enabled_for(&app_type);
+ if before.is_enabled_for(&app_type) == next_enabled {
+ continue;
+ }
+ changed = true;
+ SkillService::toggle_app(directory, &app_type, next_enabled)?;
+ }
+ }
+
+ *ctx.data = super::super::data::UiData::load(&ctx.app.app_type)?;
+ if changed {
+ ctx.app
+ .push_toast(texts::tui_toast_skill_apps_updated(), ToastKind::Success);
+ }
+ Ok(())
+}
+
pub(super) fn install(ctx: &mut RuntimeActionContext<'_>, spec: String) -> Result<(), AppError> {
let Some(tx) = ctx.skills_req_tx else {
return Err(AppError::Message(
@@ -99,27 +152,29 @@ pub(super) fn uninstall(
Ok(())
}
-pub(super) fn sync(
+pub(super) fn uninstall_many(
ctx: &mut RuntimeActionContext<'_>,
- scope: Option,
+ directories: Vec,
) -> Result<(), AppError> {
- SkillService::sync_all_enabled(scope.as_ref())?;
+ for directory in &directories {
+ SkillService::uninstall(directory)?;
+ }
*ctx.data = super::super::data::UiData::load(&ctx.app.app_type)?;
- ctx.app
- .push_toast(texts::tui_toast_skills_synced(), ToastKind::Success);
+ ctx.app.push_toast(
+ texts::tui_toast_skills_uninstalled(directories.len()),
+ ToastKind::Success,
+ );
Ok(())
}
-pub(super) fn set_sync_method(
+pub(super) fn sync(
ctx: &mut RuntimeActionContext<'_>,
- method: SyncMethod,
+ scope: Option,
) -> Result<(), AppError> {
- SkillService::set_sync_method(method)?;
+ SkillService::sync_all_enabled(scope.as_ref())?;
*ctx.data = super::super::data::UiData::load(&ctx.app.app_type)?;
- ctx.app.push_toast(
- texts::tui_toast_skills_sync_method_set(texts::tui_skills_sync_method_name(method)),
- ToastKind::Success,
- );
+ ctx.app
+ .push_toast(texts::tui_toast_skills_synced(), ToastKind::Success);
Ok(())
}
@@ -185,8 +240,8 @@ pub(super) fn open_import(ctx: &mut RuntimeActionContext<'_>) -> Result<(), AppE
open_skills_import_picker(ctx.app)
}
-pub(super) fn scan_unmanaged(ctx: &mut RuntimeActionContext<'_>) -> Result<(), AppError> {
- scan_unmanaged_skills(ctx.app)
+pub(super) fn open_agent_import(ctx: &mut RuntimeActionContext<'_>) -> Result<(), AppError> {
+ open_agent_skills_import_picker(ctx.app)
}
pub(super) fn import_from_apps(
@@ -204,3 +259,19 @@ pub(super) fn import_from_apps(
ctx.app.skills_unmanaged_idx = 0;
Ok(())
}
+
+pub(super) fn import_from_agent(
+ ctx: &mut RuntimeActionContext<'_>,
+ directories: Vec,
+) -> Result<(), AppError> {
+ finish_skills_import_with(
+ ctx.app,
+ ctx.data,
+ || SkillService::import_from_agent(directories),
+ super::super::data::UiData::load,
+ )?;
+ ctx.app.skills_unmanaged_results = SkillService::scan_agent_installed()?;
+ ctx.app.skills_unmanaged_selected.clear();
+ ctx.app.skills_unmanaged_idx = 0;
+ Ok(())
+}
diff --git a/src-tauri/src/cli/tui/runtime_skills.rs b/src-tauri/src/cli/tui/runtime_skills.rs
index d287c45d..617f3c0c 100644
--- a/src-tauri/src/cli/tui/runtime_skills.rs
+++ b/src-tauri/src/cli/tui/runtime_skills.rs
@@ -8,6 +8,7 @@ use crate::services::{skill::SkillRepo, SkillService};
use super::app::{App, Overlay, ToastKind};
use super::data::UiData;
+#[cfg(test)]
pub(crate) fn scan_unmanaged_skills_with(app: &mut App, scan: F) -> Result<(), AppError>
where
F: FnOnce() -> Result, AppError>,
@@ -22,10 +23,6 @@ where
Ok(())
}
-pub(crate) fn scan_unmanaged_skills(app: &mut App) -> Result<(), AppError> {
- scan_unmanaged_skills_with(app, SkillService::scan_unmanaged)
-}
-
pub(crate) fn open_skills_import_picker_with(app: &mut App, scan: F) -> Result<(), AppError>
where
F: FnOnce() -> Result, AppError>,
@@ -57,6 +54,40 @@ pub(crate) fn open_skills_import_picker(app: &mut App) -> Result<(), AppError> {
open_skills_import_picker_with(app, SkillService::scan_unmanaged)
}
+pub(crate) fn open_agent_skills_import_picker_with(
+ app: &mut App,
+ scan: F,
+) -> Result<(), AppError>
+where
+ F: FnOnce() -> Result, AppError>,
+{
+ let skills = scan()?;
+ app.skills_unmanaged_results = skills.clone();
+ app.skills_unmanaged_selected.clear();
+ app.skills_unmanaged_idx = 0;
+
+ if skills.is_empty() {
+ app.overlay = Overlay::None;
+ app.push_toast(texts::skills_no_agent_installed_found(), ToastKind::Info);
+ return Ok(());
+ }
+
+ let selected = skills
+ .iter()
+ .map(|skill| skill.directory.clone())
+ .collect::>();
+ app.overlay = Overlay::SkillsAgentImportPicker {
+ skills,
+ selected_idx: 0,
+ selected,
+ };
+ Ok(())
+}
+
+pub(crate) fn open_agent_skills_import_picker(app: &mut App) -> Result<(), AppError> {
+ open_agent_skills_import_picker_with(app, SkillService::scan_agent_installed)
+}
+
pub(crate) fn finish_skills_import_with(
app: &mut App,
data: &mut UiData,
diff --git a/src-tauri/src/cli/tui/runtime_systems/handlers.rs b/src-tauri/src/cli/tui/runtime_systems/handlers.rs
index a232f3f9..677ad069 100644
--- a/src-tauri/src/cli/tui/runtime_systems/handlers.rs
+++ b/src-tauri/src/cli/tui/runtime_systems/handlers.rs
@@ -1,10 +1,9 @@
use crate::cli::i18n::texts;
use crate::error::AppError;
use crate::services::SyncDecision;
-use crate::settings::{
- get_webdav_sync_settings, set_webdav_sync_settings, webdav_jianguoyun_preset,
- WebDavSyncSettings,
-};
+#[cfg(test)]
+use crate::settings::webdav_jianguoyun_preset;
+use crate::settings::{get_webdav_sync_settings, set_webdav_sync_settings, WebDavSyncSettings};
use super::super::app::{App, ConfirmAction, ConfirmOverlay, LoadingKind, Overlay, ToastKind};
use super::super::data::{load_state, UiData};
@@ -302,7 +301,8 @@ pub(crate) fn handle_webdav_msg(
}
}
}
- WebDavDone::V1Migrated { message: _ } => {
+ WebDavDone::V1Migrated { message } => {
+ let _ = message;
if let Ok(state) = load_state() {
if let Err(e) =
crate::services::provider::ProviderService::sync_current_to_live(
@@ -457,6 +457,7 @@ pub(crate) fn handle_proxy_msg(
Ok(())
}
+#[cfg(test)]
pub(crate) fn apply_webdav_jianguoyun_quick_setup(
username: &str,
password: &str,
diff --git a/src-tauri/src/cli/tui/runtime_systems/mod.rs b/src-tauri/src/cli/tui/runtime_systems/mod.rs
index f49c093b..06a3b293 100644
--- a/src-tauri/src/cli/tui/runtime_systems/mod.rs
+++ b/src-tauri/src/cli/tui/runtime_systems/mod.rs
@@ -2,6 +2,7 @@ mod handlers;
mod types;
mod workers;
+#[cfg(test)]
#[cfg(test)]
pub(crate) use handlers::{apply_webdav_jianguoyun_quick_setup, update_webdav_last_error_with};
pub(crate) use handlers::{
diff --git a/src-tauri/src/cli/tui/runtime_systems/types.rs b/src-tauri/src/cli/tui/runtime_systems/types.rs
index a27c6acb..9032d6c2 100644
--- a/src-tauri/src/cli/tui/runtime_systems/types.rs
+++ b/src-tauri/src/cli/tui/runtime_systems/types.rs
@@ -13,6 +13,18 @@ use crate::services::{EndpointLatency, HealthStatus, StreamCheckResult, SyncDeci
use super::super::form::ProviderAddField;
+const KNOWN_COMPAT_SUFFIXES: &[&str] = &[
+ "/api/claudecode",
+ "/api/anthropic",
+ "/apps/anthropic",
+ "/api/coding",
+ "/claudecode",
+ "/anthropic",
+ "/step_plan",
+ "/coding",
+ "/claude",
+];
+
pub(crate) fn next_model_fetch_request_id() -> u64 {
static NEXT_MODEL_FETCH_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
NEXT_MODEL_FETCH_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
@@ -260,7 +272,7 @@ pub(crate) fn build_model_fetch_candidate_urls(
}
let append_models = format!("{base}/models");
- let append_v1_models = if base.ends_with("/v1") || base.ends_with("/v1beta") {
+ let append_versioned_models = if base.ends_with("/v1") || base.ends_with("/v1beta") {
None
} else {
Some(format!("{base}/v1/models"))
@@ -269,14 +281,25 @@ pub(crate) fn build_model_fetch_candidate_urls(
let mut urls: Vec = Vec::new();
match strategy {
ModelFetchStrategy::Anthropic => {
- if let Some(v1) = append_v1_models.as_ref() {
- urls.push(v1.clone());
+ if let Some(versioned) = append_versioned_models.as_ref() {
+ urls.push(versioned.clone());
+ } else {
+ urls.push(append_models.clone());
+ }
+
+ if let Some(stripped) = strip_compat_suffix(base) {
+ let root = stripped.trim_end_matches('/');
+ if !root.is_empty() && root.contains("://") {
+ urls.push(format!("{root}/v1/models"));
+ urls.push(format!("{root}/models"));
+ }
+ } else if append_versioned_models.is_some() {
+ urls.push(append_models);
}
- urls.push(append_models);
}
ModelFetchStrategy::Bearer | ModelFetchStrategy::GoogleApiKey => {
urls.push(append_models);
- if let Some(v1) = append_v1_models.as_ref() {
+ if let Some(v1) = append_versioned_models.as_ref() {
urls.push(v1.clone());
}
}
@@ -287,6 +310,15 @@ pub(crate) fn build_model_fetch_candidate_urls(
urls
}
+fn strip_compat_suffix(base: &str) -> Option<&str> {
+ let lower = base.to_ascii_lowercase();
+ KNOWN_COMPAT_SUFFIXES.iter().find_map(|suffix| {
+ lower
+ .ends_with(suffix)
+ .then(|| &base[..base.len() - suffix.len()])
+ })
+}
+
pub(crate) fn parse_model_ids_from_response(payload: &Value) -> Vec {
let mut out: Vec = Vec::new();
@@ -356,8 +388,14 @@ pub(crate) async fn fetch_provider_models_for_tui(
match req.send().await {
Ok(resp) => {
- if !resp.status().is_success() {
- last_err = format!("HTTP {} ({url})", resp.status());
+ let status = resp.status();
+ if !status.is_success() {
+ last_err = format!("HTTP {status} ({url})");
+ if status != reqwest::StatusCode::NOT_FOUND
+ && status != reqwest::StatusCode::METHOD_NOT_ALLOWED
+ {
+ return Err(last_err);
+ }
continue;
}
match resp.json::().await {
diff --git a/src-tauri/src/cli/tui/tests.rs b/src-tauri/src/cli/tui/tests.rs
index 9a0a7dde..0584c7ee 100644
--- a/src-tauri/src/cli/tui/tests.rs
+++ b/src-tauri/src/cli/tui/tests.rs
@@ -7,7 +7,7 @@ use serde_json::json;
use serial_test::serial;
use tempfile::TempDir;
-use super::app::{App, LoadingKind, Overlay, ToastKind};
+use super::app::{Action, App, LoadingKind, Overlay, ToastKind};
use super::data::UiData;
use super::form::ProviderAddField;
use super::*;
@@ -28,8 +28,10 @@ impl EnvGuard {
let lock = lock_test_home_and_settings();
let old_home = std::env::var_os("HOME");
let old_userprofile = std::env::var_os("USERPROFILE");
- std::env::set_var("HOME", home);
- std::env::set_var("USERPROFILE", home);
+ unsafe {
+ std::env::set_var("HOME", home);
+ std::env::set_var("USERPROFILE", home);
+ }
set_test_home_override(Some(home));
crate::settings::reload_test_settings();
Self {
@@ -43,12 +45,12 @@ impl EnvGuard {
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old_home {
- Some(value) => std::env::set_var("HOME", value),
- None => std::env::remove_var("HOME"),
+ Some(value) => unsafe { std::env::set_var("HOME", value) },
+ None => unsafe { std::env::remove_var("HOME") },
}
match &self.old_userprofile {
- Some(value) => std::env::set_var("USERPROFILE", value),
- None => std::env::remove_var("USERPROFILE"),
+ Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
+ None => unsafe { std::env::remove_var("USERPROFILE") },
}
set_test_home_override(self.old_home.as_deref().map(Path::new));
crate::settings::reload_test_settings();
@@ -78,6 +80,75 @@ fn tui_tick_rate_returns_to_200ms() {
assert_eq!(TUI_TICK_RATE, std::time::Duration::from_millis(200));
}
+#[test]
+fn initialize_codex_opens_current_provider_mismatch_picker() {
+ let (app, _data) = initialize_app_state_for_test(Some(AppType::Codex), |_| {
+ let mut data = UiData::default();
+ data.providers.codex_current_mismatch =
+ Some(crate::services::provider::CodexCurrentProviderMismatch {
+ stored_provider_id: "stored-current".to_string(),
+ stored_provider_name: "zhima-cx".to_string(),
+ live_provider_id: "live-current".to_string(),
+ live_provider_name: "zhima-fuli".to_string(),
+ live_model_provider_key: "zhima-fuli".to_string(),
+ });
+ Ok(data)
+ })
+ .expect("initialize app state");
+
+ assert!(matches!(
+ app.overlay,
+ Overlay::CodexCurrentProviderMismatch { selected: 0, .. }
+ ));
+}
+
+#[test]
+fn switching_to_codex_opens_current_provider_mismatch_picker() {
+ let mut app = App::new(Some(AppType::Claude));
+ let mut data = UiData::default();
+ let mut next_data = UiData::default();
+ next_data.providers.codex_current_mismatch =
+ Some(crate::services::provider::CodexCurrentProviderMismatch {
+ stored_provider_id: "stored-current".to_string(),
+ stored_provider_name: "zhima-cx".to_string(),
+ live_provider_id: "live-current".to_string(),
+ live_provider_name: "zhima-fuli".to_string(),
+ live_model_provider_key: "zhima-fuli".to_string(),
+ });
+
+ apply_preloaded_app_switch(&mut app, &mut data, AppType::Codex, next_data);
+
+ assert!(matches!(
+ app.overlay,
+ Overlay::CodexCurrentProviderMismatch { selected: 0, .. }
+ ));
+}
+
+#[test]
+fn codex_current_mismatch_picker_enter_accepts_live_by_default() {
+ let mut app = App::new(Some(AppType::Codex));
+ app.overlay = Overlay::CodexCurrentProviderMismatch {
+ selected: 0,
+ mismatch: crate::services::provider::CodexCurrentProviderMismatch {
+ stored_provider_id: "stored-current".to_string(),
+ stored_provider_name: "zhima-cx".to_string(),
+ live_provider_id: "live-current".to_string(),
+ live_provider_name: "zhima-fuli".to_string(),
+ live_model_provider_key: "zhima-fuli".to_string(),
+ },
+ };
+
+ let action = app.on_key(
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
+ &UiData::default(),
+ );
+
+ assert!(matches!(
+ action,
+ Action::CodexAcceptLiveCurrent { id } if id == "live-current"
+ ));
+}
+
#[test]
fn skills_scan_unmanaged_uses_info_toast_kind() {
let mut app = App::new(Some(AppType::OpenCode));
@@ -115,6 +186,32 @@ fn opening_skills_import_picker_selects_all_by_default() {
));
}
+#[test]
+fn opening_agent_skills_import_picker_selects_all_by_default() {
+ let mut app = App::new(Some(AppType::Claude));
+
+ open_agent_skills_import_picker_with(&mut app, || {
+ Ok(vec![crate::services::skill::UnmanagedSkill {
+ directory: "agent-skill".to_string(),
+ name: "Agent Skill".to_string(),
+ description: Some("A local agent skill".to_string()),
+ found_in: vec!["agents".to_string()],
+ }])
+ })
+ .expect("agent import picker should open");
+
+ assert!(matches!(
+ &app.overlay,
+ Overlay::SkillsAgentImportPicker {
+ skills,
+ selected_idx: 0,
+ selected,
+ } if skills.len() == 1
+ && skills[0].directory == "agent-skill"
+ && selected.contains("agent-skill")
+ ));
+}
+
#[test]
fn skills_import_from_apps_uses_info_toast_kind() {
let mut app = App::new(Some(AppType::OpenCode));
@@ -605,6 +702,22 @@ fn model_fetch_candidate_urls_prefers_v1_for_anthropic_base() {
);
}
+#[test]
+fn model_fetch_candidate_urls_strip_anthropic_compat_suffix() {
+ let urls = build_model_fetch_candidate_urls(
+ "https://api.deepseek.com/anthropic",
+ ModelFetchStrategy::Anthropic,
+ );
+ assert_eq!(
+ urls,
+ vec![
+ "https://api.deepseek.com/anthropic/v1/models".to_string(),
+ "https://api.deepseek.com/v1/models".to_string(),
+ "https://api.deepseek.com/models".to_string(),
+ ]
+ );
+}
+
#[test]
fn model_fetch_candidate_urls_for_gemini_v1beta_keeps_models_endpoint() {
let urls = build_model_fetch_candidate_urls(
@@ -628,6 +741,7 @@ fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_
gemini: false,
opencode: true,
openclaw: true,
+ hermes: false,
})
.expect("save visible apps");
diff --git a/src-tauri/src/cli/tui/text_edit.rs b/src-tauri/src/cli/tui/text_edit.rs
new file mode 100644
index 00000000..3d0571a3
--- /dev/null
+++ b/src-tauri/src/cli/tui/text_edit.rs
@@ -0,0 +1,379 @@
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum TextEditCommand {
+ MoveLeft,
+ MoveRight,
+ MoveLineStart,
+ MoveLineEnd,
+ MoveWordLeft,
+ MoveWordRight,
+ DeleteBackward,
+ DeleteForward,
+ DeleteToLineStart,
+ DeleteToLineEnd,
+ DeleteWordBackward,
+ Insert(char),
+}
+
+impl TextEditCommand {
+ pub(crate) fn from_key(key: KeyEvent) -> Option {
+ let control = key.modifiers.contains(KeyModifiers::CONTROL);
+ let alt = key.modifiers.contains(KeyModifiers::ALT);
+
+ if control {
+ return match key.code {
+ KeyCode::Char('a' | 'A') => Some(Self::MoveLineStart),
+ KeyCode::Char('b' | 'B') => Some(Self::MoveLeft),
+ KeyCode::Char('d' | 'D') => Some(Self::DeleteForward),
+ KeyCode::Char('e' | 'E') => Some(Self::MoveLineEnd),
+ KeyCode::Char('f' | 'F') => Some(Self::MoveRight),
+ KeyCode::Char('k' | 'K') => Some(Self::DeleteToLineEnd),
+ KeyCode::Char('u' | 'U') => Some(Self::DeleteToLineStart),
+ KeyCode::Char('w' | 'W') => Some(Self::DeleteWordBackward),
+ _ => None,
+ };
+ }
+
+ if alt {
+ return match key.code {
+ KeyCode::Backspace => Some(Self::DeleteWordBackward),
+ KeyCode::Char('b' | 'B') => Some(Self::MoveWordLeft),
+ KeyCode::Char('f' | 'F') => Some(Self::MoveWordRight),
+ _ => None,
+ };
+ }
+
+ match key.code {
+ KeyCode::Left => Some(Self::MoveLeft),
+ KeyCode::Right => Some(Self::MoveRight),
+ KeyCode::Home => Some(Self::MoveLineStart),
+ KeyCode::End => Some(Self::MoveLineEnd),
+ KeyCode::Backspace => Some(Self::DeleteBackward),
+ KeyCode::Delete => Some(Self::DeleteForward),
+ KeyCode::Char(c) if !c.is_control() => Some(Self::Insert(c)),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub(crate) struct TextInputPolicy {
+ pub max_chars: Option,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct TextInputEdit {
+ pub changed: bool,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct TextInput {
+ pub value: String,
+ pub cursor: usize,
+}
+
+impl TextInput {
+ pub fn new(value: impl Into) -> Self {
+ let value = value.into();
+ let cursor = value.chars().count();
+ Self { value, cursor }
+ }
+
+ pub fn set(&mut self, value: impl Into) {
+ self.value = value.into();
+ self.cursor = self.value.chars().count();
+ }
+
+ pub fn is_blank(&self) -> bool {
+ self.value.trim().is_empty()
+ }
+
+ pub(crate) fn len_chars(&self) -> usize {
+ self.value.chars().count()
+ }
+
+ pub(crate) fn byte_index(line: &str, col: usize) -> usize {
+ line.char_indices()
+ .nth(col)
+ .map(|(i, _)| i)
+ .unwrap_or(line.len())
+ }
+
+ pub(crate) fn apply_key(&mut self, key: KeyEvent) -> Option {
+ self.apply_key_with_policy(key, TextInputPolicy::default())
+ }
+
+ pub(crate) fn apply_key_with_policy(
+ &mut self,
+ key: KeyEvent,
+ policy: TextInputPolicy,
+ ) -> Option {
+ let command = TextEditCommand::from_key(key)?;
+ Some(TextInputEdit {
+ changed: self.apply_command(command, policy),
+ })
+ }
+
+ pub(crate) fn apply_command(
+ &mut self,
+ command: TextEditCommand,
+ policy: TextInputPolicy,
+ ) -> bool {
+ self.clamp_cursor();
+ match command {
+ TextEditCommand::MoveLeft => self.move_left(),
+ TextEditCommand::MoveRight => self.move_right(),
+ TextEditCommand::MoveLineStart => self.move_home(),
+ TextEditCommand::MoveLineEnd => self.move_end(),
+ TextEditCommand::MoveWordLeft => self.move_word_left(),
+ TextEditCommand::MoveWordRight => self.move_word_right(),
+ TextEditCommand::DeleteBackward => self.backspace(),
+ TextEditCommand::DeleteForward => self.delete(),
+ TextEditCommand::DeleteToLineStart => self.delete_to_line_start(),
+ TextEditCommand::DeleteToLineEnd => self.delete_to_line_end(),
+ TextEditCommand::DeleteWordBackward => self.delete_word_backward(),
+ TextEditCommand::Insert(c) => {
+ if policy
+ .max_chars
+ .is_some_and(|max_chars| self.len_chars() >= max_chars)
+ {
+ false
+ } else {
+ self.insert_char(c)
+ }
+ }
+ }
+ }
+
+ fn clamp_cursor(&mut self) {
+ self.cursor = self.cursor.min(self.len_chars());
+ }
+
+ pub fn move_left(&mut self) -> bool {
+ let before = self.cursor;
+ self.cursor = self.cursor.saturating_sub(1);
+ self.cursor != before
+ }
+
+ pub fn move_right(&mut self) -> bool {
+ let before = self.cursor;
+ let len = self.len_chars();
+ self.cursor = (self.cursor + 1).min(len);
+ self.cursor != before
+ }
+
+ pub fn move_home(&mut self) -> bool {
+ let before = self.cursor;
+ self.cursor = 0;
+ self.cursor != before
+ }
+
+ pub fn move_end(&mut self) -> bool {
+ let before = self.cursor;
+ self.cursor = self.len_chars();
+ self.cursor != before
+ }
+
+ pub(crate) fn move_word_left(&mut self) -> bool {
+ let before = self.cursor;
+ self.cursor = previous_word_boundary(&self.value, self.cursor);
+ self.cursor != before
+ }
+
+ pub(crate) fn move_word_right(&mut self) -> bool {
+ let before = self.cursor;
+ self.cursor = next_word_boundary(&self.value, self.cursor);
+ self.cursor != before
+ }
+
+ pub fn insert_char(&mut self, c: char) -> bool {
+ let idx = Self::byte_index(&self.value, self.cursor);
+ self.value.insert(idx, c);
+ self.cursor += 1;
+ true
+ }
+
+ pub fn backspace(&mut self) -> bool {
+ if self.cursor == 0 || self.value.is_empty() {
+ return false;
+ }
+ let start = Self::byte_index(&self.value, self.cursor.saturating_sub(1));
+ let end = Self::byte_index(&self.value, self.cursor);
+ self.value.replace_range(start..end, "");
+ self.cursor = self.cursor.saturating_sub(1);
+ true
+ }
+
+ pub fn delete(&mut self) -> bool {
+ let len = self.len_chars();
+ if self.value.is_empty() || self.cursor >= len {
+ return false;
+ }
+ let start = Self::byte_index(&self.value, self.cursor);
+ let end = Self::byte_index(&self.value, self.cursor + 1);
+ self.value.replace_range(start..end, "");
+ true
+ }
+
+ pub(crate) fn delete_to_line_start(&mut self) -> bool {
+ if self.cursor == 0 {
+ return false;
+ }
+ let end = Self::byte_index(&self.value, self.cursor);
+ self.value.replace_range(0..end, "");
+ self.cursor = 0;
+ true
+ }
+
+ pub(crate) fn delete_to_line_end(&mut self) -> bool {
+ let len = self.len_chars();
+ if self.cursor >= len {
+ return false;
+ }
+ let start = Self::byte_index(&self.value, self.cursor);
+ self.value.replace_range(start.., "");
+ true
+ }
+
+ pub(crate) fn delete_word_backward(&mut self) -> bool {
+ let start_cursor = previous_word_boundary(&self.value, self.cursor);
+ if start_cursor == self.cursor {
+ return false;
+ }
+ let start = Self::byte_index(&self.value, start_cursor);
+ let end = Self::byte_index(&self.value, self.cursor);
+ self.value.replace_range(start..end, "");
+ self.cursor = start_cursor;
+ true
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum CharKind {
+ Whitespace,
+ Word,
+ Other,
+}
+
+fn char_kind(c: char) -> CharKind {
+ if c.is_whitespace() {
+ CharKind::Whitespace
+ } else if c.is_alphanumeric() || c == '_' {
+ CharKind::Word
+ } else {
+ CharKind::Other
+ }
+}
+
+pub(crate) fn previous_word_boundary(text: &str, cursor: usize) -> usize {
+ let chars = text.chars().collect::>();
+ let mut idx = cursor.min(chars.len());
+
+ while idx > 0 && char_kind(chars[idx - 1]) == CharKind::Whitespace {
+ idx -= 1;
+ }
+
+ if idx == 0 {
+ return 0;
+ }
+
+ let target = char_kind(chars[idx - 1]);
+ while idx > 0 && char_kind(chars[idx - 1]) == target {
+ idx -= 1;
+ }
+
+ idx
+}
+
+pub(crate) fn next_word_boundary(text: &str, cursor: usize) -> usize {
+ let chars = text.chars().collect::>();
+ let mut idx = cursor.min(chars.len());
+
+ while idx < chars.len() && char_kind(chars[idx]) == CharKind::Whitespace {
+ idx += 1;
+ }
+
+ if idx >= chars.len() {
+ return chars.len();
+ }
+
+ let target = char_kind(chars[idx]);
+ while idx < chars.len() && char_kind(chars[idx]) == target {
+ idx += 1;
+ }
+
+ idx
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn ctrl(code: KeyCode) -> KeyEvent {
+ KeyEvent::new(code, KeyModifiers::CONTROL)
+ }
+
+ fn alt(code: KeyCode) -> KeyEvent {
+ KeyEvent::new(code, KeyModifiers::ALT)
+ }
+
+ #[test]
+ fn readline_line_movement_and_deletion_work() {
+ let mut input = TextInput::new("alpha beta");
+ input.apply_key(ctrl(KeyCode::Char('a')));
+ assert_eq!(input.cursor, 0);
+
+ input.apply_key(ctrl(KeyCode::Char('e')));
+ assert_eq!(input.cursor, "alpha beta".chars().count());
+
+ input.apply_key(ctrl(KeyCode::Char('w')));
+ assert_eq!(input.value, "alpha ");
+ assert_eq!(input.cursor, "alpha ".chars().count());
+
+ input.apply_key(ctrl(KeyCode::Char('u')));
+ assert_eq!(input.value, "");
+ assert_eq!(input.cursor, 0);
+ }
+
+ #[test]
+ fn word_movement_handles_punctuation_and_unicode() {
+ let mut input = TextInput::new("你好 model-name 🚀");
+
+ input.apply_key(alt(KeyCode::Char('b')));
+ assert_eq!(input.cursor, "你好 model-name ".chars().count());
+
+ input.apply_key(alt(KeyCode::Char('b')));
+ assert_eq!(input.cursor, "你好 model-".chars().count());
+
+ input.apply_key(alt(KeyCode::Char('f')));
+ assert_eq!(input.cursor, "你好 model-name".chars().count());
+ }
+
+ #[test]
+ fn max_chars_policy_handles_insert_without_changing() {
+ let mut input = TextInput::new("abc");
+ let edit = input
+ .apply_key_with_policy(
+ KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
+ TextInputPolicy { max_chars: Some(3) },
+ )
+ .expect("printable input should be handled");
+
+ assert!(!edit.changed);
+ assert_eq!(input.value, "abc");
+ }
+
+ #[test]
+ fn apply_command_clamps_external_cursor_state() {
+ let mut input = TextInput {
+ value: "abc".to_string(),
+ cursor: 99,
+ };
+
+ input.apply_key(ctrl(KeyCode::Char('w')));
+
+ assert_eq!(input.value, "");
+ assert_eq!(input.cursor, 0);
+ }
+}
diff --git a/src-tauri/src/cli/tui/theme.rs b/src-tauri/src/cli/tui/theme.rs
index 69131619..eb91a3ad 100644
--- a/src-tauri/src/cli/tui/theme.rs
+++ b/src-tauri/src/cli/tui/theme.rs
@@ -184,6 +184,7 @@ fn accent_rgb(app: &AppType) -> (u8, u8, u8) {
AppType::Gemini => DRACULA_PINK,
AppType::OpenCode => DRACULA_ORANGE,
AppType::OpenClaw => OPENCLAW_CORAL,
+ AppType::Hermes => DRACULA_YELLOW,
}
}
diff --git a/src-tauri/src/cli/tui/ui.rs b/src-tauri/src/cli/tui/ui.rs
index 74c61816..047c58e8 100644
--- a/src-tauri/src/cli/tui/ui.rs
+++ b/src-tauri/src/cli/tui/ui.rs
@@ -21,7 +21,7 @@ use super::{
app::{
App, ConfigItem, ConfirmAction, Focus, LoadingKind, Overlay, ToastKind, WebDavConfigItem,
},
- data::{McpRow, ProviderRow, UiData},
+ data::{McpDisplayRow, ProviderRow, UiData},
form::{
CodexPreviewSection, FormFocus, FormState, GeminiAuthType, McpAddField, ProviderAddField,
},
@@ -167,7 +167,7 @@ fn render_content(
}
fn split_filter_area(area: Rect, app: &App) -> (Option, Rect) {
- let show = app.filter.active || !app.filter.buffer.trim().is_empty();
+ let show = app.filter.active || !app.filter.input.value.trim().is_empty();
if !show {
return (None, area);
}
@@ -221,11 +221,11 @@ fn render_filter_bar(frame: &mut Frame<'_>, app: &App, area: Rect, theme: &super
let input_inner = input_block.inner(inner);
frame.render_widget(input_block, inner);
- let available = input_inner.width as usize;
- let full = app.filter.buffer.clone();
- let cursor = full.chars().count();
- let start = cursor.saturating_sub(available);
- let visible = full.chars().skip(start).take(available).collect::();
+ let (visible, cursor_x) = visible_text_window(
+ &app.filter.input.value,
+ app.filter.input.cursor,
+ input_inner.width as usize,
+ );
frame.render_widget(
Paragraph::new(Line::from(Span::raw(visible))).wrap(Wrap { trim: false }),
@@ -233,7 +233,7 @@ fn render_filter_bar(frame: &mut Frame<'_>, app: &App, area: Rect, theme: &super
);
if app.filter.active {
- let cursor_x = input_inner.x + (cursor.saturating_sub(start) as u16);
+ let cursor_x = input_inner.x + cursor_x.min(input_inner.width.saturating_sub(1));
let cursor_y = input_inner.y;
frame.set_cursor_position((cursor_x, cursor_y));
}
diff --git a/src-tauri/src/cli/tui/ui/chrome.rs b/src-tauri/src/cli/tui/ui/chrome.rs
index f604cb1d..f9c1dcda 100644
--- a/src-tauri/src/cli/tui/ui/chrome.rs
+++ b/src-tauri/src/cli/tui/ui/chrome.rs
@@ -333,18 +333,16 @@ pub(super) fn render_footer(
} else {
if theme.no_color {
let proxy_segment = if proxy_action_available {
- format!(" P {}", proxy_footer_label)
+ format!("P {} ", proxy_footer_label)
} else {
String::new()
};
vec![Span::styled(
format!(
- "{} {} {} {}{}",
- texts::tui_footer_group_nav(),
+ "{} {}{}",
texts::tui_footer_nav_keys(),
- texts::tui_footer_group_actions(),
- texts::tui_footer_action_keys_global(),
proxy_segment,
+ texts::tui_footer_action_keys_global(),
),
Style::default(),
)]
@@ -353,14 +351,6 @@ pub(super) fn render_footer(
let act_bg = super::theme::terminal_palette_color((248, 248, 248)); // #F8F8F8
let nav_fg = super::theme::terminal_palette_color((255, 255, 255));
let act_fg = super::theme::terminal_palette_color((108, 108, 108));
- let nav_label_style = Style::default()
- .fg(nav_fg)
- .bg(nav_bg)
- .add_modifier(Modifier::BOLD);
- let act_label_style = Style::default()
- .fg(act_fg)
- .bg(act_bg)
- .add_modifier(Modifier::BOLD);
let nav_key_style = Style::default()
.fg(nav_fg)
.bg(nav_bg)
@@ -398,12 +388,10 @@ pub(super) fn render_footer(
let mut act_items = act_items_base.to_vec();
if proxy_action_available {
- act_items.push(("P", proxy_footer_label));
+ act_items.insert(0, ("P", proxy_footer_label));
}
let mut v = Vec::new();
- // NAV block
- v.push(Span::styled(" NAV ", nav_label_style));
for (i, (key, desc)) in nav_items.iter().enumerate() {
if i > 0 {
v.push(nav_sep.clone());
@@ -414,8 +402,6 @@ pub(super) fn render_footer(
v.push(Span::styled(" ", nav_desc_style));
// gap between blocks
v.push(Span::raw(" "));
- // ACT block
- v.push(Span::styled(" ACT ", act_label_style));
for (i, (key, desc)) in act_items.iter().enumerate() {
if i > 0 {
v.push(act_sep.clone());
diff --git a/src-tauri/src/cli/tui/ui/config.rs b/src-tauri/src/cli/tui/ui/config.rs
index 01a1d58f..0a763abe 100644
--- a/src-tauri/src/cli/tui/ui/config.rs
+++ b/src-tauri/src/cli/tui/ui/config.rs
@@ -393,45 +393,6 @@ fn pad_display_width(text: &str, width: usize) -> String {
format!("{text}{}", " ".repeat(width - used))
}
-fn compact_two_column_lines(lines: &[String], total_width: u16) -> Option> {
- if lines.len() != 4 {
- return None;
- }
-
- let gap = 4usize;
- let total_width = total_width as usize;
- let left_width = lines
- .iter()
- .step_by(2)
- .map(|line| UnicodeWidthStr::width(line.as_str()))
- .max()
- .unwrap_or(0);
- let right_width = lines
- .iter()
- .skip(1)
- .step_by(2)
- .map(|line| UnicodeWidthStr::width(line.as_str()))
- .max()
- .unwrap_or(0);
-
- if left_width + gap + right_width > total_width {
- return None;
- }
-
- Some(vec![
- format!(
- "{}{}",
- pad_display_width(&lines[0], left_width + gap),
- lines[1]
- ),
- format!(
- "{}{}",
- pad_display_width(&lines[2], left_width + gap),
- lines[3]
- ),
- ])
-}
-
struct OpenClawEnvStyledRow {
plain_text: String,
line: Line<'static>,
@@ -2379,6 +2340,7 @@ pub(super) fn render_settings(
let language = crate::cli::i18n::current_language();
let visible_apps = crate::settings::get_visible_apps();
let openclaw_config_dir = crate::settings::get_settings().openclaw_config_dir;
+ let skill_sync_method = crate::settings::get_skill_sync_method();
let skip_claude_onboarding = crate::settings::get_skip_claude_onboarding();
let claude_plugin_integration = crate::settings::get_enable_claude_plugin_integration();
@@ -2399,6 +2361,10 @@ pub(super) fn render_settings(
texts::tui_settings_openclaw_config_dir_default_value().to_string()
}),
),
+ super::app::SettingsItem::SkillSyncMethod => (
+ texts::tui_settings_skill_sync_method_label().to_string(),
+ texts::tui_skills_sync_method_name(skill_sync_method).to_string(),
+ ),
super::app::SettingsItem::SkipClaudeOnboarding => (
texts::skip_claude_onboarding_label().to_string(),
if skip_claude_onboarding {
diff --git a/src-tauri/src/cli/tui/ui/forms/mcp.rs b/src-tauri/src/cli/tui/ui/forms/mcp.rs
index 039e767c..e80e5c1b 100644
--- a/src-tauri/src/cli/tui/ui/forms/mcp.rs
+++ b/src-tauri/src/cli/tui/ui/forms/mcp.rs
@@ -186,6 +186,8 @@ pub(crate) fn mcp_field_label_and_value(
McpAddField::AppCodex => texts::tui_label_app_codex().to_string(),
McpAddField::AppGemini => texts::tui_label_app_gemini().to_string(),
McpAddField::AppOpenCode => texts::tui_label_app_opencode().to_string(),
+ McpAddField::AppOpenClaw => texts::tui_label_app_openclaw().to_string(),
+ McpAddField::AppHermes => texts::tui_label_app_hermes().to_string(),
};
let value = match field {
@@ -219,6 +221,20 @@ pub(crate) fn mcp_field_label_and_value(
"[ ]".to_string()
}
}
+ McpAddField::AppOpenClaw => {
+ if mcp.apps.openclaw {
+ format!("[{}]", texts::tui_marker_active())
+ } else {
+ "[ ]".to_string()
+ }
+ }
+ McpAddField::AppHermes => {
+ if mcp.apps.hermes {
+ format!("[{}]", texts::tui_marker_active())
+ } else {
+ "[ ]".to_string()
+ }
+ }
_ => mcp
.input(field)
.map(|v| v.value.trim().to_string())
@@ -251,6 +267,8 @@ pub(crate) fn mcp_field_editor_line(
McpAddField::AppCodex => format!("codex = {}", mcp.apps.codex),
McpAddField::AppGemini => format!("gemini = {}", mcp.apps.gemini),
McpAddField::AppOpenCode => format!("opencode = {}", mcp.apps.opencode),
+ McpAddField::AppOpenClaw => format!("openclaw = {}", mcp.apps.openclaw),
+ McpAddField::AppHermes => format!("hermes = {}", mcp.apps.hermes),
_ => String::new(),
};
@@ -286,7 +304,9 @@ fn mcp_add_form_key_items(
McpAddField::AppClaude
| McpAddField::AppCodex
| McpAddField::AppGemini
- | McpAddField::AppOpenCode,
+ | McpAddField::AppOpenCode
+ | McpAddField::AppOpenClaw
+ | McpAddField::AppHermes,
) => texts::tui_key_toggle(),
_ => texts::tui_key_edit_mode(),
};
@@ -299,7 +319,9 @@ fn mcp_add_form_key_items(
McpAddField::AppClaude
| McpAddField::AppCodex
| McpAddField::AppGemini
- | McpAddField::AppOpenCode,
+ | McpAddField::AppOpenCode
+ | McpAddField::AppOpenClaw
+ | McpAddField::AppHermes,
) => {
keys.push(("Space", texts::tui_key_toggle()));
}
diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs
index d71bbd88..94a862f7 100644
--- a/src-tauri/src/cli/tui/ui/forms/provider.rs
+++ b/src-tauri/src/cli/tui/ui/forms/provider.rs
@@ -4,14 +4,6 @@ fn claude_api_format_label(api_format: crate::cli::tui::form::ClaudeApiFormat) -
texts::tui_claude_api_format_value(api_format.as_str()).to_string()
}
-fn should_redact_provider_field(
- provider: &super::form::ProviderAddFormState,
- field: ProviderAddField,
-) -> bool {
- matches!(provider.app_type, AppType::OpenClaw)
- && matches!(field, ProviderAddField::OpenCodeApiKey)
-}
-
pub(crate) fn render_provider_add_form(
frame: &mut Frame<'_>,
app: &App,
@@ -174,25 +166,17 @@ pub(crate) fn render_provider_add_form(
.copied();
if let Some(field) = selected {
if let Some(input) = provider.input(field) {
- if !editor_active && should_redact_provider_field(provider, field) {
- frame.render_widget(
- Paragraph::new(Line::raw(redacted_secret_placeholder()))
- .wrap(Wrap { trim: false }),
- editor_inner,
- );
- } else {
- let (visible, cursor_x) =
- visible_text_window(&input.value, input.cursor, editor_inner.width as usize);
- frame.render_widget(
- Paragraph::new(Line::raw(visible)).wrap(Wrap { trim: false }),
- editor_inner,
- );
+ let (visible, cursor_x) =
+ visible_text_window(&input.value, input.cursor, editor_inner.width as usize);
+ frame.render_widget(
+ Paragraph::new(Line::raw(visible)).wrap(Wrap { trim: false }),
+ editor_inner,
+ );
- if editor_active {
- let x = editor_inner.x + cursor_x.min(editor_inner.width.saturating_sub(1));
- let y = editor_inner.y;
- frame.set_cursor_position((x, y));
- }
+ if editor_active {
+ let x = editor_inner.x + cursor_x.min(editor_inner.width.saturating_sub(1));
+ let y = editor_inner.y;
+ frame.set_cursor_position((x, y));
}
} else {
let (line, _cursor_col) =
@@ -392,13 +376,7 @@ pub(crate) fn provider_field_label_and_value(
ProviderAddField::CommonSnippet => texts::tui_key_open().to_string(),
_ => provider
.input(field)
- .map(|v| {
- if should_redact_provider_field(provider, field) && !v.value.trim().is_empty() {
- redacted_secret_placeholder().to_string()
- } else {
- v.value.trim().to_string()
- }
- })
+ .map(|v| v.value.trim().to_string())
.unwrap_or_default(),
};
diff --git a/src-tauri/src/cli/tui/ui/header_tests.rs b/src-tauri/src/cli/tui/ui/header_tests.rs
index 60c5685b..c2a8ed3b 100644
--- a/src-tauri/src/cli/tui/ui/header_tests.rs
+++ b/src-tauri/src/cli/tui/ui/header_tests.rs
@@ -195,6 +195,7 @@ fn header_openclaw_sacrifices_tabs_before_losing_the_only_status_badge() {
gemini: true,
opencode: true,
openclaw: true,
+ hermes: false,
});
let _lang = use_test_language(Language::English);
let _no_color = super::tests::EnvGuard::remove("NO_COLOR");
@@ -234,6 +235,7 @@ fn header_openclaw_truncates_long_default_model_without_fake_proxy_gap() {
gemini: true,
opencode: true,
openclaw: true,
+ hermes: false,
});
let _lang = use_test_language(Language::English);
let _no_color = super::tests::EnvGuard::remove("NO_COLOR");
diff --git a/src-tauri/src/cli/tui/ui/main_page.rs b/src-tauri/src/cli/tui/ui/main_page.rs
index 01a528f9..25ccabe1 100644
--- a/src-tauri/src/cli/tui/ui/main_page.rs
+++ b/src-tauri/src/cli/tui/ui/main_page.rs
@@ -18,32 +18,36 @@ fn main_provider_status(app: &App, data: &UiData) -> String {
);
}
- data.providers
- .rows
- .iter()
- .find(|p| p.is_current)
+ main_connection_provider_row(app, data)
.map(|row| data::provider_display_name(&app.app_type, row))
.unwrap_or_else(|| texts::none().to_string())
}
fn main_api_url(app: &App, data: &UiData) -> String {
- let api_url = if matches!(app.app_type, AppType::OpenCode) {
- data.providers
- .rows
- .iter()
- .find(|p| p.is_in_config)
- .and_then(|p| p.api_url.as_deref())
- } else {
- data.providers
- .rows
- .iter()
- .find(|p| p.is_current)
- .and_then(|p| p.api_url.as_deref())
- };
+ let api_url = main_connection_provider_row(app, data).and_then(|p| p.api_url.as_deref());
api_url.unwrap_or(texts::tui_na()).to_string()
}
+fn main_api_key(app: &App, data: &UiData) -> String {
+ main_connection_provider_row(app, data)
+ .and_then(|row| masked_provider_api_key(&row.provider.settings_config, &app.app_type))
+ .unwrap_or_else(|| texts::tui_na().to_string())
+}
+
+fn main_connection_provider_row<'a>(app: &App, data: &'a UiData) -> Option<&'a ProviderRow> {
+ match app.app_type {
+ AppType::OpenCode => data.providers.rows.iter().find(|p| p.is_in_config),
+ AppType::OpenClaw => data
+ .providers
+ .rows
+ .iter()
+ .find(|p| p.is_default_model)
+ .or_else(|| data.providers.rows.iter().find(|p| p.is_in_config)),
+ _ => data.providers.rows.iter().find(|p| p.is_current),
+ }
+}
+
pub(super) fn render_main(
frame: &mut Frame<'_>,
app: &App,
@@ -67,6 +71,7 @@ pub(super) fn render_main(
.count();
let api_url = main_api_url(app, data);
+ let api_key = main_api_key(app, data);
let label_width = 14;
let value_style = Style::default().fg(theme.cyan);
@@ -153,6 +158,12 @@ pub(super) fn render_main(
label_width,
vec![Span::styled(api_url, value_style)],
),
+ kv_line(
+ theme,
+ texts::tui_label_api_key(),
+ label_width,
+ vec![Span::styled(api_key, value_style)],
+ ),
];
if let Some(quota) = current_quota_line {
connection_lines.push(kv_line(
@@ -558,18 +569,28 @@ fn render_local_env_check_card(
let cols0 = Layout::default()
.direction(Direction::Horizontal)
- .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
+ .constraints([
+ Constraint::Percentage(34),
+ Constraint::Percentage(33),
+ Constraint::Percentage(33),
+ ])
.split(rows[0]);
let cols1 = Layout::default()
.direction(Direction::Horizontal)
- .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
+ .constraints([
+ Constraint::Percentage(34),
+ Constraint::Percentage(33),
+ Constraint::Percentage(33),
+ ])
.split(rows[1]);
let cells = [
(LocalTool::Claude, "Claude", cols0[0]),
(LocalTool::Codex, "Codex", cols0[1]),
- (LocalTool::Gemini, "Gemini", cols1[0]),
- (LocalTool::OpenCode, "OpenCode", cols1[1]),
+ (LocalTool::Gemini, "Gemini", cols0[2]),
+ (LocalTool::OpenCode, "OpenCode", cols1[0]),
+ (LocalTool::OpenClaw, "OpenClaw", cols1[1]),
+ (LocalTool::Hermes, "Hermes", cols1[2]),
];
for (tool, display_name, cell_area) in cells {
diff --git a/src-tauri/src/cli/tui/ui/mcp.rs b/src-tauri/src/cli/tui/ui/mcp.rs
index e306f332..c2c43626 100644
--- a/src-tauri/src/cli/tui/ui/mcp.rs
+++ b/src-tauri/src/cli/tui/ui/mcp.rs
@@ -1,15 +1,13 @@
use super::*;
-pub(super) fn mcp_rows_filtered<'a>(app: &App, data: &'a UiData) -> Vec<&'a McpRow> {
+pub(super) fn mcp_rows_filtered<'a>(app: &App, data: &'a UiData) -> Vec> {
let query = app.filter.query_lower();
data.mcp
- .rows
- .iter()
+ .display_rows()
+ .into_iter()
.filter(|row| match &query {
None => true,
- Some(q) => {
- row.server.name.to_lowercase().contains(q) || row.id.to_lowercase().contains(q)
- }
+ Some(q) => row.name().to_lowercase().contains(q) || row.id().to_lowercase().contains(q),
})
.collect()
}
@@ -25,32 +23,51 @@ pub(super) fn render_mcp(
let header = Row::new(vec![
Cell::from(texts::header_name()),
- Cell::from(crate::app_config::AppType::Claude.as_str()),
- Cell::from(crate::app_config::AppType::Codex.as_str()),
- Cell::from(crate::app_config::AppType::Gemini.as_str()),
- Cell::from(crate::app_config::AppType::OpenCode.as_str()),
+ centered_cell(texts::tui_mcp_live_header()),
+ centered_cell("Claude"),
+ centered_cell("Codex"),
+ centered_cell("Gemini"),
+ centered_cell("OpenCode"),
+ centered_cell("OpenClaw"),
+ centered_cell("Hermes"),
])
.style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD));
let rows = visible.iter().map(|row| {
+ let live_marker = mcp_live_marker(row.drift_kind(&data.mcp));
+ let name = row
+ .live_spec_summary()
+ .map(|summary| format!("{} {}", row.name(), summary))
+ .unwrap_or_else(|| row.name().to_string());
Row::new(vec![
- Cell::from(row.server.name.clone()),
- Cell::from(if row.server.apps.claude {
+ Cell::from(name),
+ centered_cell(live_marker),
+ centered_cell(if row.app_enabled(&AppType::Claude) {
texts::tui_marker_active()
} else {
texts::tui_marker_inactive()
}),
- Cell::from(if row.server.apps.codex {
+ centered_cell(if row.app_enabled(&AppType::Codex) {
texts::tui_marker_active()
} else {
texts::tui_marker_inactive()
}),
- Cell::from(if row.server.apps.gemini {
+ centered_cell(if row.app_enabled(&AppType::Gemini) {
texts::tui_marker_active()
} else {
texts::tui_marker_inactive()
}),
- Cell::from(if row.server.apps.opencode {
+ centered_cell(if row.app_enabled(&AppType::OpenCode) {
+ texts::tui_marker_active()
+ } else {
+ texts::tui_marker_inactive()
+ }),
+ centered_cell(if row.app_enabled(&AppType::OpenClaw) {
+ texts::tui_marker_active()
+ } else {
+ texts::tui_marker_inactive()
+ }),
+ centered_cell(if row.app_enabled(&AppType::Hermes) {
texts::tui_marker_active()
} else {
texts::tui_marker_inactive()
@@ -86,12 +103,13 @@ pub(super) fn render_mcp(
("a", texts::tui_key_add()),
("e", texts::tui_key_edit()),
("i", texts::tui_mcp_action_import_existing()),
+ ("r", texts::tui_key_resolve()),
("d", texts::tui_key_delete()),
],
);
}
- let summary = texts::tui_mcp_server_counts(
+ let mut summary = texts::tui_mcp_server_counts(
data.mcp
.rows
.iter()
@@ -112,17 +130,42 @@ pub(super) fn render_mcp(
.iter()
.filter(|row| row.server.apps.opencode)
.count(),
+ data.mcp
+ .rows
+ .iter()
+ .filter(|row| row.server.apps.openclaw)
+ .count(),
+ data.mcp
+ .rows
+ .iter()
+ .filter(|row| row.server.apps.hermes)
+ .count(),
);
+ let drift_counts = data.mcp.live_drift_counts();
+ if drift_counts.has_drift() {
+ summary = format!(
+ "{} · {summary}",
+ texts::tui_mcp_live_drift_summary(
+ drift_counts.changed,
+ drift_counts.live_only,
+ drift_counts.db_only,
+ drift_counts.invalid,
+ )
+ );
+ }
render_summary_bar(frame, chunks[1], theme, summary);
let table = Table::new(
rows,
[
- Constraint::Percentage(50),
+ Constraint::Percentage(34),
+ Constraint::Length(6),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(10),
+ Constraint::Length(10),
+ Constraint::Length(9),
],
)
.header(header)
@@ -135,3 +178,17 @@ pub(super) fn render_mcp(
frame.render_stateful_widget(table, inset_left(chunks[2], CONTENT_INSET_LEFT), &mut state);
}
+
+fn centered_cell(text: impl Into) -> Cell<'static> {
+ Cell::from(Line::from(text.into()).alignment(Alignment::Center))
+}
+
+fn mcp_live_marker(kind: Option<&crate::services::McpLiveDriftKind>) -> &'static str {
+ match kind {
+ Some(crate::services::McpLiveDriftKind::Changed) => "~",
+ Some(crate::services::McpLiveDriftKind::LiveOnly) => "+",
+ Some(crate::services::McpLiveDriftKind::DbOnly) => "-",
+ Some(crate::services::McpLiveDriftKind::LiveInvalid) => "!",
+ _ => "",
+ }
+}
diff --git a/src-tauri/src/cli/tui/ui/overlay/basic.rs b/src-tauri/src/cli/tui/ui/overlay/basic.rs
index c7d67cd6..ad0537fc 100644
--- a/src-tauri/src/cli/tui/ui/overlay/basic.rs
+++ b/src-tauri/src/cli/tui/ui/overlay/basic.rs
@@ -160,13 +160,12 @@ pub(super) fn render_text_input_overlay(
let available = input_inner.width as usize;
let full = if input.secret {
- "•".repeat(input.buffer.chars().count())
+ "•".repeat(input.input.value.chars().count())
} else {
- input.buffer.clone()
+ input.input.value.clone()
};
- let cursor = full.chars().count();
- let start = cursor.saturating_sub(available);
- let visible = full.chars().skip(start).take(available).collect::();
+ let cursor = input.input.cursor.min(full.chars().count());
+ let (visible, cursor_x) = visible_text_window(&full, cursor, available);
frame.render_widget(
Paragraph::new(Line::from(Span::raw(visible)))
.wrap(Wrap { trim: false })
@@ -174,7 +173,7 @@ pub(super) fn render_text_input_overlay(
input_inner,
);
- let cursor_x = input_inner.x + (cursor.saturating_sub(start) as u16);
+ let cursor_x = input_inner.x + cursor_x.min(input_inner.width.saturating_sub(1));
let cursor_y = input_inner.y;
frame.set_cursor_position((cursor_x, cursor_y));
}
diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs
index ce4d479f..d59aab7d 100644
--- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs
+++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs
@@ -1,5 +1,6 @@
use super::super::theme;
use super::super::*;
+use crate::cli::tui::text_edit::TextInput;
pub(super) fn render_claude_model_picker_overlay(
frame: &mut Frame<'_>,
@@ -152,6 +153,87 @@ pub(super) fn render_claude_model_picker_overlay(
}
}
+pub(super) fn render_codex_current_provider_mismatch_overlay(
+ frame: &mut Frame<'_>,
+ content_area: Rect,
+ theme: &theme::Theme,
+ selected: usize,
+ mismatch: &crate::services::provider::CodexCurrentProviderMismatch,
+) {
+ let area = centered_rect_fixed(OVERLAY_FIXED_LG.0, 14, content_area);
+ frame.render_widget(Clear, area);
+
+ let outer = Block::default()
+ .borders(Borders::ALL)
+ .border_type(BorderType::Plain)
+ .border_style(overlay_border_style(theme, true))
+ .title(crate::t!("Codex Current Provider", "Codex 当前供应商"));
+ frame.render_widget(outer.clone(), area);
+ let inner = outer.inner(area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(1),
+ Constraint::Length(4),
+ Constraint::Min(0),
+ ])
+ .split(inner);
+
+ render_key_bar_center(
+ frame,
+ chunks[0],
+ theme,
+ &[
+ ("↑↓", texts::tui_key_select()),
+ ("Enter", texts::tui_key_apply()),
+ ("Esc", texts::tui_key_cancel()),
+ ],
+ );
+
+ let message = format!(
+ "{}\n{}: {} ({})\n{}: {}",
+ crate::t!(
+ "Codex config.toml and cc-switch disagree about the current provider.",
+ "Codex config.toml 与 cc-switch 记录的当前供应商不一致。"
+ ),
+ crate::t!("config.toml", "config.toml"),
+ mismatch.live_provider_name,
+ mismatch.live_model_provider_key,
+ crate::t!("cc-switch", "cc-switch"),
+ mismatch.stored_provider_name
+ );
+ frame.render_widget(
+ Paragraph::new(message)
+ .wrap(Wrap { trim: false })
+ .style(Style::default()),
+ inset_top(chunks[1], 1),
+ );
+
+ let items = [
+ format!(
+ "{}: {}",
+ crate::t!("Use config.toml", "使用 config.toml"),
+ mismatch.live_provider_name
+ ),
+ format!(
+ "{}: {}",
+ crate::t!("Switch Codex to cc-switch", "将 Codex 切到 cc-switch 记录"),
+ mismatch.stored_provider_name
+ ),
+ ]
+ .into_iter()
+ .map(|label| ListItem::new(Line::from(Span::raw(label))));
+
+ let list = List::new(items)
+ .highlight_style(selection_style(theme))
+ .highlight_symbol(highlight_symbol(theme));
+
+ let mut state = ListState::default();
+ state.select(Some(selected.min(1)));
+ frame.render_stateful_widget(list, inset_top(chunks[2], 1), &mut state);
+}
+
pub(super) fn render_claude_api_format_picker_overlay(
frame: &mut Frame<'_>,
app: &App,
@@ -230,11 +312,67 @@ pub(super) fn render_claude_api_format_picker_overlay(
frame.render_stateful_widget(list, body_area, &mut state);
}
+pub(super) fn render_provider_test_menu_overlay(
+ frame: &mut Frame<'_>,
+ app: &App,
+ _data: &UiData,
+ content_area: Rect,
+ theme: &theme::Theme,
+ _provider_id: &str,
+ selected: usize,
+) {
+ let area = centered_rect_fixed(50, 8, content_area);
+ frame.render_widget(Clear, area);
+
+ let outer = Block::default()
+ .borders(Borders::ALL)
+ .border_type(BorderType::Plain)
+ .border_style(overlay_border_style(theme, false))
+ .title(texts::tui_provider_test_menu_title());
+ frame.render_widget(outer.clone(), area);
+ let inner = outer.inner(area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(1), Constraint::Min(0)])
+ .split(inner);
+
+ render_key_bar_center(
+ frame,
+ chunks[0],
+ theme,
+ &[
+ ("↑↓", texts::tui_key_select()),
+ ("Enter", texts::tui_key_apply()),
+ ("Esc", texts::tui_key_close()),
+ ],
+ );
+
+ let body_area = inset_top(chunks[1], 1);
+ let items = app::provider_test_menu_items(&app.app_type)
+ .into_iter()
+ .map(|item| ListItem::new(Line::raw(app::provider_test_menu_item_label(item))));
+
+ let list = List::new(items)
+ .highlight_style(selection_style(theme))
+ .highlight_symbol(highlight_symbol(theme));
+
+ let mut state = ListState::default();
+ state.select(Some(
+ selected.min(
+ app::provider_test_menu_items(&app.app_type)
+ .len()
+ .saturating_sub(1),
+ ),
+ ));
+ frame.render_stateful_widget(list, body_area, &mut state);
+}
+
pub(super) fn render_model_fetch_picker_overlay(
frame: &mut Frame<'_>,
content_area: Rect,
theme: &theme::Theme,
- input: &str,
+ input: &TextInput,
query: &str,
fetching: bool,
models: &[String],
@@ -270,8 +408,8 @@ pub(super) fn render_model_fetch_picker_overlay(
let input_inner = input_block.inner(chunks[0]);
let (visible, cursor_x) =
- visible_text_window(input, input.chars().count(), input_inner.width as usize);
- let (input_text, input_style) = if input.is_empty() {
+ visible_text_window(&input.value, input.cursor, input_inner.width as usize);
+ let (input_text, input_style) = if input.value.is_empty() {
(
texts::tui_model_fetch_search_placeholder().to_string(),
Style::default().fg(theme.dim),
@@ -644,13 +782,83 @@ pub(super) fn render_mcp_apps_picker_overlay(
texts::tui_mcp_apps_title(name),
selected,
apps,
+ crate::app_config::MCP_PICKER_APPS,
+ );
+}
+
+pub(super) fn render_mcp_live_drift_resolve_overlay(
+ frame: &mut Frame<'_>,
+ content_area: Rect,
+ theme: &theme::Theme,
+ app_type: &AppType,
+ id: &str,
+ kind: &crate::services::McpLiveDriftKind,
+ selected: usize,
+) {
+ let area = centered_rect_fixed(64, 12, content_area);
+ frame.render_widget(Clear, area);
+
+ let outer = Block::default()
+ .borders(Borders::ALL)
+ .border_type(BorderType::Plain)
+ .border_style(overlay_border_style(theme, true))
+ .title(texts::tui_mcp_live_drift_resolve_title());
+ frame.render_widget(outer.clone(), area);
+ let inner = outer.inner(area);
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(1),
+ Constraint::Length(3),
+ Constraint::Min(0),
+ ])
+ .split(inner);
+
+ render_key_bar_center(
+ frame,
+ chunks[0],
+ theme,
&[
- crate::app_config::AppType::Claude,
- crate::app_config::AppType::Codex,
- crate::app_config::AppType::Gemini,
- crate::app_config::AppType::OpenCode,
+ ("↑↓", texts::tui_key_select()),
+ ("Enter", texts::tui_key_apply()),
+ ("Esc", texts::tui_key_cancel()),
],
);
+
+ let message = format!(
+ "{}: {} · {}: {} · {}: {}",
+ crate::t!("App", "应用"),
+ app_type.as_str(),
+ crate::t!("Server", "服务器"),
+ id,
+ crate::t!("Status", "状态"),
+ texts::tui_mcp_live_drift_status(kind)
+ );
+ frame.render_widget(
+ Paragraph::new(message)
+ .wrap(Wrap { trim: false })
+ .style(Style::default().fg(theme.cyan)),
+ inset_top(chunks[1], 1),
+ );
+
+ let choices = app::mcp_live_drift_resolve_choices(kind);
+ let items = choices.iter().map(|choice| {
+ let label = match choice {
+ app::McpLiveDriftResolveChoice::ImportLive => texts::tui_mcp_live_drift_import_live(),
+ app::McpLiveDriftResolveChoice::PushDbToLive => texts::tui_mcp_live_drift_push_db(),
+ app::McpLiveDriftResolveChoice::Cancel => texts::tui_mcp_live_drift_cancel(),
+ };
+ ListItem::new(Line::raw(label))
+ });
+
+ let list = List::new(items)
+ .highlight_style(selection_style(theme))
+ .highlight_symbol(highlight_symbol(theme));
+
+ let mut state = ListState::default();
+ state.select(Some(selected.min(choices.len().saturating_sub(1))));
+ frame.render_stateful_widget(list, inset_top(chunks[2], 1), &mut state);
}
pub(super) fn render_mcp_type_picker_overlay(
@@ -718,13 +926,7 @@ pub(super) fn render_visible_apps_picker_overlay(
texts::tui_settings_visible_apps_title().to_string(),
selected,
apps,
- &[
- crate::app_config::AppType::Claude,
- crate::app_config::AppType::Codex,
- crate::app_config::AppType::Gemini,
- crate::app_config::AppType::OpenCode,
- crate::app_config::AppType::OpenClaw,
- ],
+ crate::app_config::VISIBLE_PICKER_APPS,
);
}
@@ -743,12 +945,7 @@ pub(super) fn render_skills_apps_picker_overlay(
texts::tui_skill_apps_title(name),
selected,
apps,
- &[
- crate::app_config::AppType::Claude,
- crate::app_config::AppType::Codex,
- crate::app_config::AppType::Gemini,
- crate::app_config::AppType::OpenCode,
- ],
+ crate::app_config::SKILLS_PICKER_APPS,
);
}
@@ -759,6 +956,48 @@ pub(super) fn render_skills_import_picker_overlay(
skills: &[crate::services::skill::UnmanagedSkill],
selected_idx: usize,
selected: &std::collections::HashSet,
+) {
+ render_skill_import_picker_overlay(
+ frame,
+ content_area,
+ theme,
+ texts::tui_skills_import_title(),
+ texts::tui_skills_import_description(),
+ skills,
+ selected_idx,
+ selected,
+ );
+}
+
+pub(super) fn render_skills_agent_import_picker_overlay(
+ frame: &mut Frame<'_>,
+ content_area: Rect,
+ theme: &theme::Theme,
+ skills: &[crate::services::skill::UnmanagedSkill],
+ selected_idx: usize,
+ selected: &std::collections::HashSet,
+) {
+ render_skill_import_picker_overlay(
+ frame,
+ content_area,
+ theme,
+ texts::tui_skills_agent_import_title(),
+ texts::tui_skills_agent_import_description(),
+ skills,
+ selected_idx,
+ selected,
+ );
+}
+
+fn render_skill_import_picker_overlay(
+ frame: &mut Frame<'_>,
+ content_area: Rect,
+ theme: &theme::Theme,
+ title: &str,
+ description: &str,
+ skills: &[crate::services::skill::UnmanagedSkill],
+ selected_idx: usize,
+ selected: &std::collections::HashSet,
) {
let area = centered_rect_fixed(OVERLAY_FIXED_LG.0, OVERLAY_FIXED_LG.1, content_area);
frame.render_widget(Clear, area);
@@ -767,7 +1006,7 @@ pub(super) fn render_skills_import_picker_overlay(
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(overlay_border_style(theme, true))
- .title(texts::tui_skills_import_title())
+ .title(title)
.style(if theme.no_color {
Style::default()
} else {
@@ -798,7 +1037,7 @@ pub(super) fn render_skills_import_picker_overlay(
);
frame.render_widget(
- Paragraph::new(texts::tui_skills_import_description())
+ Paragraph::new(description)
.style(Style::default().fg(theme.dim))
.wrap(Wrap { trim: false }),
chunks[1],
@@ -852,69 +1091,6 @@ pub(super) fn render_skills_import_picker_overlay(
frame.render_stateful_widget(table, body_area, &mut state);
}
-pub(super) fn render_skills_sync_method_picker_overlay(
- frame: &mut Frame<'_>,
- data: &UiData,
- content_area: Rect,
- theme: &theme::Theme,
- selected: usize,
-) {
- let area = centered_rect_fixed(OVERLAY_FIXED_LG.0, 12, content_area);
- frame.render_widget(Clear, area);
-
- let outer = Block::default()
- .borders(Borders::ALL)
- .border_type(BorderType::Plain)
- .border_style(overlay_border_style(theme, false))
- .title(texts::tui_skills_sync_method_title());
- frame.render_widget(outer.clone(), area);
- let inner = outer.inner(area);
-
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([Constraint::Length(1), Constraint::Min(0)])
- .split(inner);
-
- render_key_bar_center(
- frame,
- chunks[0],
- theme,
- &[
- ("←→", texts::tui_key_select()),
- ("Enter", texts::tui_key_apply()),
- ("Esc", texts::tui_key_cancel()),
- ],
- );
-
- let body_area = inset_top(chunks[1], 1);
- let current = data.skills.sync_method;
- let methods = [
- crate::services::skill::SyncMethod::Auto,
- crate::services::skill::SyncMethod::Symlink,
- crate::services::skill::SyncMethod::Copy,
- ];
-
- let items = methods.into_iter().map(|method| {
- let marker = if method == current {
- texts::tui_marker_active()
- } else {
- texts::tui_marker_inactive()
- };
- ListItem::new(Line::from(Span::raw(format!(
- "{marker} {}",
- texts::tui_skills_sync_method_name(method)
- ))))
- });
-
- let list = List::new(items)
- .highlight_style(selection_style(theme))
- .highlight_symbol(highlight_symbol(theme));
-
- let mut state = ListState::default();
- state.select(Some(selected));
- frame.render_stateful_widget(list, body_area, &mut state);
-}
-
fn render_apps_picker_overlay(
frame: &mut Frame<'_>,
content_area: Rect,
diff --git a/src-tauri/src/cli/tui/ui/overlay/render.rs b/src-tauri/src/cli/tui/ui/overlay/render.rs
index 02d04db9..1b9f2067 100644
--- a/src-tauri/src/cli/tui/ui/overlay/render.rs
+++ b/src-tauri/src/cli/tui/ui/overlay/render.rs
@@ -38,6 +38,18 @@ pub(crate) fn render_overlay(
*selected,
)
}
+ Overlay::ProviderTestMenu {
+ provider_id,
+ selected,
+ } => super::pickers::render_provider_test_menu_overlay(
+ frame,
+ app,
+ data,
+ content_area,
+ theme,
+ provider_id,
+ *selected,
+ ),
Overlay::FailoverQueueManager { selected } => {
super::pickers::render_failover_queue_manager_overlay(
frame,
@@ -76,6 +88,15 @@ pub(crate) fn render_overlay(
*selected,
)
}
+ Overlay::CodexCurrentProviderMismatch { selected, mismatch } => {
+ super::pickers::render_codex_current_provider_mismatch_overlay(
+ frame,
+ content_area,
+ theme,
+ *selected,
+ mismatch,
+ )
+ }
Overlay::ModelFetchPicker {
input,
query,
@@ -127,6 +148,20 @@ pub(crate) fn render_overlay(
*selected,
apps,
),
+ Overlay::McpLiveDriftResolve {
+ app_type,
+ id,
+ kind,
+ selected,
+ } => super::pickers::render_mcp_live_drift_resolve_overlay(
+ frame,
+ content_area,
+ theme,
+ app_type,
+ id,
+ kind,
+ *selected,
+ ),
Overlay::McpTypePicker { selected } => {
super::pickers::render_mcp_type_picker_overlay(frame, content_area, theme, *selected)
}
@@ -164,15 +199,18 @@ pub(crate) fn render_overlay(
*selected_idx,
selected,
),
- Overlay::SkillsSyncMethodPicker { selected } => {
- super::pickers::render_skills_sync_method_picker_overlay(
- frame,
- data,
- content_area,
- theme,
- *selected,
- )
- }
+ Overlay::SkillsAgentImportPicker {
+ skills,
+ selected_idx,
+ selected,
+ } => super::pickers::render_skills_agent_import_picker_overlay(
+ frame,
+ content_area,
+ theme,
+ skills,
+ *selected_idx,
+ selected,
+ ),
Overlay::McpEnvPicker { selected } => super::mcp_env::render_mcp_env_picker_overlay(
frame,
app,
diff --git a/src-tauri/src/cli/tui/ui/providers.rs b/src-tauri/src/cli/tui/ui/providers.rs
index 23431f0d..733407e8 100644
--- a/src-tauri/src/cli/tui/ui/providers.rs
+++ b/src-tauri/src/cli/tui/ui/providers.rs
@@ -67,6 +67,73 @@ fn provider_name_with_quota_line(
Line::from(spans)
}
+fn render_provider_empty_state(
+ frame: &mut Frame<'_>,
+ area: Rect,
+ app_type: &crate::app_config::AppType,
+ theme: &super::theme::Theme,
+) {
+ let title_style = Style::default().add_modifier(Modifier::BOLD);
+ let subtitle_style = Style::default().fg(theme.comment);
+ let primary_style = if theme.no_color {
+ Style::default().add_modifier(Modifier::BOLD)
+ } else {
+ Style::default()
+ .fg(Color::White)
+ .bg(theme.accent)
+ .add_modifier(Modifier::BOLD)
+ };
+ let secondary_style = if theme.no_color {
+ Style::default()
+ } else {
+ Style::default()
+ .fg(theme.dim)
+ .bg(theme.surface)
+ .add_modifier(Modifier::BOLD)
+ };
+
+ let subtitle = if matches!(app_type, crate::app_config::AppType::Codex) {
+ texts::tui_provider_empty_subtitle_codex()
+ } else {
+ texts::tui_provider_empty_subtitle()
+ };
+
+ let mut content_lines = vec![
+ Line::styled(texts::tui_provider_empty_title(), title_style),
+ Line::raw(""),
+ Line::styled(subtitle, subtitle_style),
+ Line::raw(""),
+ Line::from(vec![Span::styled(
+ format!(" Enter {} ", texts::tui_key_import_current_config()),
+ primary_style,
+ )]),
+ ];
+ if matches!(app_type, crate::app_config::AppType::Codex) {
+ content_lines.push(Line::from(vec![Span::styled(
+ format!(" i {} ", texts::tui_key_import_current_config()),
+ secondary_style,
+ )]));
+ }
+ content_lines.push(Line::from(vec![Span::styled(
+ format!(" a {} ", texts::tui_key_add_provider()),
+ secondary_style,
+ )]));
+
+ let top_padding = area.height.saturating_sub(content_lines.len() as u16) / 2;
+ let mut lines = Vec::with_capacity(top_padding as usize + content_lines.len());
+ for _ in 0..top_padding {
+ lines.push(Line::raw(""));
+ }
+ lines.extend(content_lines);
+
+ frame.render_widget(
+ Paragraph::new(lines)
+ .alignment(Alignment::Center)
+ .wrap(Wrap { trim: false }),
+ area,
+ );
+}
+
pub(super) fn render_providers(
frame: &mut Frame<'_>,
app: &App,
@@ -77,11 +144,36 @@ pub(super) fn render_providers(
let header_style = Style::default().fg(theme.dim).add_modifier(Modifier::BOLD);
let table_style = Style::default();
- let outer = Block::default()
- .borders(Borders::ALL)
- .border_type(BorderType::Plain)
- .border_style(pane_border_style(app, Focus::Content, theme))
- .title(texts::menu_manage_providers());
+ // Show current provider name at top-right for apps that track a current provider
+ let has_current_concept = !matches!(
+ app.app_type,
+ crate::app_config::AppType::OpenCode | crate::app_config::AppType::OpenClaw
+ );
+ let outer = if has_current_concept {
+ let current_name = data
+ .providers
+ .rows
+ .iter()
+ .find(|row| row.is_current)
+ .map(|row| data::provider_display_name(&app.app_type, row))
+ .unwrap_or_else(|| texts::tui_provider_none().to_string());
+ let title_right = Span::styled(
+ format!("{}: {}", texts::tui_label_current(), current_name),
+ Style::default().fg(theme.accent),
+ );
+ Block::default()
+ .borders(Borders::ALL)
+ .border_type(BorderType::Plain)
+ .border_style(pane_border_style(app, Focus::Content, theme))
+ .title(texts::menu_manage_providers())
+ .title(Line::from(title_right).right_aligned())
+ } else {
+ Block::default()
+ .borders(Borders::ALL)
+ .border_type(BorderType::Plain)
+ .border_style(pane_border_style(app, Focus::Content, theme))
+ .title(texts::menu_manage_providers())
+ };
frame.render_widget(outer.clone(), area);
let inner = outer.inner(area);
@@ -94,73 +186,59 @@ pub(super) fn render_providers(
let selected_supports_quota = visible
.get(app.provider_idx)
.is_some_and(|row| data::quota_target_for_provider(&app.app_type, row).is_some());
-
if app.focus == Focus::Content {
let mut keys = Vec::new();
- if !data.providers.rows.is_empty() {
- keys.push(("Enter", texts::tui_key_details()));
- }
- if matches!(
- app.app_type,
- crate::app_config::AppType::OpenCode | crate::app_config::AppType::OpenClaw
- ) {
- if data.providers.rows.is_empty() {
- keys.push(("a", texts::tui_key_add()));
- keys.push(("i", texts::tui_key_import()));
- } else {
- keys.extend([
- ("s", texts::tui_key_add_remove()),
- ("a", texts::tui_key_add()),
- ]);
- keys.extend([
- ("d", texts::tui_key_delete()),
- ("t", texts::tui_key_speedtest()),
- ]);
- if let Some(row) = visible.get(app.provider_idx) {
- keys.push(("e", texts::tui_key_edit()));
- if selected_supports_quota {
- keys.push(("r", texts::tui_key_refresh()));
- }
- if matches!(app.app_type, crate::app_config::AppType::OpenClaw)
- && row.is_in_config
- {
- keys.push(("x", texts::tui_key_set_default()));
- }
- }
- if matches!(app.app_type, crate::app_config::AppType::OpenCode) {
- keys.push(("c", texts::tui_key_stream_check()));
- }
+ if data.providers.rows.is_empty() {
+ keys.push(("Enter", texts::tui_key_import_current_config()));
+ if matches!(app.app_type, crate::app_config::AppType::Codex) {
+ keys.push(("i", texts::tui_key_import_current_config()));
}
+ keys.push(("a", texts::tui_key_add_provider()));
+ } else if visible.is_empty() {
+ keys.push(("a", texts::tui_key_add()));
} else {
- if data.providers.rows.is_empty() {
- keys.push(("a", texts::tui_key_add()));
- keys.push(("i", texts::tui_key_import()));
- } else {
- if !data.proxy.auto_failover_enabled {
- keys.push(("Space", texts::tui_key_switch()));
- }
- keys.extend([
- ("a", texts::tui_key_add()),
- ("e", texts::tui_key_edit()),
- ("d", texts::tui_key_delete()),
- ]);
- if selected_supports_quota {
- keys.push(("r", texts::tui_key_refresh()));
- }
- keys.push(("t", texts::tui_key_speedtest()));
- if crate::cli::tui::app::supports_temporary_provider_launch(&app.app_type) {
- keys.push(("o", texts::tui_key_launch_temp()));
- }
- keys.push(("c", texts::tui_key_stream_check()));
+ keys.push(("Enter", texts::tui_key_details()));
+ keys.push(("Space", texts::tui_key_switch()));
+ if crate::cli::tui::app::supports_temporary_provider_launch(&app.app_type) {
+ keys.push(("o", texts::tui_key_launch_temp()));
+ }
+ if matches!(app.app_type, crate::app_config::AppType::Codex) {
+ keys.push(("i", texts::tui_key_import_current_config()));
+ }
+ keys.extend([
+ ("a", texts::tui_key_add()),
+ ("e", texts::tui_key_edit()),
+ ("d", texts::tui_key_delete()),
+ ]);
+ keys.push(("t", texts::tui_key_test()));
+ if selected_supports_quota {
+ keys.push(("r", texts::tui_key_refresh()));
}
if crate::cli::tui::app::supports_failover_controls(&app.app_type) {
- keys.push(("f", crate::t!("manage failover", "管理故障转移")));
+ keys.push(("f", texts::tui_key_failover()));
+ }
+ if let Some(row) = visible.get(app.provider_idx) {
+ if matches!(app.app_type, crate::app_config::AppType::OpenClaw) && row.is_in_config
+ {
+ keys.push(("x", texts::tui_key_set_default()));
+ }
}
}
render_key_bar_center(frame, chunks[0], theme, &keys);
}
+ if data.providers.rows.is_empty() {
+ render_provider_empty_state(frame, chunks[1], &app.app_type, theme);
+ return;
+ }
+
let failover_supported = crate::cli::tui::app::supports_failover_controls(&app.app_type);
+ let proxy_failover_active = failover_supported
+ && data.proxy.auto_failover_enabled
+ && data
+ .proxy
+ .routes_current_app_through_proxy(&app.app_type)
+ .unwrap_or(false);
let mut header_cells = vec![
Cell::from(""),
Cell::from(texts::header_name()),
@@ -174,7 +252,7 @@ pub(super) fn render_providers(
let rows = visible.iter().enumerate().map(|(idx, row)| {
let marker = if matches!(app.app_type, crate::app_config::AppType::OpenClaw) {
if row.is_default_model {
- "*"
+ texts::tui_marker_active()
} else if row.is_in_config {
"+"
} else {
@@ -186,7 +264,7 @@ pub(super) fn render_providers(
} else {
""
}
- } else if failover_supported && data.proxy.auto_failover_enabled {
+ } else if proxy_failover_active {
if row.provider.in_failover_queue {
texts::tui_marker_active()
} else {
@@ -270,48 +348,22 @@ pub(super) fn render_provider_detail(
.split(inner);
if app.focus == Focus::Content {
- let mut keys = if matches!(
- app.app_type,
- crate::app_config::AppType::OpenCode | crate::app_config::AppType::OpenClaw
- ) {
- let keys = vec![
- ("s", texts::tui_key_add_remove()),
- ("e", texts::tui_key_edit()),
- ];
- keys
- } else {
- let keys = if data.proxy.auto_failover_enabled {
- vec![("e", texts::tui_key_edit())]
- } else {
- vec![
- ("Space", texts::tui_key_switch()),
- ("e", texts::tui_key_edit()),
- ]
- };
- keys
- };
+ let mut keys = vec![
+ ("Space", texts::tui_key_switch()),
+ ("e", texts::tui_key_edit()),
+ ];
+ if crate::cli::tui::app::supports_temporary_provider_launch(&app.app_type) {
+ keys.push(("o", texts::tui_key_launch_temp()));
+ }
+ keys.push(("t", texts::tui_key_test()));
if data::quota_target_for_provider(&app.app_type, row).is_some() {
keys.push(("r", texts::tui_key_refresh()));
}
- keys.push(("t", texts::tui_key_speedtest()));
if matches!(app.app_type, crate::app_config::AppType::OpenClaw) && row.is_in_config {
keys.push(("x", texts::tui_key_set_default()));
- } else if matches!(app.app_type, crate::app_config::AppType::OpenCode) {
- keys.push(("c", texts::tui_key_stream_check()));
- } else if !matches!(
- app.app_type,
- crate::app_config::AppType::OpenCode | crate::app_config::AppType::OpenClaw
- ) {
- if matches!(
- app.app_type,
- crate::app_config::AppType::Claude | crate::app_config::AppType::Codex
- ) {
- keys.push(("o", texts::tui_key_launch_temp()));
- }
- keys.push(("c", texts::tui_key_stream_check()));
- if crate::cli::tui::app::supports_failover_controls(&app.app_type) {
- keys.push(("f", crate::t!("manage failover", "管理故障转移")));
- }
+ }
+ if crate::cli::tui::app::supports_failover_controls(&app.app_type) {
+ keys.push(("f", texts::tui_key_failover()));
}
render_key_bar_center(frame, chunks[0], theme, &keys);
}
@@ -340,6 +392,16 @@ pub(super) fn render_provider_detail(
Span::raw(url),
]));
}
+ if let Some(api_key) = masked_provider_api_key(&row.provider.settings_config, &app.app_type) {
+ lines.push(Line::from(vec![
+ Span::styled(
+ texts::tui_label_api_key(),
+ Style::default().fg(theme.accent),
+ ),
+ Span::raw(": "),
+ Span::raw(api_key),
+ ]));
+ }
if matches!(app.app_type, crate::app_config::AppType::OpenClaw) {
lines.push(Line::raw(""));
@@ -403,12 +465,6 @@ pub(super) fn render_provider_detail(
.get("env")
.and_then(|v| v.as_object())
{
- let api_key = env
- .get("ANTHROPIC_AUTH_TOKEN")
- .or_else(|| env.get("ANTHROPIC_API_KEY"))
- .and_then(|v| v.as_str())
- .map(mask_api_key)
- .unwrap_or_else(|| texts::tui_na().to_string());
let base_url = env
.get("ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str())
@@ -433,14 +489,6 @@ pub(super) fn render_provider_detail(
Span::raw(": "),
Span::raw(texts::tui_claude_api_format_value(api_format)),
]));
- lines.push(Line::from(vec![
- Span::styled(
- texts::tui_label_api_key(),
- Style::default().fg(theme.accent),
- ),
- Span::raw(": "),
- Span::raw(api_key),
- ]));
}
}
diff --git a/src-tauri/src/cli/tui/ui/shared.rs b/src-tauri/src/cli/tui/ui/shared.rs
index 4cbf8220..7a1ad803 100644
--- a/src-tauri/src/cli/tui/ui/shared.rs
+++ b/src-tauri/src/cli/tui/ui/shared.rs
@@ -719,15 +719,72 @@ where
}
pub(super) fn mask_api_key(key: &str) -> String {
- let mut iter = key.chars();
- let prefix: String = iter.by_ref().take(8).collect();
- if iter.next().is_some() {
- format!("{prefix}...")
- } else {
- prefix
+ let key = key.trim();
+ if key.is_empty() || key == redacted_secret_placeholder() {
+ return key.to_string();
+ }
+
+ let chars = key.chars().collect::>();
+ match chars.len() {
+ 0 => String::new(),
+ 1..=4 => "****".to_string(),
+ 5..=8 => format!(
+ "{}****{}",
+ chars.iter().take(1).collect::(),
+ chars.iter().skip(chars.len() - 1).collect::()
+ ),
+ 9..=12 => format!(
+ "{}****{}",
+ chars.iter().take(2).collect::