11 Commits
Author SHA1 Message Date
owen 1d3c354b8b Add v0.4.2 release notes
Publish Markdown site / Build Markdown site (push) Waiting to run
Publish Markdown site / Deploy to GitHub Pages (push) Blocked by required conditions
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
Release / Test release commit (push) Waiting to run
Release / Build macOS Apple Silicon (push) Blocked by required conditions
Release / Build Linux ARM64 (push) Blocked by required conditions
Release / Build Linux x86_64 (push) Blocked by required conditions
Release / Build Windows x86_64 CLI (push) Blocked by required conditions
Release / Upload GitHub release assets (push) Blocked by required conditions
Release / Publish npm packages (push) Blocked by required conditions
2026-06-27 16:43:31 -05:00
owen 20e11ccd39 Preparing for v0.4.2 2026-06-27 16:40:10 -05:00
owen 96b9c9cdba Prepare Cassady v0.4.1
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
Release / Test release commit (push) Waiting to run
Release / Build macOS Apple Silicon (push) Blocked by required conditions
Release / Build Linux ARM64 (push) Blocked by required conditions
Release / Build Linux x86_64 (push) Blocked by required conditions
Release / Build Windows x86_64 CLI (push) Blocked by required conditions
Release / Upload GitHub release assets (push) Blocked by required conditions
Release / Publish npm packages (push) Blocked by required conditions
2026-06-27 14:58:06 -05:00
owen 47ce998b4e Automate desktop release binaries
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
Release / Test release commit (push) Waiting to run
Release / Build macOS Apple Silicon (push) Blocked by required conditions
Release / Build Linux ARM64 (push) Blocked by required conditions
Release / Build Linux x86_64 (push) Blocked by required conditions
Release / Build Windows x86_64 CLI (push) Blocked by required conditions
Release / Upload GitHub release assets (push) Blocked by required conditions
Release / Publish npm packages (push) Blocked by required conditions
2026-06-27 13:27:10 -05:00
IrrelevantandGitHub 415275c5a1 Update README.md 2026-06-27 12:29:37 -05:00
owen 99ea3fed50 Prepare Cassady v0.4.0 desktop app 2026-06-27 11:03:43 -05:00
owen e564777877 Add npm CLI package publishing 2026-06-26 13:59:54 -05:00
owen b4ff4f9f14 Prepare Cassady v0.3.5
CI / Build (push) Waiting to run
CI / Test (push) Waiting to run
2026-06-26 11:53:01 -05:00
owen ac86ee3933 Updating README 2026-06-25 22:03:46 -05:00
IrrelevantandGitHub da577aed1c Update ROADMAP with completion status for versions 2026-06-25 21:58:39 -05:00
owen ab0c45aff4 Correct roadmap: real v0.3.4 release notes, rename planned Tool Output Context Reliability to v0.3.5
The v0.3.4 tag is the released 'Reasoning Off Handling' release (commit
b35c4fb), distinct from the planned Tool Output Context Reliability work.
Add proper v0.3.4 release notes based on the shipped commit, move the Tool
Output Context Reliability entry to the top as v0.3.5, and rename its plan
file to match.
2026-06-25 21:56:44 -05:00
153 changed files with 23622 additions and 1296 deletions
+28
View File
@@ -5,12 +5,16 @@ on:
paths: paths:
- 'src/**' - 'src/**'
- 'tests/**' - 'tests/**'
- 'cassady-desktop/**'
- 'docs/**' - 'docs/**'
- 'plans/**' - 'plans/**'
- '*.md' - '*.md'
- 'Cargo.toml' - 'Cargo.toml'
- 'Cargo.lock' - 'Cargo.lock'
- 'build.rs' - 'build.rs'
- 'package.json'
- 'npm/**'
- '.github/workflows/**'
permissions: permissions:
contents: read contents: read
@@ -26,6 +30,18 @@ jobs:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Linux desktop build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libxdo-dev \
libssl-dev
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -42,6 +58,18 @@ jobs:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install Linux desktop build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libxdo-dev \
libssl-dev
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
+426
View File
@@ -0,0 +1,426 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build, for example v0.4.0'
required: true
type: string
replace_assets:
description: 'Delete existing release assets before uploading rebuilt assets'
required: true
default: true
type: boolean
publish_github_release:
description: 'Publish the GitHub release after uploading assets'
required: true
default: false
type: boolean
publish_npm:
description: 'Publish npm packages after release assets are built. Requires the NPM_TOKEN repository secret.'
required: true
default: false
type: boolean
permissions:
contents: write
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
jobs:
test:
name: Test release commit
runs-on: ubuntu-24.04
steps:
- name: Check out release ref
uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
- name: Install Linux desktop build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libxdo-dev \
libssl-dev
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Confirm version matches tag
run: |
version=$(awk -F\" '/^version = / { print $2; exit }' Cargo.toml)
test "${RELEASE_TAG}" = "v${version}"
- name: Run tests
run: cargo test --locked --all-targets
build-macos:
name: Build macOS Apple Silicon
needs: test
runs-on: macos-14
steps:
- name: Check out release ref
uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: cassady-desktop/package-lock.json
- name: Install desktop frontend dependencies
working-directory: cassady-desktop
run: npm ci
- name: Install Tauri CLI
run: cargo install tauri-cli --version 2.11.3 --locked
- name: Build CLI binaries
run: cargo build -p cassady --release --locked --target aarch64-apple-darwin --bin cass --bin cassady
- name: Build desktop binary
working-directory: cassady-desktop
run: cargo tauri build --target aarch64-apple-darwin --no-bundle -- --locked
- name: Package archive
env:
TRIPLE: aarch64-apple-darwin
run: |
set -euo pipefail
name="cassady-${RELEASE_TAG}-${TRIPLE}"
mkdir -p "dist/${name}"
cp "target/${TRIPLE}/release/cass" "dist/${name}/cass"
cp "target/${TRIPLE}/release/cassady" "dist/${name}/cassady"
cp "target/${TRIPLE}/release/cassady-desktop" "dist/${name}/cassady-desktop"
cp README.md "dist/${name}/README.md"
tar -C dist -czf "dist/${name}.tar.gz" "${name}"
(cd dist && shasum -a 256 "${name}.tar.gz" > "${name}.tar.gz.sha256")
- name: Upload release artifact
uses: actions/upload-artifact@v4
with:
name: release-aarch64-apple-darwin
path: |
dist/cassady-${{ env.RELEASE_TAG }}-aarch64-apple-darwin.tar.gz
dist/cassady-${{ env.RELEASE_TAG }}-aarch64-apple-darwin.tar.gz.sha256
if-no-files-found: error
build-linux:
name: Build Linux ${{ matrix.label }}
needs: test
strategy:
fail-fast: false
matrix:
include:
- label: x86_64
runner: ubuntu-24.04
triple: x86_64-unknown-linux-gnu
- label: ARM64
runner: ubuntu-24.04-arm
triple: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.runner }}
steps:
- name: Check out release ref
uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
- name: Install Linux desktop build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libxdo-dev \
libssl-dev \
patchelf
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.triple }}
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: cassady-desktop/package-lock.json
- name: Install desktop frontend dependencies
working-directory: cassady-desktop
run: npm ci
- name: Install Tauri CLI
run: cargo install tauri-cli --version 2.11.3 --locked
- name: Build CLI binaries
run: cargo build -p cassady --release --locked --target "${{ matrix.triple }}" --bin cass --bin cassady
- name: Build desktop binary
working-directory: cassady-desktop
run: cargo tauri build --target "${{ matrix.triple }}" --no-bundle -- --locked
- name: Package archive
env:
TRIPLE: ${{ matrix.triple }}
run: |
set -euo pipefail
name="cassady-${RELEASE_TAG}-${TRIPLE}"
mkdir -p "dist/${name}"
cp "target/${TRIPLE}/release/cass" "dist/${name}/cass"
cp "target/${TRIPLE}/release/cassady" "dist/${name}/cassady"
cp "target/${TRIPLE}/release/cassady-desktop" "dist/${name}/cassady-desktop"
cp README.md "dist/${name}/README.md"
tar -C dist -czf "dist/${name}.tar.gz" "${name}"
(cd dist && shasum -a 256 "${name}.tar.gz" > "${name}.tar.gz.sha256")
- name: Upload release artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.triple }}
path: |
dist/cassady-${{ env.RELEASE_TAG }}-${{ matrix.triple }}.tar.gz
dist/cassady-${{ env.RELEASE_TAG }}-${{ matrix.triple }}.tar.gz.sha256
if-no-files-found: error
build-windows-cli:
name: Build Windows x86_64 CLI
needs: test
runs-on: ubuntu-24.04
steps:
- name: Check out release ref
uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-gnu
- name: Cache Cargo artifacts
uses: Swatinem/rust-cache@v2
- name: Set up Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: 0.14.1
- name: Install cargo-zigbuild
run: cargo install cargo-zigbuild --locked
- name: Build Windows CLI binaries
run: cargo zigbuild -p cassady --release --locked --target x86_64-pc-windows-gnu --bin cass --bin cassady
- name: Package archive
env:
TRIPLE: x86_64-pc-windows-gnu
run: |
set -euo pipefail
name="cassady-${RELEASE_TAG}-${TRIPLE}"
mkdir -p "dist/${name}"
cp "target/${TRIPLE}/release/cass.exe" "dist/${name}/cass.exe"
cp "target/${TRIPLE}/release/cassady.exe" "dist/${name}/cassady.exe"
cp README.md "dist/${name}/README.md"
(cd dist && zip -qr "${name}.zip" "${name}")
(cd dist && shasum -a 256 "${name}.zip" > "${name}.zip.sha256")
- name: Upload release artifact
uses: actions/upload-artifact@v4
with:
name: release-x86_64-pc-windows-gnu
path: |
dist/cassady-${{ env.RELEASE_TAG }}-x86_64-pc-windows-gnu.zip
dist/cassady-${{ env.RELEASE_TAG }}-x86_64-pc-windows-gnu.zip.sha256
if-no-files-found: error
github-release:
name: Upload GitHub release assets
needs:
- build-macos
- build-linux
- build-windows-cli
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ github.token }}
REPLACE_ASSETS: ${{ github.event_name != 'workflow_dispatch' || inputs.replace_assets }}
PUBLISH_GITHUB_RELEASE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_github_release }}
steps:
- name: Check out release ref
uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
pattern: release-*
path: dist
merge-multiple: true
- name: Verify checksums
run: |
set -euo pipefail
ls -lh dist/cassady-${RELEASE_TAG}-*
for sum in dist/cassady-${RELEASE_TAG}-*.sha256; do
(cd dist && shasum -a 256 -c "$(basename "$sum")")
done
- name: Choose release notes
id: notes
run: |
set -euo pipefail
tag="${RELEASE_TAG}"
if [ -f "docs/releases/${tag}.md" ]; then
echo "file=docs/releases/${tag}.md" >> "$GITHUB_OUTPUT"
elif [ -f "dist/RELEASE_NOTES_${tag}.md" ]; then
echo "file=dist/RELEASE_NOTES_${tag}.md" >> "$GITHUB_OUTPUT"
else
printf '## Cassady %s\n\nRelease assets for Cassady %s.\n' "$tag" "$tag" > "dist/RELEASE_NOTES_${tag}.md"
echo "file=dist/RELEASE_NOTES_${tag}.md" >> "$GITHUB_OUTPUT"
fi
- name: Create or update release assets
run: |
set -euo pipefail
tag="${RELEASE_TAG}"
notes="${{ steps.notes.outputs.file }}"
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$tag" \
--repo "$GITHUB_REPOSITORY" \
--title "Cassady ${tag}" \
--notes-file "$notes" \
--draft \
--verify-tag
else
gh release edit "$tag" \
--repo "$GITHUB_REPOSITORY" \
--title "Cassady ${tag}" \
--notes-file "$notes"
fi
if [ "$REPLACE_ASSETS" = "true" ]; then
gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | while IFS= read -r asset; do
[ -z "$asset" ] && continue
gh release delete-asset "$tag" "$asset" --repo "$GITHUB_REPOSITORY" -y
done
fi
gh release upload "$tag" \
dist/cassady-${tag}-aarch64-apple-darwin.tar.gz \
dist/cassady-${tag}-aarch64-apple-darwin.tar.gz.sha256 \
dist/cassady-${tag}-x86_64-unknown-linux-gnu.tar.gz \
dist/cassady-${tag}-x86_64-unknown-linux-gnu.tar.gz.sha256 \
dist/cassady-${tag}-aarch64-unknown-linux-gnu.tar.gz \
dist/cassady-${tag}-aarch64-unknown-linux-gnu.tar.gz.sha256 \
dist/cassady-${tag}-x86_64-pc-windows-gnu.zip \
dist/cassady-${tag}-x86_64-pc-windows-gnu.zip.sha256 \
--repo "$GITHUB_REPOSITORY" \
--clobber
if [ "$PUBLISH_GITHUB_RELEASE" = "true" ]; then
release_id=$(gh release view "$tag" --repo "$GITHUB_REPOSITORY" --json databaseId --jq .databaseId)
gh api \
--method PATCH \
"repos/${GITHUB_REPOSITORY}/releases/${release_id}" \
-F draft=false \
-F prerelease=false \
-f make_latest=true >/dev/null
fi
npm-publish:
name: Publish npm packages
needs:
- github-release
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_npm }}
runs-on: ubuntu-24.04
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Check out release ref
uses: actions/checkout@v4
with:
ref: ${{ env.RELEASE_TAG }}
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
pattern: release-*
path: dist
merge-multiple: true
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org/
- name: Rehydrate target binaries from release archives
run: |
set -euo pipefail
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
echo "::error::Set the NPM_TOKEN repository secret before publishing npm packages."
exit 1
fi
for archive in dist/cassady-${RELEASE_TAG}-*.tar.gz; do
tar -xzf "$archive" -C dist
done
unzip -q -o "dist/cassady-${RELEASE_TAG}-x86_64-pc-windows-gnu.zip" -d dist
for triple in aarch64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
src="dist/cassady-${RELEASE_TAG}-${triple}"
dst="target/${triple}/release"
mkdir -p "$dst"
cp "$src/cass" "$dst/cass"
cp "$src/cassady" "$dst/cassady"
cp "$src/cassady-desktop" "$dst/cassady-desktop"
chmod 755 "$dst/cass" "$dst/cassady" "$dst/cassady-desktop"
done
triple=x86_64-pc-windows-gnu
src="dist/cassady-${RELEASE_TAG}-${triple}"
dst="target/${triple}/release"
mkdir -p "$dst"
cp "$src/cass.exe" "$dst/cass.exe"
cp "$src/cassady.exe" "$dst/cassady.exe"
- name: Publish to npm
run: npm run npm:publish
+3
View File
@@ -1,3 +1,6 @@
/target/ /target/
/dist/ /dist/
/site/ /site/
*node_modules/
*dist/
*.tsbuildinfo
+126 -7
View File
@@ -2,7 +2,26 @@
## Release process: build artifacts and create a draft GitHub release ## Release process: build artifacts and create a draft GitHub release
Use this process when preparing a Cassady (`cass`) release. Always create the GitHub release as a **draft** first; do not publish the final release unless the user explicitly asks. Use this process when preparing a Cassady (`cass`) release. Prefer the GitHub Actions release workflow over local cross-builds so macOS/Linux/Windows assets are built on runners instead of by hand. Always create the GitHub release as a **draft** first; do not publish the final release unless the user explicitly asks.
### 0. Preferred automated release workflow
The release workflow is `.github/workflows/release.yml`. On a pushed `v*` tag it builds and uploads draft GitHub release assets automatically. For an existing tag or to publish the release/npm packages, run it manually:
```sh
gh workflow run release.yml \
--repo owenqwenstarsky/cassady \
-f tag="$TAG" \
-f replace_assets=true \
-f publish_github_release=false \
-f publish_npm=false
```
Set `publish_github_release=true` only when the user explicitly asks to publish the GitHub release. Set `publish_npm=true` only when the user explicitly asks to publish npm packages, and make sure the `NPM_TOKEN` repository secret exists first. The workflow deletes/replaces old release assets, uploads the four current archives/checksums, and publishes npm packages from those runner-built archives.
Keep release notes in `docs/releases/${TAG}.md` when the workflow should apply curated notes. If that file is missing, the workflow falls back to generated placeholder notes.
Use the manual steps below only for local debugging or when the workflow cannot be used.
### 1. Review prior releases for consistency ### 1. Review prior releases for consistency
@@ -17,7 +36,7 @@ Keep release notes consistent with the existing format:
- Title: `## Cassady vX.Y.Z` - Title: `## Cassady vX.Y.Z`
- One short summary paragraph. - One short summary paragraph.
- `### Downloads` with four bullets in this order: macOS Apple Silicon, Linux x86_64, Linux ARM64, Windows x86_64. - `### Downloads` with four bullets in this order: macOS Apple Silicon, Linux x86_64, Linux ARM64, Windows x86_64.
- Note that each archive contains both `cass` and `cassady`; mention SHA-256 files. - Note that macOS/Linux archives contain `cass`, `cassady`, and `cassady-desktop`; the Windows archive contains `cass` and `cassady`; mention SHA-256 files.
- `### Highlights` with concise user-facing bullets. - `### Highlights` with concise user-facing bullets.
- Optional `### Upgrade notes` only when compatibility, config, or migration details matter. - Optional `### Upgrade notes` only when compatibility, config, or migration details matter.
- `### Install from source` with a `cargo install --git ... --tag vX.Y.Z` command. - `### Install from source` with a `cargo install --git ... --tag vX.Y.Z` command.
@@ -41,22 +60,30 @@ If the version is wrong, update `Cargo.toml` and `Cargo.lock` first, then commit
cargo +stable test --locked --all-targets cargo +stable test --locked --all-targets
``` ```
### 4. Build all release binaries ### 4. Build all release binaries manually only when needed
Prerequisites for cross-builds: stable Rust, `cargo-zigbuild`, Zig, and the needed Rust targets. The runner workflow should normally do this. Manual prerequisites for cross-builds are stable Rust, `cargo-zigbuild`, Tauri CLI, Zig, Node/npm, and the needed Rust targets.
```sh ```sh
rustup target add aarch64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-pc-windows-gnu rustup target add aarch64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-pc-windows-gnu
cargo install cargo-zigbuild --locked cargo install cargo-zigbuild tauri-cli --locked
``` ```
Build the same four targets used by previous releases: Build the same four CLI targets used by previous releases, then build desktop binaries for macOS/Linux. Do not build or upload DMG/installer artifacts unless the user explicitly asks.
```sh ```sh
cargo +stable build --release --locked --target aarch64-apple-darwin cargo +stable build --release --locked --target aarch64-apple-darwin
cargo +stable zigbuild --release --locked --target x86_64-unknown-linux-gnu cargo +stable zigbuild --release --locked --target x86_64-unknown-linux-gnu
cargo +stable zigbuild --release --locked --target aarch64-unknown-linux-gnu cargo +stable zigbuild --release --locked --target aarch64-unknown-linux-gnu
cargo +stable zigbuild --release --locked --target x86_64-pc-windows-gnu cargo +stable zigbuild --release --locked --target x86_64-pc-windows-gnu
(cd cassady-desktop && cargo tauri build --target aarch64-apple-darwin)
# Build Linux desktop binaries with `cargo tauri build` on Linux/Tauri builders. The packaging step accepts either:
# target/x86_64-unknown-linux-gnu/release/cassady-desktop
# target/aarch64-unknown-linux-gnu/release/cassady-desktop
# or the Tauri builder output paths:
# target/tauri-linux-x86_64/release/cassady-desktop
# target/tauri-linux-aarch64/release/cassady-desktop
``` ```
### 5. Rebuild the `dist/` artifacts ### 5. Rebuild the `dist/` artifacts
@@ -75,11 +102,40 @@ rm -rf \
mkdir -p dist mkdir -p dist
desktop_binary_for() {
case "$1" in
aarch64-apple-darwin)
if [ -f "target/aarch64-apple-darwin/release/cassady-desktop" ]; then
printf '%s\n' "target/aarch64-apple-darwin/release/cassady-desktop"
else
printf '%s\n' "target/release/cassady-desktop"
fi
;;
x86_64-unknown-linux-gnu)
if [ -f "target/x86_64-unknown-linux-gnu/release/cassady-desktop" ]; then
printf '%s\n' "target/x86_64-unknown-linux-gnu/release/cassady-desktop"
else
printf '%s\n' "target/tauri-linux-x86_64/release/cassady-desktop"
fi
;;
aarch64-unknown-linux-gnu)
if [ -f "target/aarch64-unknown-linux-gnu/release/cassady-desktop" ]; then
printf '%s\n' "target/aarch64-unknown-linux-gnu/release/cassady-desktop"
else
printf '%s\n' "target/tauri-linux-aarch64/release/cassady-desktop"
fi
;;
esac
}
for target in aarch64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do for target in aarch64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
name="cassady-${TAG}-${target}" name="cassady-${TAG}-${target}"
desktop_bin=$(desktop_binary_for "$target")
test -f "$desktop_bin"
mkdir -p "dist/${name}" mkdir -p "dist/${name}"
cp "target/${target}/release/cass" "dist/${name}/cass" cp "target/${target}/release/cass" "dist/${name}/cass"
cp "target/${target}/release/cassady" "dist/${name}/cassady" cp "target/${target}/release/cassady" "dist/${name}/cassady"
cp "$desktop_bin" "dist/${name}/cassady-desktop"
cp README.md "dist/${name}/README.md" cp README.md "dist/${name}/README.md"
tar -C dist -czf "dist/${name}.tar.gz" "${name}" tar -C dist -czf "dist/${name}.tar.gz" "${name}"
done done
@@ -103,6 +159,7 @@ Sanity-check the generated artifacts:
ls -lh dist/cassady-${TAG}-*.tar.gz dist/cassady-${TAG}-*.zip dist/cassady-${TAG}-*.sha256 ls -lh dist/cassady-${TAG}-*.tar.gz dist/cassady-${TAG}-*.zip dist/cassady-${TAG}-*.sha256
for sum in dist/cassady-${TAG}-*.sha256; do (cd dist && shasum -a 256 -c "$(basename "$sum")"); done for sum in dist/cassady-${TAG}-*.sha256; do (cd dist && shasum -a 256 -c "$(basename "$sum")"); done
tar -tzf "dist/cassady-${TAG}-aarch64-apple-darwin.tar.gz" | head tar -tzf "dist/cassady-${TAG}-aarch64-apple-darwin.tar.gz" | head
tar -tzf "dist/cassady-${TAG}-x86_64-unknown-linux-gnu.tar.gz" | grep cassady-desktop
unzip -l "dist/cassady-${TAG}-x86_64-pc-windows-gnu.zip" | head unzip -l "dist/cassady-${TAG}-x86_64-pc-windows-gnu.zip" | head
``` ```
@@ -122,7 +179,7 @@ Cassady vX.Y.Z ...
- Linux ARM64: `cassady-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz` - Linux ARM64: `cassady-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz`
- Windows x86_64: `cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip` - Windows x86_64: `cassady-vX.Y.Z-x86_64-pc-windows-gnu.zip`
Each archive contains both `cass` and `cassady`. SHA-256 checksum files are included for every archive. The macOS and Linux archives contain `cass`, `cassady`, and `cassady-desktop`; launch the desktop app with `cass desktop`. The Windows archive contains `cass` and `cassady`. SHA-256 checksum files are included for every archive.
### Highlights ### Highlights
@@ -148,6 +205,8 @@ cargo +stable build --release --locked --target aarch64-apple-darwin
cargo +stable zigbuild --release --locked --target x86_64-unknown-linux-gnu cargo +stable zigbuild --release --locked --target x86_64-unknown-linux-gnu
cargo +stable zigbuild --release --locked --target aarch64-unknown-linux-gnu cargo +stable zigbuild --release --locked --target aarch64-unknown-linux-gnu
cargo +stable zigbuild --release --locked --target x86_64-pc-windows-gnu cargo +stable zigbuild --release --locked --target x86_64-pc-windows-gnu
(cd cassady-desktop && cargo tauri build --target aarch64-apple-darwin)
# Linux desktop binaries built with `cargo tauri build` on Linux/Tauri builders and copied into the Linux archives.
``` ```
```` ````
@@ -199,6 +258,66 @@ gh release view "$TAG" --repo owenqwenstarsky/cassady --json tagName,name,isDraf
Do **not** publish the draft or mark it as the final/latest release unless the user explicitly asks. Do **not** publish the draft or mark it as the final/latest release unless the user explicitly asks.
## NPM package process: prepare and publish the binary packages
Use this process when preparing Cassady for npm. The npm distribution is a tiny wrapper package plus platform-specific binary packages. Do not publish to npm unless the user explicitly asks; when they ask for a publish-ready setup, make sure the packages can be published with one command.
### Package layout and names
The committed npm tooling generates packages under `dist/npm/` from the current Rust version in `Cargo.toml`:
- Wrapper package: `cassady`
- Exposes both npm binaries: `cass` and `cassady`.
- `cass desktop` launches the bundled desktop binary when the installed platform package includes one.
- Depends on the platform packages through `optionalDependencies` at the exact same version.
- Platform packages:
- `@cassady/cli-darwin-arm64` for `aarch64-apple-darwin`
- `@cassady/cli-linux-x64` for `x86_64-unknown-linux-gnu`
- `@cassady/cli-linux-arm64` for `aarch64-unknown-linux-gnu`
- `@cassady/cli-win32-x64` for `x86_64-pc-windows-gnu`
Before the first publish, make sure the npm account owns or has publish access to the `cassady` package name and the `@cassady` scope. If the npm package name or scope changes, update `npm/scripts/lib/release-config.mjs` and regenerate the packages. Scoped packages are published with `--access public`.
### 1. Build the release binaries first
Prefer the `.github/workflows/release.yml` workflow with `publish_npm=true`; it rebuilds release assets on runners, rehydrates the npm package inputs from those archives, and publishes with `NPM_TOKEN`. For manual publishing, the npm packages copy binaries from the release build outputs, so run the same verification and target builds used for the GitHub release first. macOS/Linux packages include `cassady-desktop` so `cass desktop` works from npm installs; Windows remains CLI-only for now.
```sh
cargo +stable test --locked --all-targets
cargo +stable build --release --locked --target aarch64-apple-darwin
cargo +stable zigbuild --release --locked --target x86_64-unknown-linux-gnu
cargo +stable zigbuild --release --locked --target aarch64-unknown-linux-gnu
cargo +stable zigbuild --release --locked --target x86_64-pc-windows-gnu
(cd cassady-desktop && cargo tauri build --target aarch64-apple-darwin)
# Linux desktop binaries built with `cargo tauri build` on Linux/Tauri builders, as in the GitHub release process.
```
### 2. Generate and verify the npm package directories
```sh
npm run npm:prepare
npm run npm:verify
```
`npm:prepare` rebuilds `dist/npm/` for the current `Cargo.toml` version. `npm:verify` runs `npm publish --dry-run` for each generated package and does not publish anything.
### 3. Publish to npm when explicitly requested
The publish command prepares packages, checks npm auth, runs `npm login` if needed, checks whether each package version already exists, publishes platform packages first, publishes the wrapper last, and verifies that the published versions can be packed from npm. The existence/verification checks intentionally use `npm pack --dry-run` instead of `npm view` because npm registry metadata for newly-created scoped packages can briefly return 404 even after publish succeeds.
```sh
npm run npm:publish
```
Optional checks and controls:
```sh
npm run npm:publish -- --dry-run # full publish flow without publishing
NPM_TAG=next npm run npm:publish # publish under a non-latest dist-tag
```
If a version already exists, npm cannot overwrite it. Stop and ask before changing versions, deleting packages, or moving dist-tags.
## Roadmap process: write a new release entry in `ROADMAP.md` ## Roadmap process: write a new release entry in `ROADMAP.md`
Use this process when planning a future Cassady release. Follow the existing newest-first format in `ROADMAP.md` so new entries look like prior releases. Use this process when planning a future Cassady release. Follow the existing newest-first format in `ROADMAP.md` so new entries look like prior releases.
Generated
+2792 -66
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cassady" name = "cassady"
version = "0.3.4" version = "0.4.2"
edition = "2021" edition = "2021"
description = "Cassady/Cass minimal terminal coding agent" description = "Cassady/Cass minimal terminal coding agent"
license = "MIT" license = "MIT"
@@ -28,6 +28,7 @@ dirs = "5"
futures-util = "0.3" futures-util = "0.3"
ignore = "0.4" ignore = "0.4"
include_dir = "0.7" include_dir = "0.7"
libc = "0.2"
nanoid = "0.4" nanoid = "0.4"
ratatui = { version = "0.30", default-features = false, features = ["crossterm", "underline-color"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm", "underline-color"] }
regex = "1" regex = "1"
@@ -47,3 +48,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
wiremock = "0.6" wiremock = "0.6"
[workspace]
members = ["cassady-desktop/src-tauri"]
resolver = "2"
+26 -7
View File
@@ -1,5 +1,7 @@
# Cassady / Cass # Cassady / Cass
> ⚠️ This project is in early beta. It may not run perfectly on all hardware or operating systems, and setup may require some manual work.
Cassady (`cass`) is a terminal coding agent written in Rust. It runs an interactive chat in your project, can inspect files, apply exact edits, run shell commands when the active safety mode allows them, and persist sessions for later resume. Cassady talks to OpenAI-compatible providers and a built-in ChatGPT Codex provider preset. Cassady (`cass`) is a terminal coding agent written in Rust. It runs an interactive chat in your project, can inspect files, apply exact edits, run shell commands when the active safety mode allows them, and persist sessions for later resume. Cassady talks to OpenAI-compatible providers and a built-in ChatGPT Codex provider preset.
The project installs two equivalent commands, `cass` and `cassady`; examples use `cass`. The project installs two equivalent commands, `cass` and `cassady`; examples use `cass`.
@@ -7,16 +9,18 @@ The project installs two equivalent commands, `cass` and `cassady`; examples use
## Current scope and limitations ## Current scope and limitations
- Provider support includes OpenAI-compatible chat/completions APIs plus the `ChatGPT Codex` preset for users already signed in to Codex. - Provider support includes OpenAI-compatible chat/completions APIs plus the `ChatGPT Codex` preset for users already signed in to Codex.
- The primary interface is an interactive terminal UI. - The primary interface is an interactive terminal UI; v0.4.0 also includes a desktop app preview launched with `cass desktop` on macOS/Linux binary installs.
- v0.2.6 adds an experimental Rust embedding API for headless sessions; it is useful for early integrations but not yet a stable long-term library contract. - An experimental Rust embedding API for headless sessions is available (added in v0.2.6); it is useful for early integrations but not yet a stable long-term library contract.
- Config and conversation state live under `~/.cass`. - Config and conversation state live under `~/.cass`.
- Windows binaries are built for releases, but deeper Windows terminal, path, shell, and filesystem polish is planned for a later release. - Windows binaries are built for releases, but deeper Windows terminal, path, shell, and filesystem polish is planned for a later release.
- `cass update` can update release-archive installs from official GitHub releases; external package managers should still be updated through their own tools. - `cass update` can update release-archive installs from official GitHub releases, including the companion desktop binary when present in a prebuilt archive; external package managers should still be updated through their own tools.
## Install from source ## Install with npm
The recommended installation method is npm:
```sh ```sh
cargo install --path . npm install -g cassady
``` ```
This installs both commands: This installs both commands:
@@ -26,6 +30,14 @@ cass --version
cassady --version cassady --version
``` ```
The npm package installs a small launcher plus the matching Rust-built binary package for your platform. macOS and Linux packages include the `cassady-desktop` companion used by `cass desktop`; Windows remains CLI-only for now. Supported npm platforms are macOS Apple Silicon, Linux x86_64, Linux ARM64, and Windows x86_64.
## Install from source
```sh
cargo install --path .
```
## First use ## First use
Start Cassady in a project directory: Start Cassady in a project directory:
@@ -66,6 +78,7 @@ Run `cass check` any time to validate JSON config, provider/model references, ac
cass [--model MODEL] [--cwd PATH] cass [--model MODEL] [--cwd PATH]
cass --resume <chat-id> cass --resume <chat-id>
cass --resume cass --resume
cass desktop
cass check cass check
cass login cass login
cass logout cass logout
@@ -73,7 +86,7 @@ cass setup
cass update cass update
``` ```
`cass --resume` without an id lists saved chats for the current directory. `cass update` checks official GitHub releases and can update both `cass` and `cassady` in the current install directory. When Cassady exits a chat, it prints a resume command for that session. `cass --resume` without an id lists saved chats for the current directory. `cass desktop` launches the desktop app in the background when a release-built `cassady-desktop` binary is installed next to `cass` or on `PATH`; after it starts, you can close the terminal. Cassady refuses desktop binaries that do not contain the bundled frontend assets because they open as a blank window. `cass update` checks official GitHub releases and can update `cass`, `cassady`, and the companion desktop binary when present in the selected prebuilt archive. When Cassady exits a chat, it prints a resume command for that session.
Common in-chat commands: Common in-chat commands:
@@ -108,6 +121,12 @@ Cassady exposes tools according to the active access mode:
Use `--readonly`, `--workspace-edit`, or `--full-access` to choose a mode at launch, or press `Shift-Tab` while idle. Use `--readonly`, `--workspace-edit`, or `--full-access` to choose a mode at launch, or press `Shift-Tab` while idle.
## Tool output and context recovery
Cassady keeps tool calls reviewable while fitting provider context windows. Very large tool results may be sent to the model as compacted or truncated head/tail excerpts with a note that names the tool, retained excerpt shape, file ranges or command provenance when available, and suggested follow-up reads/searches. Treat those notes as incomplete evidence: ask Cass to re-read a narrower line range, run a focused `grep`, or rerun a narrower shell command before editing from omitted details.
Conversation files keep the recorded tool result content; model-facing compaction happens when preparing provider messages. The compact/full tool-output UI toggle only changes display.
## Branch and restore ## Branch and restore
Press `Esc` twice while idle, or type `/branch`, to browse the current conversation's branch family. Selecting an earlier user message, assistant message, tool call, or tool result creates a new branch conversation instead of truncating the original chat. The menu also lets you switch back to related branches later. Press `Esc` twice while idle, or type `/branch`, to browse the current conversation's branch family. Selecting an earlier user message, assistant message, tool call, or tool result creates a new branch conversation instead of truncating the original chat. The menu also lets you switch back to related branches later.
@@ -142,7 +161,7 @@ let session = SessionBuilder::new()
.await?; .await?;
``` ```
See [Experimental Rust embedding API](docs/embedding.md) for session creation, streamed events, approval handling, cancellation, and current limitations. See [Experimental Rust embedding API](docs/embedding.md) for session creation, streamed events, approval handling, cancellation, and current limitations. The desktop app is an in-repo reference embedding built on that API.
## More documentation ## More documentation
+115 -33
View File
@@ -1,6 +1,118 @@
# Cassady (Cass) Roadmap # Cassady (Cass) Roadmap
## v0.3.3 — Codex Fast-Mode Compatibility ## v0.4.2 — Desktop Slash Commands
This release focuses on full slash-command support in the desktop app and on moving the command system out of the CLI into a shared core module so the TUI and the desktop share one registry, parser, autocomplete, and execution path. The desktop composer now surfaces a filterable slash menu with auto-run, and the interactive commands (`/login`, `/logout`, `/branch`) open native modals. See `plans/DESKTOP_SLASH_COMMANDS_PLAN.md`.
### Shared Command Core
- [x] **Add the `cassady::commands` module.** Move the command catalog (`COMMANDS`/`CommandSpec`), `LocalCommand`/`FastModeCommand` enums, `parse`, the autocomplete builders (`build_autofill` + `command_autofill`/`model_autofill`/`resume_chat_autofill`), and the pure action functions out of `src/app.rs` into a new public core module.
- Move `AutoFillItem`/`AutoFillMenu` from `ui/autofill.rs` into `commands` (with serde derives); `ui/autofill` re-exports them so the TUI renderer is unchanged.
- Add `CommandOutcome`/`CommandContext`/`execute` as the single entry point both hosts use; interactive commands return `Open*Picker` outcomes so each host renders its own picker.
- [x] **Refactor the CLI dispatcher onto the shared module.** Replace the inline `match parse_local_command` block and the private helpers in `src/app.rs` with `commands::execute` + an outcome match; delete the duplicated code. CLI user-facing behavior is unchanged.
- [x] **Expose `embedding::Session` accessors.** Add `config`/`config_mut`/`conversation`/`set_conversation` and refactor `Session::set_model` to delegate to `commands::apply_model_selection`, removing the duplicated resolve/switch logic.
### Desktop Slash Surface
- [x] **Add a slash-command popup to the composer.** `SlashMenu` reuses the `ModelSelector` pattern (filter, arrow-key nav, grouped headers, click-outside) and auto-runs complete commands on selection; value-command names insert and keep the menu open for argument selection. Typing a complete command and pressing Enter also runs it.
- Add Tauri commands `list_slash_commands`, `slash_autofill`, `run_slash_command`, plus `create_branch_from_checkpoint`, `apply_provider_login`, `discover_models`, `remove_providers`, and `list_provider_catalog`, with camelCase DTOs and TS mirrors.
- [x] **Route command outcomes in the desktop shell.** `/fast`, `/status`, and `/model` push status blocks and update the status footer; `/new` and `/resume` install the new conversation and rebuild the transcript (including resume warnings); busy/parse/error outcomes update the status line.
### Interactive Command Modals
- [x] **Add a branch/restore modal.** `BranchModal` lists the branch family, supports switching to a branch or branching from a checkpoint (conversation-only or with tracked-file restore), and reports the restore outcome in the transcript.
- [x] **Add a logout modal.** `LogoutModal` multi-selects configured providers, removes them via `remove_providers`, refreshes the model list, and reports the result.
- [x] **Add a login wizard modal.** `LoginModal` picks a provider from the catalog, configures base URL / API-key env / model id, and applies via `apply_provider_login`.
### Verification
- [x] **Build, test, and document.** `cargo test --locked --all-targets`, `cargo fmt --check`, `cargo clippy` (no new warnings), the desktop `npm run typecheck` and `npm run build` all pass. Update `docs/commands.md` to note desktop parity.
## v0.4.0 — Desktop App
This release focuses on adding a native desktop app for Cassady that runs the real coding agent via the experimental Rust embedding API, themed to match the `cassady-web/` landing page. The desktop app is a peer of the TUI: same config, providers, tools, safety policy, and JSONL storage, with a windowed chat surface for streaming transcripts, tool calls, approvals, and cancellation. See `plans/V0_4_0_DESKTOP_APP_PLAN.md`.
### Workspace Setup
- [ ] **Add the `cassady-desktop` workspace member.** Convert the root `Cargo.toml` to a Cargo workspace with `members = ["cassady-desktop"]` while keeping the existing `cassady` package at the repo root.
- Depend on `cassady` via a path dependency; embed through the public `cassady::prelude` API only.
- Do not modify `src/` (the existing lib, bins, or TUI).
### Design System Port
- [ ] **Port the landing-page design system into the desktop frontend.** Copy `cassady-web/src/index.css` (tokens, scanlines, grain, vignette, glow utilities) and IBM Plex `@fontsource` setup into the Tauri frontend.
- Reuse `cass-logo-transparent.png` for the app icon and in-app branding.
- Copy the shadcn `ui/` primitives (button, card, separator, tooltip, accordion) so chrome matches the landing page.
### Chat Surface
- [ ] **Build the streaming chat UI.** Composer, transcript with full markdown rendering for assistant text, tool-call blocks for `read`/`write`/`edit`/`shell`/`grep`/`ls`, reasoning toggle, and a terminal-style status footer.
- Stream events over `tauri::ipc::Channel<StreamEvent>` from a per-turn Rust worker that drives `Session`/`Turn`/`Event`.
- Approve or deny `ApprovalRequested` events through a native dialog; cancel running turns with `Turn::cancel`.
### Session Lifecycle
- [ ] **Make desktop and CLI sessions interoperable.** New chat, resume by id, and list chats for the current cwd all use the same `~/.cass/conversations/*.jsonl` storage.
- A chat started in the desktop app is resumable from `cass --resume <id>`, and a CLI-started chat is openable in the desktop app.
- Show a "run `cass setup` first" card when `~/.cass` has no valid provider config; a setup-wizard GUI is deferred.
### Verification
- [ ] **Build, test, and document the desktop app.** `cargo build -p cassady-desktop`, `npm run tauri build`, and a manual smoke test across new chat, streaming, approval, cancel, and resume interop.
- Add Rust tests for `StreamEvent` mapping and `SessionBuilder` round-trip against a temp `config_root`.
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass across the workspace.
## v0.3.5 — Tool Output Context Reliability ✅ Completed
This release focuses on making large tool outputs easier for the assistant to recover from when model-context compaction or truncation hides important details. Cassady should guide the assistant toward smaller, targeted reads and searches, preserve enough provenance for follow-up inspection, and add regression coverage for broad-output workflows that previously stalled safe edits. See `plans/V0_3_5_TOOL_OUTPUT_CONTEXT_RELIABILITY_PLAN.md`.
### Model Context Recovery
- [x] **Improve compacted tool-output guidance.** Replace generic head/tail compaction notices with actionable guidance that tells the assistant what was omitted and how to inspect it again safely.
- Include tool name, output size, retained excerpt shape, and suggested narrower follow-up reads or searches when available.
- Keep model-facing guidance concise enough that it does not worsen context pressure.
- [x] **Preserve targeted reinspection metadata.** Track enough structured context for large reads and command output so the assistant can recover omitted details without repeating broad requests.
- For file reads, preserve path and line-range coverage even after compaction.
- For shell and search output, prefer guidance toward narrower commands or `grep`/`read` follow-ups rather than blindly rerunning the same broad command.
### Tool Behavior and Prompting
- [x] **Bias tool use toward smaller inspections.** Update tool descriptions, prompt guidance, and result messages so broad reads become a fallback rather than the default.
- Encourage search-first workflows for large files and unknown locations.
- Mention result limits before or at truncation points so the assistant knows when context may be incomplete.
- [x] **Make truncation and compaction visible across layers.** Align model-facing messages, stored conversation records, and UI summaries so users and the assistant can tell when output was incomplete.
- Do not let UI-only collapsed output change what is stored or sent to the model.
- Keep existing conversation files readable and resumable.
### Validation
- [x] **Add regression coverage for broad-output recovery.** Test workflows where an early broad read or command output is compacted before the assistant needs exact context for an edit.
- Cover superseded reads, compacted non-newest tool outputs, provider-message validity, and suggested follow-up guidance.
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
## v0.3.4 — Reasoning Off Handling ✅ Completed
This release focuses on sending the correct reasoning effort value when a reasoning-capable model has reasoning turned off. Cassady now sends `none` rather than omitting the field or sending `off` to both the ChatGPT Codex Responses API and OpenAI-compatible chat completions, while non-reasoning models continue to send no reasoning field at all.
### Reasoning Effort Off
- [x] **Send `none` when reasoning is off for supported models.** For reasoning-capable models with reasoning effort set to off, send `none` as the effort value instead of omitting the field or sending `off`.
- Apply to the ChatGPT Codex Responses API (`reasoning.effort`) and OpenAI-compatible chat completions in both `reasoning_effort` and `reasoning` object request formats.
- Keep non-reasoning models sending no reasoning field.
- [x] **Gate reasoning fields on model capability.** Track whether the active model supports reasoning from its metadata so reasoning request fields are only added for reasoning-capable models.
### Validation
- [x] **Add regression coverage for reasoning-off requests.** Test that supported models send `none` when reasoning is off across both provider kinds and both request formats, and that unsupported models send no reasoning field.
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
## v0.3.3 — Codex Fast-Mode Compatibility ✅ Completed
This release focuses on keeping fast mode available for ChatGPT Codex users when local model metadata predates the fast-mode capability flag. Cassady should treat active `chatgpt-codex` provider models, including `gpt-5.5`, as fast-capable while leaving OpenAI-compatible and custom providers capability-gated by model metadata. This release focuses on keeping fast mode available for ChatGPT Codex users when local model metadata predates the fast-mode capability flag. Cassady should treat active `chatgpt-codex` provider models, including `gpt-5.5`, as fast-capable while leaving OpenAI-compatible and custom providers capability-gated by model metadata.
@@ -16,37 +128,7 @@ This release focuses on keeping fast mode available for ChatGPT Codex users when
- [x] **Add regression coverage for legacy metadata.** Test that a ChatGPT Codex model with older `fast_mode.supported: false` metadata still reports fast mode as supported and active when preferred. - [x] **Add regression coverage for legacy metadata.** Test that a ChatGPT Codex model with older `fast_mode.supported: false` metadata still reports fast mode as supported and active when preferred.
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff. - Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
## v0.3.4 — Tool Output Context Reliability ## v0.3.2 — Provider Fast Mode ✅ Completed
This release focuses on making large tool outputs easier for the assistant to recover from when model-context compaction or truncation hides important details. Cassady should guide the assistant toward smaller, targeted reads and searches, preserve enough provenance for follow-up inspection, and add regression coverage for broad-output workflows that previously stalled safe edits. See `plans/V0_3_4_TOOL_OUTPUT_CONTEXT_RELIABILITY_PLAN.md`.
### Model Context Recovery
- [ ] **Improve compacted tool-output guidance.** Replace generic head/tail compaction notices with actionable guidance that tells the assistant what was omitted and how to inspect it again safely.
- Include tool name, output size, retained excerpt shape, and suggested narrower follow-up reads or searches when available.
- Keep model-facing guidance concise enough that it does not worsen context pressure.
- [ ] **Preserve targeted reinspection metadata.** Track enough structured context for large reads and command output so the assistant can recover omitted details without repeating broad requests.
- For file reads, preserve path and line-range coverage even after compaction.
- For shell and search output, prefer guidance toward narrower commands or `grep`/`read` follow-ups rather than blindly rerunning the same broad command.
### Tool Behavior and Prompting
- [ ] **Bias tool use toward smaller inspections.** Update tool descriptions, prompt guidance, and result messages so broad reads become a fallback rather than the default.
- Encourage search-first workflows for large files and unknown locations.
- Mention result limits before or at truncation points so the assistant knows when context may be incomplete.
- [ ] **Make truncation and compaction visible across layers.** Align model-facing messages, stored conversation records, and UI summaries so users and the assistant can tell when output was incomplete.
- Do not let UI-only collapsed output change what is stored or sent to the model.
- Keep existing conversation files readable and resumable.
### Validation
- [ ] **Add regression coverage for broad-output recovery.** Test workflows where an early broad read or command output is compacted before the assistant needs exact context for an edit.
- Cover superseded reads, compacted non-newest tool outputs, provider-message validity, and suggested follow-up guidance.
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
## v0.3.2 — Provider Fast Mode
This release focuses on adding a `/fast` command that lets users prefer faster inference when the active provider/model supports it. The first supported provider is `ChatGPT Codex`; other providers can add their own fast-mode request behavior later without changing the user-facing command. See `plans/V0_3_2_FAST_MODE_PLAN.md`. This release focuses on adding a `/fast` command that lets users prefer faster inference when the active provider/model supports it. The first supported provider is `ChatGPT Codex`; other providers can add their own fast-mode request behavior later without changing the user-facing command. See `plans/V0_3_2_FAST_MODE_PLAN.md`.
@@ -78,7 +160,7 @@ This release focuses on adding a `/fast` command that lets users prefer faster i
- [ ] **Test fast-mode preference, switching, and provider requests.** Cover command parsing, persistence, status rendering, model/provider switching, and Codex request body behavior. - [ ] **Test fast-mode preference, switching, and provider requests.** Cover command parsing, persistence, status rendering, model/provider switching, and Codex request body behavior.
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff. - Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
## v0.3.1 — Transcript Scroll Stability ## v0.3.1 — Transcript Scroll Stability ✅ Completed
This release focuses on keeping the live transcript anchored correctly above the input and footer during long sessions with blank reasoning or tool-output lines. This release focuses on keeping the live transcript anchored correctly above the input and footer during long sessions with blank reasoning or tool-output lines.
+45
View File
@@ -0,0 +1,45 @@
# Cassady Desktop
Cassady Desktop is the v0.4.0 desktop preview. It uses the same Cassady config, providers, tools, approval policy, and `~/.cass/conversations/*.jsonl` sessions as the terminal CLI.
## Launch from a release archive
macOS and Linux release archives include three binaries:
- `cass`
- `cassady`
- `cassady-desktop`
Keep them in the same directory or put all three on `PATH`, then launch the desktop app with:
```sh
cass desktop
```
`cass desktop` starts `cassady-desktop` as a detached background process, so you can close the terminal after the window opens. For debugging, keep it attached with:
```sh
cass desktop --foreground
```
If the desktop binary is somewhere else, set:
```sh
CASSADY_DESKTOP_BIN=/path/to/cassady-desktop cass desktop
```
Run `cass setup` first if Cassady has no configured provider.
## Development
From the repository root:
```sh
cd cassady-desktop
npm install
cargo tauri build --target aarch64-apple-darwin --no-bundle -- --locked
```
`cargo tauri build` runs the frontend build and embeds the generated assets into the standalone binary. Release Cargo builds now fail fast when `cassady-desktop/dist` is missing or incomplete, and `cass desktop` rejects desktop binaries that do not contain the bundled frontend assets. A plain debug `cargo build -p cassady-desktop` is useful for Rust checks, but do not package that output.
For a GUI dev session, run the Vite dev server and the Tauri app using your local Tauri workflow. Release packaging uses the raw `cassady-desktop` binary, not DMG, app bundle, AppImage, MSI, or other installer artifacts.
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b0d10" />
<title>Cassady</title>
<style>
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background: #0b0d10;
color: #8b929d;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
</style>
</head>
<body>
<div id="root">loading Cassady…</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4662
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "cassady-desktop-frontend",
"private": true,
"version": "0.4.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --noEmit",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.2.8",
"@radix-ui/react-accordion": "^1.2.14",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@tauri-apps/api": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^11.15.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^26.0.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "cassady-desktop"
version = "0.4.2"
edition = "2021"
description = "Cassady desktop app"
license = "MIT"
[lib]
name = "cassady_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
cassady = { path = "../.." }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] }
+82
View File
@@ -0,0 +1,82 @@
use std::env;
use std::fs;
use std::path::Path;
fn main() {
// Tauri embeds the built frontend into release binaries. `cargo tauri build`
// runs the frontend build before Cargo, so make Cargo rerun this build script
// whenever the generated frontend changes; otherwise a standalone desktop
// binary can accidentally contain stale or missing assets and open to a
// blank WebView.
println!("cargo:rerun-if-changed=../dist/index.html");
println!("cargo:rerun-if-changed=../dist/assets");
println!("cargo:rerun-if-changed=../index.html");
println!("cargo:rerun-if-changed=../src");
println!("cargo:rerun-if-changed=../vite.config.ts");
println!("cargo:rerun-if-changed=../package.json");
println!("cargo:rerun-if-changed=../package-lock.json");
if env::var("PROFILE").as_deref() == Ok("release") {
validate_release_build_mode();
validate_frontend_dist();
}
tauri_build::build()
}
fn validate_release_build_mode() {
if env::var("DEP_TAURI_DEV").as_deref() == Ok("false") {
return;
}
panic!(
"Cassady desktop release binaries must be built with `cargo tauri build --no-bundle` so Tauri enables custom-protocol asset embedding. Plain `cargo build --release -p cassady-desktop` builds a dev-url binary that opens as a blank window when the Vite server is not running."
);
}
fn validate_frontend_dist() {
let index = Path::new("../dist/index.html");
let assets_dir = Path::new("../dist/assets");
if !index.is_file() {
panic!(
"Cassady desktop release builds require built frontend assets. Run `npm install` and `npm run build` in cassady-desktop, or use `cargo tauri build --no-bundle`, before building the desktop binary."
);
}
let html = fs::read_to_string(index).unwrap_or_else(|error| {
panic!("failed to read {}: {error}", index.display());
});
if !html.contains("assets/index-") {
panic!(
"{} does not reference the Vite-built frontend assets. Run `npm run build` in cassady-desktop before building the release desktop binary.",
index.display()
);
}
if !assets_dir.is_dir() {
panic!(
"{} is missing. Run `npm run build` in cassady-desktop before building the release desktop binary.",
assets_dir.display()
);
}
let mut has_js = false;
let mut has_css = false;
for entry in fs::read_dir(assets_dir).unwrap_or_else(|error| {
panic!("failed to read {}: {error}", assets_dir.display());
}) {
let path = entry
.unwrap_or_else(|error| panic!("failed to read asset entry: {error}"))
.path();
has_js |= path.extension().and_then(|extension| extension.to_str()) == Some("js");
has_css |= path.extension().and_then(|extension| extension.to_str()) == Some("css");
}
if !has_js || !has_css {
panic!(
"{} must contain Vite-built JavaScript and CSS assets. Run `npm run build` in cassady-desktop before building the release desktop binary.",
assets_dir.display()
);
}
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+37
View File
@@ -0,0 +1,37 @@
mod session;
mod state;
mod turn;
mod types;
use state::DesktopState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(DesktopState::new())
.invoke_handler(tauri::generate_handler![
session::new_session,
session::resume_session,
session::get_cwd,
session::list_chats_cmd,
session::session_info,
session::session_records,
session::list_models_cmd,
session::update_session_settings,
session::reload_session_config,
session::list_slash_commands,
session::slash_autofill,
session::run_slash_command,
session::create_branch_from_checkpoint,
session::apply_provider_login,
session::discover_models,
session::remove_providers,
session::list_provider_catalog,
turn::start_turn,
turn::approve,
turn::deny,
turn::cancel_turn,
])
.run(tauri::generate_context!())
.expect("error while running cassady desktop");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
cassady_desktop_lib::run()
}
+434
View File
@@ -0,0 +1,434 @@
use crate::state::DesktopState;
use crate::types::{
ApplyProviderLoginArgs, AutoFillMenuDto, BranchResultDto, ChatSummaryDto, CommandOutcomeDto,
CommandSpecDto, ConversationInfoDto, CreateBranchArgs, DiscoverModelsArgs, ListChatsArgs,
LogoutResultDto, ModelOptionDto, NewSessionArgs, ProviderApplyResultDto,
ProviderLogoutCandidateDto, RemoveProvidersArgs, RestoreReportDto, ResumeSessionArgs,
RunSlashCommandArgs, SessionIdArgs, SlashAutofillArgs, UpdateSessionSettingsArgs,
};
use cassady::commands::{self, CommandContext, CommandOutcome};
use cassady::config::{cass_root, load_or_create_default_model_registry, save_last_used_provider};
use cassady::conversation::list_chats as list_chats_fn;
use cassady::embedding::SessionBuilder;
use cassady::setup::SetupSelection;
use std::path::PathBuf;
use tauri::State;
#[tauri::command]
pub async fn new_session(
state: State<'_, DesktopState>,
args: NewSessionArgs,
) -> Result<ConversationInfoDto, String> {
let mut builder = SessionBuilder::new();
if let Some(cwd) = args.cwd {
builder = builder.cwd(cwd);
}
if let Some(mode) = args.access_mode {
builder = builder.access_mode(mode);
}
if let Some(model) = args.model {
builder = builder.model(model);
}
if let Some(base_url) = args.base_url {
builder = builder.base_url(base_url);
}
if let Some(api_key_env) = args.api_key_env {
builder = builder.api_key_env(api_key_env);
}
if let Some(effort) = args.reasoning_effort {
builder = builder.reasoning_effort(effort);
}
let session = builder.new_session().await.map_err(|e| e.to_string())?;
let info: ConversationInfoDto = session.info().into();
state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?
.insert(info.id.clone(), session);
Ok(info)
}
#[tauri::command]
pub async fn resume_session(
state: State<'_, DesktopState>,
args: ResumeSessionArgs,
) -> Result<ConversationInfoDto, String> {
let mut builder = SessionBuilder::new();
if let Some(cwd) = args.cwd {
builder = builder.cwd(cwd);
}
let session = builder
.resume(args.chat_id)
.await
.map_err(|e| e.to_string())?;
let info: ConversationInfoDto = session.info().into();
state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?
.insert(info.id.clone(), session);
Ok(info)
}
#[tauri::command]
pub fn list_chats_cmd(
_state: State<'_, DesktopState>,
args: ListChatsArgs,
) -> Result<Vec<ChatSummaryDto>, String> {
let root = cass_root();
let conversations_dir = root.join("conversations");
let cwd = match args.cwd {
Some(cwd) => PathBuf::from(cwd),
None => std::env::current_dir().map_err(|e| e.to_string())?,
};
let cwd = cwd.canonicalize().map_err(|e| e.to_string())?;
let summaries = list_chats_fn(&conversations_dir, &cwd).map_err(|e| e.to_string())?;
Ok(summaries.into_iter().map(ChatSummaryDto::from).collect())
}
#[tauri::command]
pub fn session_info(
state: State<'_, DesktopState>,
args: SessionIdArgs,
) -> Result<ConversationInfoDto, String> {
let info = {
let sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let session = sessions
.get(&args.chat_id)
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
session.info()
};
Ok(info.into())
}
#[tauri::command]
pub fn get_cwd() -> Result<String, String> {
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
Ok(cwd.display().to_string())
}
#[tauri::command]
pub fn session_records(
state: State<'_, DesktopState>,
args: SessionIdArgs,
) -> Result<serde_json::Value, String> {
let records = {
let sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let session = sessions
.get(&args.chat_id)
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
session.records().to_vec()
};
serde_json::to_value(records).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_models_cmd() -> Result<Vec<ModelOptionDto>, String> {
let root = cass_root();
let models = load_or_create_default_model_registry(&root).map_err(|e| e.to_string())?;
Ok(models
.models
.into_iter()
.map(ModelOptionDto::from)
.collect())
}
#[tauri::command]
pub fn update_session_settings(
state: State<'_, DesktopState>,
args: UpdateSessionSettingsArgs,
) -> Result<ConversationInfoDto, String> {
{
let turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
if turns.values().any(|entry| entry.chat_id == args.chat_id) {
return Err("settings can be changed when idle".to_string());
}
}
let info = {
let mut sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let session = sessions
.get_mut(&args.chat_id)
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
if let Some(mode) = args.access_mode {
session.set_access_mode(mode);
}
if let Some(model) = args.model {
session.set_model(model).map_err(|e| e.to_string())?;
}
if let Some(effort) = args.reasoning_effort {
session.set_reasoning_effort(effort);
}
let info = session.info();
let _ = save_last_used_provider(
&cass_root(),
session.provider_id(),
session.model(),
session.reasoning_effort(),
);
info
};
Ok(info.into())
}
/// Reload the session's resolved config from disk, used after `/login` or
/// `/logout` change the active provider/model outside the session. Returns
/// the updated conversation info so the host can refresh its display.
#[tauri::command]
pub fn reload_session_config(
state: State<'_, DesktopState>,
args: SessionIdArgs,
) -> Result<ConversationInfoDto, String> {
let info = {
let mut sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let session = sessions
.get_mut(&args.chat_id)
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
session.reload_config().map_err(|e| e.to_string())?;
session.info()
};
Ok(info.into())
}
// --- Slash commands ---------------------------------------------------------
#[tauri::command]
pub fn list_slash_commands() -> Result<Vec<CommandSpecDto>, String> {
Ok(commands::COMMANDS
.iter()
.map(|spec| (*spec).into())
.collect())
}
#[tauri::command]
pub fn list_provider_catalog() -> Result<Vec<crate::types::ProviderCatalogEntryDto>, String> {
Ok(commands::login_catalog()
.into_iter()
.map(crate::types::ProviderCatalogEntryDto::from)
.collect())
}
#[tauri::command]
pub fn slash_autofill(args: SlashAutofillArgs) -> Result<Option<AutoFillMenuDto>, String> {
let root = cass_root();
let config = commands::reload_config(&root).map_err(|e| e.to_string())?;
let cwd = match args.cwd {
Some(cwd) => PathBuf::from(cwd),
None => std::env::current_dir().map_err(|e| e.to_string())?,
};
let cwd = cwd.canonicalize().map_err(|e| e.to_string())?;
let menu =
commands::build_autofill(&args.input, 0, &config, &cwd).map_err(|e| e.to_string())?;
Ok(menu.map(AutoFillMenuDto::from))
}
#[tauri::command]
pub fn run_slash_command(
state: State<'_, DesktopState>,
args: RunSlashCommandArgs,
) -> Result<CommandOutcomeDto, String> {
let busy = {
let turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
turns.values().any(|entry| entry.chat_id == args.chat_id)
};
// Clone the session state out so `execute` can mutate a owned config copy
// without holding simultaneous &mut config / &conversation borrows of the
// session. Mutations are written back after the call.
let (mut config, conversation, mut reasoning_effort, cwd, access_mode) = {
let sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let session = sessions
.get(&args.chat_id)
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
(
session.config().clone(),
session.conversation().clone(),
session.reasoning_effort(),
session.cwd().to_path_buf(),
session.access_mode(),
)
};
let mut ctx = CommandContext {
config: &mut config,
reasoning_effort: &mut reasoning_effort,
conversation: &conversation,
cwd: &cwd,
access_mode,
busy,
status: "",
};
let outcome = commands::execute(&args.input, &mut ctx);
// Write back config + reasoning effort mutations (e.g. /fast, /model).
{
let mut sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let session = sessions
.get_mut(&args.chat_id)
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
*session.config_mut() = config;
session.set_reasoning_effort(reasoning_effort);
}
match outcome {
CommandOutcome::Status { title, content } => Ok(CommandOutcomeDto::Status {
title: title.into(),
content,
}),
CommandOutcome::NewChat {
conversation: new_conv,
status,
} => {
let info = rekey_session(&state, &args.chat_id, new_conv)?;
Ok(CommandOutcomeDto::NewChat {
info: info.into(),
status,
})
}
CommandOutcome::ResumedChat {
conversation: new_conv,
warning,
status,
} => {
let info = rekey_session(&state, &args.chat_id, new_conv)?;
Ok(CommandOutcomeDto::ResumedChat {
info: info.into(),
warning,
status,
})
}
CommandOutcome::OpenBranchPicker { family } => Ok(CommandOutcomeDto::OpenBranchPicker {
family: family.into(),
}),
CommandOutcome::OpenLoginWizard => Ok(CommandOutcomeDto::OpenLoginWizard),
CommandOutcome::OpenLogoutPicker { candidates } => {
Ok(CommandOutcomeDto::OpenLogoutPicker {
candidates: candidates
.into_iter()
.map(ProviderLogoutCandidateDto::from)
.collect(),
})
}
CommandOutcome::Busy(msg) => Ok(CommandOutcomeDto::Busy { message: msg }),
CommandOutcome::ParseError(msg) => Ok(CommandOutcomeDto::ParseError { message: msg }),
CommandOutcome::Error { title, message } => Ok(CommandOutcomeDto::Error {
title: title.into(),
message,
}),
}
}
/// Replace a session's conversation and re-key it in the sessions map under
/// the new conversation id. Used by `/new` and `/resume`.
fn rekey_session(
state: &State<'_, DesktopState>,
old_chat_id: &str,
new_conversation: cassady::conversation::Conversation,
) -> Result<cassady::embedding::ConversationInfo, String> {
let mut sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let mut session = sessions
.remove(old_chat_id)
.ok_or_else(|| format!("session {} not found", old_chat_id))?;
session.set_conversation(new_conversation);
let info = session.info();
sessions.insert(info.id.clone(), session);
Ok(info)
}
#[tauri::command]
pub fn create_branch_from_checkpoint(
state: State<'_, DesktopState>,
args: CreateBranchArgs,
) -> Result<BranchResultDto, String> {
let checkpoint: cassady::branch::Checkpoint = args.checkpoint.into();
let (info, source_chat_id, status, restore) = {
let mut sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
let mut session = sessions
.remove(&args.chat_id)
.ok_or_else(|| format!("session {} not found", args.chat_id))?;
let config = session.config().clone();
let outcome =
commands::create_branch_from_checkpoint(&config, &checkpoint, args.restore_files)
.map_err(|e| e.to_string())?;
let source_chat_id = outcome.source_chat_id;
let status = outcome.status;
let restore = outcome.restore.map(|r| RestoreReportDto {
summary: r.summary,
applied: r.applied,
skipped: r.skipped,
conflicts: r.conflicts,
});
session.set_conversation(outcome.conversation);
let info = session.info();
sessions.insert(info.id.clone(), session);
(info, source_chat_id, status, restore)
};
Ok(BranchResultDto {
info: info.into(),
source_chat_id,
status,
restore,
})
}
#[tauri::command]
pub async fn apply_provider_login(
args: ApplyProviderLoginArgs,
) -> Result<ProviderApplyResultDto, String> {
let root = cass_root();
let selections: Vec<SetupSelection> = args
.selections
.into_iter()
.map(SetupSelection::from)
.collect();
commands::apply_login(&root, &selections, args.active_index).map_err(|e| e.to_string())?;
let config = commands::reload_config(&root).map_err(|e| e.to_string())?;
Ok(ProviderApplyResultDto {
active_provider: config.provider_id,
active_model: config.model,
})
}
#[tauri::command]
pub async fn discover_models(args: DiscoverModelsArgs) -> Result<Vec<String>, String> {
commands::discover_models(&args.base_url, &args.api_key)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_providers(args: RemoveProvidersArgs) -> Result<LogoutResultDto, String> {
let root = cass_root();
let result =
commands::remove_providers(&root, &args.provider_ids).map_err(|e| e.to_string())?;
Ok(result.into())
}
+37
View File
@@ -0,0 +1,37 @@
use cassady::embedding::Session;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
#[derive(Clone)]
pub struct DesktopState {
pub sessions: Arc<Mutex<HashMap<String, Session>>>,
pub turns: Arc<Mutex<HashMap<String, TurnEntry>>>,
}
pub struct TurnEntry {
pub approval_tx: mpsc::UnboundedSender<ApprovalDecision>,
pub cancel_tx: tokio::sync::oneshot::Sender<()>,
pub chat_id: String,
}
#[derive(Debug)]
pub struct ApprovalDecision {
pub request_id: String,
pub approved: bool,
}
impl DesktopState {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
turns: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl Default for DesktopState {
fn default() -> Self {
Self::new()
}
}
+271
View File
@@ -0,0 +1,271 @@
use crate::state::{ApprovalDecision, DesktopState, TurnEntry};
use crate::types::{ApproveArgs, CancelTurnArgs, ConversationInfoDto, StreamEvent, TurnHandle};
use std::sync::atomic::{AtomicU64, Ordering};
use tauri::ipc::Channel;
use tauri::State;
use tokio::sync::mpsc;
static TURN_SEQ: AtomicU64 = AtomicU64::new(0);
fn next_turn_id() -> String {
let n = TURN_SEQ.fetch_add(1, Ordering::Relaxed);
format!("turn-{n}")
}
#[tauri::command]
pub async fn start_turn(
state: State<'_, DesktopState>,
chat_id: String,
message: String,
on_event: Channel<StreamEvent>,
) -> Result<TurnHandle, String> {
let session = {
let mut sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
sessions
.remove(&chat_id)
.ok_or_else(|| format!("session {} not found", chat_id))?
};
let turn = session
.start_turn(message)
.await
.map_err(|e| e.to_string())?;
let turn_id = next_turn_id();
let (approval_tx, approval_rx) = mpsc::unbounded_channel::<ApprovalDecision>();
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
state
.turns
.lock()
.map_err(|e| format!("turns lock: {e}"))?
.insert(
turn_id.clone(),
TurnEntry {
approval_tx,
cancel_tx,
chat_id: chat_id.clone(),
},
);
let state_clone = state.inner().clone();
spawn_turn_worker(
state_clone,
turn_id.clone(),
chat_id.clone(),
turn,
on_event,
approval_rx,
cancel_rx,
);
Ok(TurnHandle { turn_id, chat_id })
}
#[allow(clippy::too_many_arguments)]
fn spawn_turn_worker(
state: DesktopState,
turn_id: String,
chat_id: String,
turn: cassady::embedding::Turn,
on_event: Channel<StreamEvent>,
mut approval_rx: mpsc::UnboundedReceiver<ApprovalDecision>,
mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
) {
tauri::async_runtime::spawn(async move {
let mut turn: Option<cassady::embedding::Turn> = Some(turn);
loop {
let event_result = tokio::select! {
biased;
_ = &mut cancel_rx => {
// Cancel during streaming.
if let Some(t) = turn.take() {
match t.cancel().await {
Ok(session) => {
let info: ConversationInfoDto = session.info().into();
if let Ok(mut sessions) = state.sessions.lock() {
sessions.insert(chat_id.clone(), session);
}
let _ = on_event.send(StreamEvent::Status {
text: "Turn cancelled.".to_string(),
});
let _ = info;
}
Err(e) => {
let _ = on_event.send(StreamEvent::Error {
message: format!("cancel: {e}"),
});
}
}
}
cleanup_turn(&state, &turn_id);
return;
}
event = async {
match turn.as_mut() {
Some(t) => t.next_event().await,
None => Ok(None),
}
} => event,
};
match event_result {
Ok(Some(event)) => {
let mapped = StreamEvent::from_embedding(event);
let is_finished = matches!(mapped, StreamEvent::Finished);
let is_approval = matches!(mapped, StreamEvent::ApprovalRequested { .. });
if on_event.send(mapped).is_err() {
break;
}
if is_finished {
break;
}
if is_approval {
// The agent is now blocked waiting for a decision.
// Wait for the frontend to approve/deny or cancel.
tokio::select! {
biased;
_ = &mut cancel_rx => {
if let Some(t) = turn.take() {
match t.cancel().await {
Ok(session) => {
if let Ok(mut sessions) = state.sessions.lock() {
sessions.insert(chat_id.clone(), session);
}
let _ = on_event.send(StreamEvent::Status {
text: "Turn cancelled.".to_string(),
});
}
Err(e) => {
let _ = on_event.send(StreamEvent::Error {
message: format!("cancel: {e}"),
});
}
}
}
cleanup_turn(&state, &turn_id);
return;
}
decision = approval_rx.recv() => {
if let Some(decision) = decision {
if let Some(t) = turn.as_mut() {
let res = if decision.approved {
t.approve(&decision.request_id)
} else {
t.deny(&decision.request_id)
};
if let Err(e) = res {
let _ = on_event.send(StreamEvent::Error {
message: format!("approval: {e}"),
});
}
}
}
}
}
}
}
Ok(None) => break,
Err(e) => {
let _ = on_event.send(StreamEvent::Error {
message: e.to_string(),
});
break;
}
}
}
// Normal completion: finish the turn and re-insert the session.
if let Some(t) = turn {
match t.finish().await {
Ok(session) => {
if let Ok(mut sessions) = state.sessions.lock() {
sessions.insert(chat_id.clone(), session);
}
}
Err(e) => {
let _ = on_event.send(StreamEvent::Error {
message: format!("finish: {e}"),
});
}
}
}
cleanup_turn(&state, &turn_id);
});
}
fn cleanup_turn(state: &DesktopState, turn_id: &str) {
if let Ok(mut turns) = state.turns.lock() {
turns.remove(turn_id);
}
}
#[tauri::command]
pub async fn approve(state: State<'_, DesktopState>, args: ApproveArgs) -> Result<(), String> {
let tx = {
let turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
let entry = turns
.get(&args.turn_id)
.ok_or_else(|| format!("turn {} not found", args.turn_id))?;
entry.approval_tx.clone()
};
tx.send(ApprovalDecision {
request_id: args.request_id,
approved: true,
})
.map_err(|_| format!("turn {} worker gone", args.turn_id))?;
Ok(())
}
#[tauri::command]
pub async fn deny(state: State<'_, DesktopState>, args: ApproveArgs) -> Result<(), String> {
let tx = {
let turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
let entry = turns
.get(&args.turn_id)
.ok_or_else(|| format!("turn {} not found", args.turn_id))?;
entry.approval_tx.clone()
};
tx.send(ApprovalDecision {
request_id: args.request_id,
approved: false,
})
.map_err(|_| format!("turn {} worker gone", args.turn_id))?;
Ok(())
}
#[tauri::command]
pub async fn cancel_turn(
state: State<'_, DesktopState>,
args: CancelTurnArgs,
) -> Result<ConversationInfoDto, String> {
let (cancel_tx, chat_id) = {
let mut turns = state.turns.lock().map_err(|e| format!("turns lock: {e}"))?;
let entry = turns
.remove(&args.turn_id)
.ok_or_else(|| format!("turn {} not found", args.turn_id))?;
(entry.cancel_tx, entry.chat_id)
};
let _ = cancel_tx.send(());
// Wait for the worker to re-insert the session after cancel completes.
let info = loop {
{
let sessions = state
.sessions
.lock()
.map_err(|e| format!("sessions lock: {e}"))?;
if let Some(session) = sessions.get(&chat_id) {
break session.info();
}
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
};
Ok(info.into())
}
+620
View File
@@ -0,0 +1,620 @@
use cassady::access::AccessMode;
use cassady::branch::{BranchFamily, BranchSummary, Checkpoint, CheckpointKind};
use cassady::commands::CommandSpec;
use cassady::config::{ModelDefinition, ReasoningEffort};
use cassady::setup::{LogoutResult, ProviderCatalogEntry, ProviderLogoutCandidate, SetupSelection};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationInfoDto {
pub id: String,
pub cwd: String,
pub model: String,
pub access_mode: AccessMode,
pub reasoning_effort: ReasoningEffort,
pub path: String,
pub record_count: usize,
}
impl From<cassady::embedding::ConversationInfo> for ConversationInfoDto {
fn from(info: cassady::embedding::ConversationInfo) -> Self {
Self {
id: info.id,
cwd: info.cwd.display().to_string(),
model: info.model,
access_mode: info.access_mode,
reasoning_effort: info.reasoning_effort,
path: info.path.display().to_string(),
record_count: info.record_count,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatSummaryDto {
pub id: String,
pub created_at: String,
pub model: String,
pub cwd: String,
pub first_user_preview: String,
}
impl From<cassady::conversation::ChatSummary> for ChatSummaryDto {
fn from(s: cassady::conversation::ChatSummary) -> Self {
Self {
id: s.id,
created_at: s.created_at,
model: s.model,
cwd: s.cwd,
first_user_preview: s.first_user_preview,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelOptionDto {
pub id: String,
pub provider: String,
pub display_name: Option<String>,
pub reasoning_supported: bool,
pub reasoning_required: bool,
pub default_reasoning_effort: ReasoningEffort,
}
impl From<ModelDefinition> for ModelOptionDto {
fn from(model: ModelDefinition) -> Self {
Self {
id: model.id,
provider: model.provider,
display_name: model.display_name,
reasoning_supported: model.reasoning.supported,
reasoning_required: model.reasoning.required,
default_reasoning_effort: model.reasoning.default_effort,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSessionSettingsArgs {
pub chat_id: String,
pub access_mode: Option<AccessMode>,
pub model: Option<String>,
pub reasoning_effort: Option<ReasoningEffort>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NewSessionArgs {
pub cwd: Option<String>,
pub access_mode: Option<AccessMode>,
pub model: Option<String>,
pub base_url: Option<String>,
pub api_key_env: Option<String>,
pub reasoning_effort: Option<ReasoningEffort>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResumeSessionArgs {
pub chat_id: String,
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListChatsArgs {
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApproveArgs {
pub turn_id: String,
pub request_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionIdArgs {
pub chat_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelTurnArgs {
pub turn_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum StreamEvent {
AssistantChunk {
text: String,
},
ReasoningChunk {
text: String,
},
ToolCallStarted {
id: String,
name: String,
arguments: serde_json::Value,
},
ToolOutputChunk {
id: String,
name: String,
stream: String,
content: String,
},
ToolResult {
id: String,
name: String,
ok: bool,
content: String,
},
ApprovalRequested {
request_id: String,
tool_call_id: String,
name: String,
arguments: serde_json::Value,
reason: String,
},
ApprovalResolved {
request_id: String,
approved: bool,
},
Status {
text: String,
},
Finished,
Error {
message: String,
},
}
impl StreamEvent {
pub fn from_embedding(event: cassady::embedding::Event) -> Self {
match event {
cassady::embedding::Event::AssistantChunk(text) => Self::AssistantChunk { text },
cassady::embedding::Event::ReasoningChunk(text) => Self::ReasoningChunk { text },
cassady::embedding::Event::ToolCallStarted {
id,
name,
arguments,
} => Self::ToolCallStarted {
id,
name,
arguments,
},
cassady::embedding::Event::ToolOutputChunk {
id,
name,
stream,
content,
} => Self::ToolOutputChunk {
id,
name,
stream,
content,
},
cassady::embedding::Event::ToolResult {
id,
name,
ok,
content,
} => Self::ToolResult {
id,
name,
ok,
content,
},
cassady::embedding::Event::ApprovalRequested(req) => Self::ApprovalRequested {
request_id: req.request_id,
tool_call_id: req.tool_call_id,
name: req.name,
arguments: req.arguments,
reason: req.reason,
},
cassady::embedding::Event::ApprovalResolved {
request_id,
approved,
} => Self::ApprovalResolved {
request_id,
approved,
},
cassady::embedding::Event::Status(text) => Self::Status { text },
cassady::embedding::Event::Finished => Self::Finished,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TurnHandle {
pub turn_id: String,
pub chat_id: String,
}
// --- Slash command DTOs -----------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandSpecDto {
pub name: String,
pub usage: String,
pub description: String,
pub takes_value: bool,
}
impl From<CommandSpec> for CommandSpecDto {
fn from(spec: CommandSpec) -> Self {
Self {
name: spec.name.to_string(),
usage: spec.usage.to_string(),
description: spec.description.to_string(),
takes_value: spec.takes_value,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutoFillItemDto {
pub label: String,
pub insert: String,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutoFillMenuDto {
pub title: String,
pub replacement_start: usize,
pub replacement_end: usize,
pub items: Vec<AutoFillItemDto>,
pub selected: usize,
}
impl From<cassady::commands::AutoFillMenu> for AutoFillMenuDto {
fn from(menu: cassady::commands::AutoFillMenu) -> Self {
Self {
title: menu.title,
replacement_start: menu.replacement_start,
replacement_end: menu.replacement_end,
items: menu
.items
.into_iter()
.map(|item| AutoFillItemDto {
label: item.label,
insert: item.insert,
detail: item.detail,
})
.collect(),
selected: menu.selected,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchSummaryDto {
pub id: String,
pub created_at: String,
pub parent_chat_id: Option<String>,
pub branch_label: Option<String>,
pub record_count: usize,
pub current: bool,
}
impl From<BranchSummary> for BranchSummaryDto {
fn from(b: BranchSummary) -> Self {
Self {
id: b.id,
created_at: b.created_at,
parent_chat_id: b.parent_chat_id,
branch_label: b.branch_label,
record_count: b.record_count,
current: b.current,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckpointDto {
pub id: String,
pub chat_id: String,
pub record_index: usize,
pub tool_call_id: Option<String>,
pub kind: CheckpointKind,
pub label: String,
pub detail: String,
pub ts: Option<String>,
}
impl From<Checkpoint> for CheckpointDto {
fn from(c: Checkpoint) -> Self {
Self {
id: c.id,
chat_id: c.chat_id,
record_index: c.record_index,
tool_call_id: c.tool_call_id,
kind: c.kind,
label: c.label,
detail: c.detail,
ts: c.ts,
}
}
}
impl From<CheckpointDto> for Checkpoint {
fn from(c: CheckpointDto) -> Self {
Self {
id: c.id,
chat_id: c.chat_id,
record_index: c.record_index,
tool_call_id: c.tool_call_id,
kind: c.kind,
label: c.label,
detail: c.detail,
ts: c.ts,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchFamilyDto {
pub branches: Vec<BranchSummaryDto>,
pub checkpoints: Vec<CheckpointDto>,
}
impl From<BranchFamily> for BranchFamilyDto {
fn from(f: BranchFamily) -> Self {
Self {
branches: f.branches.into_iter().map(BranchSummaryDto::from).collect(),
checkpoints: f.checkpoints.into_iter().map(CheckpointDto::from).collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderLogoutCandidateDto {
pub id: String,
pub name: Option<String>,
pub default_model: Option<String>,
pub model_count: usize,
}
impl From<ProviderLogoutCandidate> for ProviderLogoutCandidateDto {
fn from(c: ProviderLogoutCandidate) -> Self {
Self {
id: c.id,
name: c.name,
default_model: c.default_model,
model_count: c.model_count,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogoutResultDto {
pub removed_provider_ids: Vec<String>,
pub removed_model_count: usize,
pub remaining_provider_count: usize,
pub active_provider: Option<String>,
pub active_model: Option<String>,
}
impl From<LogoutResult> for LogoutResultDto {
fn from(r: LogoutResult) -> Self {
Self {
removed_provider_ids: r.removed_provider_ids,
removed_model_count: r.removed_model_count,
remaining_provider_count: r.remaining_provider_count,
active_provider: r.active_provider,
active_model: r.active_model,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderCatalogEntryDto {
pub name: String,
pub id: String,
pub base_url: String,
pub api_key_env: String,
}
impl From<ProviderCatalogEntry> for ProviderCatalogEntryDto {
fn from(e: ProviderCatalogEntry) -> Self {
Self {
name: e.name.to_string(),
id: e.id.to_string(),
base_url: e.base_url.to_string(),
api_key_env: e.api_key_env.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupSelectionDto {
pub provider_id: String,
pub provider_name: String,
pub base_url: String,
pub api_key_env: String,
pub model_id: String,
pub supports_tools: bool,
pub supports_reasoning: bool,
}
impl From<SetupSelectionDto> for SetupSelection {
fn from(s: SetupSelectionDto) -> Self {
Self {
provider_id: s.provider_id,
provider_name: s.provider_name,
base_url: s.base_url,
api_key_env: s.api_key_env,
model_id: s.model_id,
supports_tools: s.supports_tools,
supports_reasoning: s.supports_reasoning,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RestoreReportDto {
pub summary: String,
pub applied: usize,
pub skipped: usize,
pub conflicts: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchResultDto {
pub info: ConversationInfoDto,
pub source_chat_id: String,
pub status: String,
pub restore: Option<RestoreReportDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderApplyResultDto {
pub active_provider: String,
pub active_model: String,
}
/// Tagged result of `run_slash_command`, mirroring `cassady::commands::CommandOutcome`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum CommandOutcomeDto {
Status {
title: String,
content: String,
},
NewChat {
info: ConversationInfoDto,
status: String,
},
ResumedChat {
info: ConversationInfoDto,
warning: Option<String>,
status: String,
},
OpenBranchPicker {
family: BranchFamilyDto,
},
OpenLoginWizard,
OpenLogoutPicker {
candidates: Vec<ProviderLogoutCandidateDto>,
},
Busy {
message: String,
},
ParseError {
message: String,
},
Error {
title: String,
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunSlashCommandArgs {
pub chat_id: String,
pub input: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SlashAutofillArgs {
pub input: String,
pub cwd: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateBranchArgs {
pub chat_id: String,
pub checkpoint: CheckpointDto,
pub restore_files: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyProviderLoginArgs {
pub selections: Vec<SetupSelectionDto>,
pub active_index: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoverModelsArgs {
pub base_url: String,
pub api_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveProvidersArgs {
pub provider_ids: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stream_event_serializes_camel_case_with_kind_tag() {
let ev = StreamEvent::AssistantChunk { text: "hi".into() };
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["kind"], "assistantChunk");
assert_eq!(json["text"], "hi");
let ev = StreamEvent::ToolResult {
id: "1".into(),
name: "ls".into(),
ok: true,
content: "x".into(),
};
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["kind"], "toolResult");
assert_eq!(json["ok"], true);
}
#[test]
fn from_embedding_maps_each_variant() {
use cassady::embedding::Event;
let cases: Vec<(Event, &str)> = vec![
(Event::AssistantChunk("a".into()), "assistantChunk"),
(Event::ReasoningChunk("r".into()), "reasoningChunk"),
(
Event::ToolCallStarted {
id: "1".into(),
name: "ls".into(),
arguments: serde_json::Value::Null,
},
"toolCallStarted",
),
(Event::Status("s".into()), "status"),
(Event::Finished, "finished"),
];
for (ev, expected_kind) in cases {
let mapped = StreamEvent::from_embedding(ev);
let json = serde_json::to_value(&mapped).unwrap();
assert_eq!(json["kind"], expected_kind);
}
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cassady",
"version": "0.4.1",
"identifier": "com.cassady.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Cassady",
"width": 1000,
"height": 720,
"minWidth": 720,
"minHeight": 480,
"resizable": true,
"fullscreen": false,
"decorations": true
}
],
"security": {
"csp": null
},
"withGlobalTauri": false
},
"bundle": {
"active": false,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "DeveloperTool"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import { ChatShell } from "@/components/ChatShell";
import { getCwd } from "@/lib/tauri";
export default function App() {
const [cwd, setCwd] = useState<string>("");
useEffect(() => {
void (async () => {
try {
const c = await getCwd();
setCwd(c);
} catch {
setCwd("");
}
})();
}, []);
if (!cwd) {
return (
<div className="vignette scanlines grain flex h-screen w-screen items-center justify-center bg-[var(--color-bg)] font-mono text-xs text-[var(--color-fg-dim)]">
loading…
</div>
);
}
return <ChatShell cwd={cwd} />;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

@@ -0,0 +1,52 @@
import { type PendingApproval } from "@/hooks/useTurn";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export function ApprovalDialog({
approval,
onResolve,
}: {
approval: PendingApproval;
onResolve: (approved: boolean) => void;
}) {
const args = approval.arguments
? JSON.stringify(approval.arguments, null, 2)
: "";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="text-[var(--color-amber)]">
approval required
</CardTitle>
<CardDescription>
<span className="font-mono text-[var(--color-fg)]">
{approval.name}
</span>{" "}
— {approval.reason}
</CardDescription>
</CardHeader>
<CardContent>
{args && (
<pre className="max-h-60 overflow-auto border border-[var(--color-line)] bg-[var(--color-bg)] p-3 font-mono text-xs text-[var(--color-fg-muted)] whitespace-pre-wrap break-words">
{args}
</pre>
)}
</CardContent>
<CardFooter className="gap-3 justify-end">
<Button variant="outline" onClick={() => onResolve(false)}>
deny
</Button>
<Button onClick={() => onResolve(true)}>approve</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -0,0 +1,193 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BranchFamily, Checkpoint } from "@/lib/tauri";
export function BranchModal({
family,
onSwitch,
onBranch,
onClose,
}: {
family: BranchFamily;
onSwitch: (branchId: string) => void;
onBranch: (checkpoint: Checkpoint, restoreFiles: boolean) => Promise<void>;
onClose: () => void;
}) {
const [selectedCheckpoint, setSelectedCheckpoint] = useState<Checkpoint | null>(
null,
);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && !applying) {
if (selectedCheckpoint) setSelectedCheckpoint(null);
else onClose();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [applying, selectedCheckpoint, onClose]);
const runBranch = async (restoreFiles: boolean) => {
if (!selectedCheckpoint) return;
setApplying(true);
setError(null);
try {
await onBranch(selectedCheckpoint, restoreFiles);
} catch (e) {
setError(String(e));
setApplying(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="text-[var(--color-amber)]">
{selectedCheckpoint ? "branch from checkpoint" : "branch / restore"}
</CardTitle>
<CardDescription>
{selectedCheckpoint
? `${selectedCheckpoint.chatId} · ${selectedCheckpoint.label}`
: "switch to a branch or branch from a checkpoint"}
</CardDescription>
</CardHeader>
<CardContent>
{!selectedCheckpoint ? (
<div className="flex flex-col gap-3">
{family.branches.length > 0 && (
<div>
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
branches
</p>
<div className="flex flex-col gap-1">
{family.branches.map((b) => (
<button
key={b.id}
type="button"
onClick={() => onSwitch(b.id)}
disabled={applying || b.current}
className={cn(
"flex items-center justify-between border px-3 py-2 text-left transition-colors",
b.current
? "border-[var(--color-line)] text-[var(--color-fg-dim)]"
: "border-[var(--color-line)] hover:border-[var(--color-accent)]/50 hover:text-[var(--color-fg)]",
)}
>
<span className="flex flex-col gap-0.5">
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{b.current ? `current · ${b.id}` : b.id}
</span>
{b.branchLabel && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{b.branchLabel}
</span>
)}
</span>
<span className="shrink-0 font-mono text-[10px] text-[var(--color-fg-dim)]">
{b.recordCount} records
</span>
</button>
))}
</div>
</div>
)}
{family.checkpoints.length > 0 && (
<div>
<p className="mb-1 font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
checkpoints
</p>
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{family.checkpoints.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setSelectedCheckpoint(c)}
disabled={applying}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{c.chatId} · {c.label}
</span>
{c.detail && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{c.detail}
</span>
)}
</button>
))}
</div>
</div>
)}
{family.branches.length === 0 &&
family.checkpoints.length === 0 && (
<p className="font-mono text-xs text-[var(--color-fg-dim)]">
no branches or checkpoints found.
</p>
)}
</div>
) : (
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => setSelectedCheckpoint(null)}
disabled={applying}
className="self-start font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
‹ back
</button>
<button
type="button"
onClick={() => void runBranch(false)}
disabled={applying}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
branch conversation only
</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)]">
safe default; leaves files unchanged
</span>
</button>
<button
type="button"
onClick={() => void runBranch(true)}
disabled={applying}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
branch and restore tracked files
</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)]">
applies safe write/edit snapshots; conflicts are skipped
</span>
</button>
</div>
)}
{error && (
<p className="mt-3 whitespace-pre-wrap font-mono text-xs text-[var(--color-amber)]">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-end gap-3">
<Button variant="outline" onClick={onClose} disabled={applying}>
cancel
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -0,0 +1,401 @@
import { useEffect, useMemo, useState } from "react";
import { TopBar, OpenChatList, NoConfigCard, useSessionManager } from "@/components/TopBar";
import { Transcript } from "@/components/Transcript";
import { Composer } from "@/components/Composer";
import { BranchModal } from "@/components/BranchModal";
import { LogoutModal } from "@/components/LogoutModal";
import { LoginModal } from "@/components/LoginModal";
import { ACCESS_MODES, REASONING_EFFORTS } from "@/lib/sessionSettings";
import { StatusFooter } from "@/components/StatusFooter";
import { ApprovalDialog } from "@/components/ApprovalDialog";
import { useTurn } from "@/hooks/useTurn";
import type {
AccessMode,
BranchFamily,
Checkpoint,
CommandOutcome,
LogoutResult,
ProviderApplyResult,
ProviderLogoutCandidate,
ReasoningEffort,
} from "@/lib/tauri";
import {
createBranchFromCheckpoint,
listModels,
reloadSessionConfig,
runSlashCommand,
updateSessionSettings,
type ModelOption,
} from "@/lib/tauri";
export function ChatShell({ cwd }: { cwd: string }) {
const {
chat,
setChat,
chats,
showOpen,
setShowOpen,
configError,
handleNew,
handleOpen,
handlePick,
} = useSessionManager(cwd);
const [modelOptions, setModelOptions] = useState<ModelOption[]>([]);
const [branchFamily, setBranchFamily] = useState<BranchFamily | null>(null);
const [logoutCandidates, setLogoutCandidates] = useState<
ProviderLogoutCandidate[]
>([]);
const [logoutOpen, setLogoutOpen] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const turn = useTurn(chat);
useEffect(() => {
if (chat) {
const shortId = chat.id.slice(0, 8);
document.title = `cass — ${shortId}`;
} else {
document.title = "cass";
}
}, [chat]);
useEffect(() => {
void (async () => {
try {
setModelOptions(await listModels());
} catch {
setModelOptions([]);
}
})();
}, [chat?.id]);
const accessMode = chat?.accessMode ?? "read-only";
const reasoningEffort = chat?.reasoningEffort ?? "medium";
const model = chat?.model ?? "";
const updateSettings = async (settings: {
accessMode?: AccessMode;
model?: string;
reasoningEffort?: ReasoningEffort;
}) => {
if (!chat) return null;
if (turn.state === "running" || turn.state === "approval") {
turn.setStatusHint("settings can be changed when idle");
return null;
}
try {
const info = await updateSessionSettings({ chatId: chat.id, ...settings });
setChat(info);
return info;
} catch (e) {
turn.setStatusHint(String(e));
return null;
}
};
const cycleAccessMode = () => {
const idx = ACCESS_MODES.indexOf(accessMode);
const next = ACCESS_MODES[(idx + 1) % ACCESS_MODES.length];
void (async () => {
const info = await updateSettings({ accessMode: next });
if (info) turn.setStatusHint(`mode: ${info.accessMode}`);
})();
};
const cycleReasoning = () => {
const idx = REASONING_EFFORTS.indexOf(reasoningEffort);
const next = REASONING_EFFORTS[(idx + 1) % REASONING_EFFORTS.length];
void (async () => {
const info = await updateSettings({ reasoningEffort: next });
if (info) turn.setStatusHint(`reasoning:${info.reasoningEffort}`);
})();
};
const selectModel = (nextModel: string) => {
if (!nextModel || nextModel === model) return;
void (async () => {
const info = await updateSettings({ model: nextModel });
if (info) turn.setStatusHint(`model: ${info.model}`);
})();
};
const refreshModelOptions = async () => {
try {
setModelOptions(await listModels());
} catch {
setModelOptions([]);
}
};
const routeSlashOutcome = async (outcome: CommandOutcome) => {
switch (outcome.kind) {
case "status": {
turn.pushNotice(outcome.content, "status");
// The CLI sets the status line to the content for /fast and /model,
// and to "status shown" for /status.
turn.setStatusHint(
outcome.title === "status" ? "status shown" : outcome.content,
);
break;
}
case "newChat": {
turn.queueAfterLoad({ status: outcome.status });
setChat(outcome.info);
break;
}
case "resumedChat": {
turn.queueAfterLoad({
status: outcome.status,
notice: outcome.warning
? { text: outcome.warning, kind: "error" }
: undefined,
});
setChat(outcome.info);
break;
}
case "openBranchPicker": {
setBranchFamily(outcome.family);
turn.setStatusHint("branch/restore menu");
break;
}
case "openLoginWizard": {
setLoginOpen(true);
turn.setStatusHint("login");
break;
}
case "openLogoutPicker": {
setLogoutCandidates(outcome.candidates);
setLogoutOpen(true);
turn.setStatusHint("logout");
break;
}
case "busy":
turn.setStatusHint(outcome.message);
break;
case "parseError":
turn.setStatusHint(outcome.message);
break;
case "error":
turn.pushNotice(outcome.message, "error");
turn.setStatusHint(outcome.message);
break;
}
};
const onSlashCommand = (input: string) => {
if (!chat) return;
void (async () => {
try {
const outcome = await runSlashCommand(chat.id, input);
await routeSlashOutcome(outcome);
} catch (e) {
turn.setStatusHint(String(e));
}
})();
};
const onBranchSwitch = (branchId: string) => {
setBranchFamily(null);
onSlashCommand(`/resume ${branchId}`);
};
const onBranchFromCheckpoint = async (
checkpoint: Checkpoint,
restoreFiles: boolean,
) => {
if (!chat) return;
try {
const result = await createBranchFromCheckpoint(
chat.id,
checkpoint,
restoreFiles,
);
const notice = result.restore
? {
text: `${result.restore.summary}\n\nApplied: ${result.restore.applied}\nSkipped: ${result.restore.skipped}\nConflicts: ${result.restore.conflicts}`,
kind: (result.restore.conflicts > 0 ? "error" : "status") as
| "status"
| "error",
}
: undefined;
turn.queueAfterLoad({ status: result.status, notice });
setChat(result.info);
setBranchFamily(null);
} catch (e) {
turn.setStatusHint(String(e));
}
};
const onLogoutResolved = async (result: LogoutResult) => {
setLogoutOpen(false);
setLogoutCandidates([]);
await refreshModelOptions();
if (result.removedProviderIds.length === 0) {
turn.pushNotice("logout cancelled", "status");
turn.setStatusHint("logout cancelled");
} else if (result.remainingProviderCount === 0) {
turn.pushNotice(
`removed providers: ${result.removedProviderIds.join(", ")}\nremoved model entries: ${result.removedModelCount}\nno providers remain; run /login before sending another message`,
"status",
);
turn.setStatusHint("no provider configured");
} else {
// Reload the open session's config so the active provider/model reflect
// the post-logout defaults (repair_active_defaults updated config.json).
if (chat) {
try {
const info = await reloadSessionConfig(chat.id);
setChat(info);
} catch (e) {
turn.setStatusHint(String(e));
}
}
turn.pushNotice(
`removed providers: ${result.removedProviderIds.join(", ")}\nremoved model entries: ${result.removedModelCount}\nactive provider: ${result.activeProvider ?? "?"}\nactive model: ${result.activeModel ?? "?"}`,
"status",
);
turn.setStatusHint("logout updated");
}
};
const onLoginResolved = async (result: ProviderApplyResult) => {
setLoginOpen(false);
await refreshModelOptions();
// Reload the open session's config so it picks up the new active
// provider/model written by apply_provider_login.
if (chat) {
try {
const info = await reloadSessionConfig(chat.id);
setChat(info);
} catch (e) {
turn.setStatusHint(String(e));
}
}
turn.pushNotice(
`active provider: ${result.activeProvider}\nactive model: ${result.activeModel}`,
"status",
);
turn.setStatusHint("login updated");
};
const onSend = (message: string) => {
void turn.send(message);
};
const onCancel = () => {
void turn.cancel();
};
const onResolveApproval = (approved: boolean) => {
void turn.resolveApproval(approved);
};
const body = useMemo(() => {
if (configError) {
return <NoConfigCard cwd={cwd} />;
}
if (!chat) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-6">
<p className="font-mono text-sm text-[var(--color-fg-muted)]">
no chat open.
</p>
<button
onClick={() => void handleNew()}
className="font-mono text-sm text-[var(--color-accent)] hover:underline"
>
▸ start a new chat
</button>
</div>
);
}
return (
<>
<Transcript blocks={turn.blocks} />
{turn.pendingApproval && (
<ApprovalDialog
approval={turn.pendingApproval}
onResolve={onResolveApproval}
/>
)}
</>
);
}, [configError, chat, turn.blocks, turn.pendingApproval, cwd, handleNew]);
return (
<div className="vignette scanlines grain relative flex h-screen w-screen min-w-0 flex-col overflow-hidden bg-[var(--color-bg)]">
<TopBar
chat={chat}
onNewChat={() => void handleNew()}
onOpenChat={() => void handleOpen()}
chats={chats}
cwd={cwd}
/>
{showOpen && (
<OpenChatList
chats={chats}
onPick={(id) => void handlePick(id)}
onClose={() => setShowOpen(false)}
/>
)}
{body}
{chat && (
<Composer
onSend={onSend}
onCancel={onCancel}
onSlashCommand={onSlashCommand}
cwd={chat.cwd}
running={turn.state === "running" || turn.state === "approval"}
accessMode={accessMode}
reasoningEffort={reasoningEffort}
model={model}
modelOptions={modelOptions}
onCycleAccessMode={cycleAccessMode}
onCycleReasoning={cycleReasoning}
onSelectModel={selectModel}
/>
)}
{chat && (
<StatusFooter
chatId={chat.id}
cwd={chat.cwd}
model={chat.model}
accessMode={accessMode}
reasoningEffort={reasoningEffort}
state={turn.state}
status={turn.status}
/>
)}
{chat && branchFamily && (
<BranchModal
family={branchFamily}
onSwitch={onBranchSwitch}
onBranch={onBranchFromCheckpoint}
onClose={() => {
setBranchFamily(null);
turn.setStatusHint("branch menu cancelled");
}}
/>
)}
{chat && logoutOpen && (
<LogoutModal
candidates={logoutCandidates}
onResolved={onLogoutResolved}
onClose={() => {
setLogoutOpen(false);
turn.setStatusHint("logout cancelled");
}}
/>
)}
{chat && loginOpen && (
<LoginModal
onResolved={onLoginResolved}
onClose={() => {
setLoginOpen(false);
turn.setStatusHint("login cancelled");
}}
/>
)}
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ModelSelector } from "@/components/ModelSelector";
import { SlashMenu } from "@/components/SlashMenu";
import { ArrowUp, Square } from "lucide-react";
import { cn } from "@/lib/utils";
import {
slashAutofill,
type AccessMode,
type AutoFillMenu,
type ModelOption,
type ReasoningEffort,
} from "@/lib/tauri";
export function Composer({
disabled,
running,
cwd,
onSend,
onCancel,
onSlashCommand,
accessMode,
reasoningEffort,
model,
modelOptions,
onCycleAccessMode,
onCycleReasoning,
onSelectModel,
}: {
disabled?: boolean;
running?: boolean;
cwd: string;
onSend: (message: string) => void;
onCancel: () => void;
onSlashCommand: (input: string) => void;
accessMode: AccessMode;
reasoningEffort: ReasoningEffort;
model: string;
modelOptions: ModelOption[];
onCycleAccessMode: () => void;
onCycleReasoning: () => void;
onSelectModel: (model: string) => void;
}) {
const [value, setValue] = useState("");
const [slashMenu, setSlashMenu] = useState<AutoFillMenu | null>(null);
const [slashSelected, setSlashSelected] = useState(0);
const ref = useRef<HTMLTextAreaElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}, [value]);
useEffect(() => {
if (!value.startsWith("/") || value.includes("\n")) {
setSlashMenu(null);
return;
}
let active = true;
void (async () => {
try {
const menu = await slashAutofill(value, cwd);
if (active) {
setSlashMenu(menu);
setSlashSelected(menu?.selected ?? 0);
}
} catch {
if (active) setSlashMenu(null);
}
})();
return () => {
active = false;
};
}, [value, cwd]);
useEffect(() => {
if (!slashMenu) return;
const handler = (e: MouseEvent) => {
if (rowRef.current && !rowRef.current.contains(e.target as Node)) {
setSlashMenu(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [slashMenu]);
const submit = () => {
const trimmed = value.trim();
if (!trimmed || running) return;
onSend(trimmed);
setValue("");
};
const applySlashSelection = () => {
if (!slashMenu) return;
const item = slashMenu.items[slashSelected];
if (!item) return;
const newInput =
value.slice(0, slashMenu.replacementStart) +
item.insert +
value.slice(slashMenu.replacementEnd);
if (item.insert.endsWith(" ")) {
// Value command name selected (e.g. "/model "); keep the menu open for
// argument selection.
setValue(newInput);
} else {
// Complete command; auto-run and clear the input.
setSlashMenu(null);
setValue("");
onSlashCommand(newInput.trim());
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (slashMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashSelected((s) => Math.min(s + 1, slashMenu.items.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashSelected((s) => Math.max(s - 1, 0));
return;
}
if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault();
applySlashSelection();
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashMenu(null);
return;
}
} else {
if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault();
const trimmed = value.trim();
if (trimmed.startsWith("/")) {
setValue("");
onSlashCommand(trimmed);
} else {
submit();
}
return;
}
if (e.key === "Escape" && running) {
e.preventDefault();
onCancel();
}
}
};
return (
<div className="border-t border-[var(--color-line)] bg-[var(--color-bg-soft)]/60 px-4 py-3">
<div className="mb-2 flex items-center gap-2 font-mono text-[11px] text-[var(--color-fg-dim)]">
<button
type="button"
onClick={onCycleAccessMode}
disabled={running}
className="border border-[var(--color-line)] px-2 py-0.5 uppercase tracking-[0.16em] hover:border-[var(--color-accent)]/50 hover:text-[var(--color-accent)] disabled:opacity-50 disabled:hover:border-[var(--color-line)] disabled:hover:text-[var(--color-fg-dim)] transition-colors"
>
{accessMode}
</button>
<button
type="button"
onClick={onCycleReasoning}
disabled={running}
className="border border-[var(--color-line)] px-2 py-0.5 uppercase tracking-[0.16em] hover:border-[var(--color-accent)]/50 hover:text-[var(--color-accent)] disabled:opacity-50 disabled:hover:border-[var(--color-line)] disabled:hover:text-[var(--color-fg-dim)] transition-colors"
>
reasoning:{reasoningEffort}
</button>
<ModelSelector
model={model}
modelOptions={modelOptions}
disabled={running}
onSelect={onSelectModel}
/>
</div>
<div ref={rowRef} className="relative flex items-end gap-2">
{slashMenu && (
<SlashMenu
menu={slashMenu}
selected={slashSelected}
onSelect={(idx) => {
setSlashSelected(idx);
applySlashSelection();
}}
onHover={setSlashSelected}
/>
)}
<span className="font-mono text-[var(--color-accent)] pb-2">›</span>
<textarea
ref={ref}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
disabled={disabled}
rows={1}
placeholder="message cass… (type / for commands)"
className={cn(
"flex-1 resize-none bg-transparent font-mono text-sm text-[var(--color-fg)] placeholder:text-[var(--color-fg-dim)] focus:outline-none",
"py-2",
)}
/>
{running ? (
<Button size="sm" variant="outline" onClick={onCancel}>
<Square className="h-3 w-3" />
stop
</Button>
) : (
<Button size="sm" onClick={submit} disabled={disabled || !value.trim()}>
<ArrowUp className="h-3 w-3" />
send
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,198 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
applyProviderLogin,
listProviderCatalog,
type ProviderApplyResult,
type ProviderCatalogEntry,
type SetupSelection,
} from "@/lib/tauri";
const CHATGPT_CODEX_ID = "chatgpt-codex";
export function LoginModal({
onResolved,
onClose,
}: {
onResolved: (result: ProviderApplyResult) => void;
onClose: () => void;
}) {
const [catalog, setCatalog] = useState<ProviderCatalogEntry[]>([]);
const [selection, setSelection] = useState<SetupSelection | null>(null);
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
setCatalog(await listProviderCatalog());
} catch {
setCatalog([]);
}
})();
}, []);
const isCodex = selection?.providerId === CHATGPT_CODEX_ID;
const apply = async () => {
if (!selection) return;
if (!selection.providerId.trim() || !selection.modelId.trim()) {
setError("provider id and model id are required");
return;
}
if (!isCodex && !selection.apiKeyEnv.trim()) {
setError("API key environment variable is required for this provider");
return;
}
setApplying(true);
setError(null);
try {
const result = await applyProviderLogin([selection], 0);
onResolved(result);
} catch (e) {
setError(String(e));
setApplying(false);
}
};
const pick = (entry: ProviderCatalogEntry) => {
setError(null);
setSelection({
providerId: entry.id,
providerName: entry.name,
baseUrl: entry.baseUrl,
apiKeyEnv: entry.apiKeyEnv,
modelId: "",
supportsTools: true,
supportsReasoning: false,
});
};
const update = (patch: Partial<SetupSelection>) => {
setSelection((prev) => (prev ? { ...prev, ...patch } : prev));
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="text-[var(--color-amber)]">login</CardTitle>
<CardDescription>
{selection ? "configure provider and model" : "choose a provider"}
</CardDescription>
</CardHeader>
<CardContent>
{!selection ? (
<div className="grid max-h-80 grid-cols-1 gap-1 overflow-y-auto">
{catalog.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => pick(entry)}
className="flex flex-col gap-0.5 border border-[var(--color-line)] px-3 py-2 text-left transition-colors hover:border-[var(--color-accent)]/50"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
{entry.name}
</span>
<span className="font-mono text-[10px] text-[var(--color-fg-dim)]">
{entry.id} · {entry.baseUrl}
</span>
</button>
))}
{catalog.length === 0 && (
<p className="font-mono text-xs text-[var(--color-fg-dim)]">
provider catalog unavailable.
</p>
)}
</div>
) : (
<div className="flex flex-col gap-3">
<Field label="provider id">
<input
value={selection.providerId}
onChange={(e) => update({ providerId: e.target.value })}
className={inputClass}
/>
</Field>
<Field label="base url">
<input
value={selection.baseUrl}
onChange={(e) => update({ baseUrl: e.target.value })}
className={inputClass}
/>
</Field>
<Field label="api key env">
<input
value={selection.apiKeyEnv}
onChange={(e) => update({ apiKeyEnv: e.target.value })}
placeholder={isCodex ? "uses local codex auth" : "$MY_API_KEY"}
disabled={isCodex}
className={cn(inputClass, isCodex && "opacity-50")}
/>
</Field>
<Field label="model id">
<input
value={selection.modelId}
onChange={(e) => update({ modelId: e.target.value })}
placeholder="e.g. gpt-5.5"
className={inputClass}
/>
</Field>
</div>
)}
{error && (
<p className="mt-3 whitespace-pre-wrap font-mono text-xs text-[var(--color-amber)]">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-end gap-3">
<Button
variant="outline"
onClick={() => {
if (selection) setSelection(null);
else onClose();
}}
disabled={applying}
>
{selection ? "back" : "cancel"}
</Button>
{selection && (
<Button onClick={apply} disabled={applying}>
apply
</Button>
)}
</CardFooter>
</Card>
</div>
);
}
const inputClass =
"w-full border border-[var(--color-line)] bg-[var(--color-bg)] px-2 py-1.5 font-mono text-xs text-[var(--color-fg)] placeholder:text-[var(--color-fg-dim)] focus:border-[var(--color-accent)] focus:outline-none";
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1">
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)]">
{label}
</span>
{children}
</label>
);
}
@@ -0,0 +1,127 @@
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { removeProviders, type LogoutResult, type ProviderLogoutCandidate } from "@/lib/tauri";
export function LogoutModal({
candidates,
onResolved,
onClose,
}: {
candidates: ProviderLogoutCandidate[];
onResolved: (result: LogoutResult) => void;
onClose: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [applying, setApplying] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const apply = async () => {
if (selected.size === 0) return;
setApplying(true);
setError(null);
try {
const result = await removeProviders([...selected]);
onResolved(result);
} catch (e) {
setError(String(e));
setApplying(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle className="text-[var(--color-amber)]">logout</CardTitle>
<CardDescription>
remove saved providers and their models
</CardDescription>
</CardHeader>
<CardContent>
{candidates.length === 0 ? (
<p className="font-mono text-xs text-[var(--color-fg-dim)]">
no providers configured.
</p>
) : (
<div className="flex flex-col gap-1">
{candidates.map((c) => {
const checked = selected.has(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => toggle(c.id)}
className={cn(
"flex items-center gap-2 border px-3 py-2 text-left transition-colors",
checked
? "border-[var(--color-accent)]/60 bg-[var(--color-accent-glow)]"
: "border-[var(--color-line)] hover:border-[var(--color-accent)]/40",
)}
>
<span
className={cn(
"flex h-3 w-3 shrink-0 items-center justify-center border text-[10px]",
checked
? "border-[var(--color-accent)] text-[var(--color-accent)]"
: "border-[var(--color-fg-dim)]",
)}
>
{checked ? "✓" : ""}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{c.id}
</span>
{c.name && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{c.name}
</span>
)}
</span>
<span className="shrink-0 font-mono text-[10px] text-[var(--color-fg-dim)]">
{c.modelCount} models
</span>
</button>
);
})}
</div>
)}
{error && (
<p className="mt-3 whitespace-pre-wrap font-mono text-xs text-[var(--color-amber)]">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-end gap-3">
<Button variant="outline" onClick={onClose} disabled={applying}>
cancel
</Button>
<Button
onClick={apply}
disabled={applying || selected.size === 0}
>
remove {selected.size > 0 ? `(${selected.size})` : ""}
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -0,0 +1,260 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, Search, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ModelOption } from "@/lib/tauri";
interface ModelSelectorProps {
model: string;
modelOptions: ModelOption[];
disabled?: boolean;
onSelect: (model: string) => void;
}
function modelLabel(option: ModelOption): string {
return option.displayName && option.displayName !== option.id
? option.displayName
: option.id;
}
type Entry =
| { kind: "header"; provider: string }
| { kind: "item"; option: ModelOption; index: number };
export function ModelSelector({
model,
modelOptions,
disabled,
onSelect,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const current = modelOptions.find((o) => o.id === model);
const grouped = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = modelOptions.filter((o) => {
if (!q) return true;
return (
o.id.toLowerCase().includes(q) ||
o.provider.toLowerCase().includes(q) ||
(o.displayName ?? "").toLowerCase().includes(q)
);
});
const map = new Map<string, ModelOption[]>();
for (const o of filtered) {
const arr = map.get(o.provider) ?? [];
arr.push(o);
map.set(o.provider, arr);
}
return Array.from(map.entries()).sort((a, b) =>
a[0].localeCompare(b[0]),
);
}, [modelOptions, query]);
const flat = useMemo(
() => grouped.flatMap(([, opts]) => opts),
[grouped],
);
const entries = useMemo<Entry[]>(() => {
const out: Entry[] = [];
let idx = 0;
for (const [provider, opts] of grouped) {
out.push({ kind: "header", provider });
for (const option of opts) {
out.push({ kind: "item", option, index: idx++ });
}
}
return out;
}, [grouped]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
useEffect(() => {
if (open) {
const i = flat.findIndex((o) => o.id === model);
setActiveIndex(i >= 0 ? i : 0);
const id = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(id);
}
setQuery("");
}, [open]);
useEffect(() => {
setActiveIndex(0);
}, [query]);
useEffect(() => {
if (!open) return;
const el = listRef.current?.querySelector(
`[data-index="${activeIndex}"]`,
);
el?.scrollIntoView({ block: "nearest" });
}, [activeIndex, open]);
const select = (id: string) => {
onSelect(id);
setOpen(false);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, flat.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const opt = flat[activeIndex];
if (opt) select(opt.id);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
};
const triggerLabel = current ? modelLabel(current) : model || "no model";
const isEmpty = modelOptions.length === 0;
return (
<div ref={wrapperRef} className="relative">
<button
type="button"
onClick={() => !disabled && setOpen((o) => !o)}
disabled={disabled || isEmpty}
title="model"
className={cn(
"group flex items-center gap-1.5 border px-2 py-0.5 font-mono text-[11px] transition-colors",
"border-[var(--color-line)] text-[var(--color-fg-dim)]",
"hover:border-[var(--color-accent)]/50 hover:text-[var(--color-fg)]",
"focus:outline-none focus:border-[var(--color-accent)]",
"disabled:opacity-50 disabled:hover:border-[var(--color-line)] disabled:hover:text-[var(--color-fg-dim)]",
open && "border-[var(--color-accent)]/60 text-[var(--color-fg)]",
)}
>
{current?.reasoningSupported && (
<Sparkles
className={cn(
"h-2.5 w-2.5 shrink-0",
current.reasoningRequired
? "text-[var(--color-amber)]"
: "text-[var(--color-accent)]/70",
)}
/>
)}
<span className="max-w-[min(40vw,22rem)] truncate tracking-tight">
{triggerLabel}
</span>
<ChevronDown
className={cn(
"h-3 w-3 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</button>
{open && (
<div className="absolute bottom-full left-0 z-40 mb-2 w-[min(90vw,28rem)] border border-[var(--color-line)] bg-[var(--color-bg-elev)] shadow-[0_24px_60px_-20px_rgba(0,0,0,0.7)]">
<div className="flex items-center gap-2 border-b border-[var(--color-line)] px-3 py-2">
<Search className="h-3 w-3 shrink-0 text-[var(--color-fg-dim)]" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="filter models…"
className="flex-1 bg-transparent font-mono text-xs text-[var(--color-fg)] placeholder:text-[var(--color-fg-dim)] focus:outline-none"
/>
<span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)]">
{flat.length}
</span>
</div>
<div ref={listRef} className="max-h-72 overflow-y-auto py-1">
{flat.length === 0 ? (
<div className="px-3 py-6 text-center font-mono text-xs text-[var(--color-fg-dim)]">
no models match.
</div>
) : (
entries.map((entry) => {
if (entry.kind === "header") {
return (
<div
key={`h:${entry.provider}`}
className="sticky top-0 bg-[var(--color-bg-elev)] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]"
>
{entry.provider}
</div>
);
}
const opt = entry.option;
const isActive = entry.index === activeIndex;
const isSelected = opt.id === model;
const showId =
opt.displayName && opt.displayName !== opt.id;
return (
<button
key={`${opt.provider}:${opt.id}`}
type="button"
data-index={entry.index}
onClick={() => select(opt.id)}
onMouseEnter={() => setActiveIndex(entry.index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left transition-colors",
isActive && "bg-[var(--color-accent-glow)]",
)}
>
<span className="flex h-3 w-3 shrink-0 items-center justify-center">
{isSelected && (
<Check className="h-3 w-3 text-[var(--color-accent)]" />
)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{modelLabel(opt)}
</span>
{showId && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{opt.id}
</span>
)}
</span>
{opt.reasoningSupported && (
<span
className={cn(
"shrink-0 font-mono text-[10px] uppercase tracking-[0.16em]",
opt.reasoningRequired
? "text-[var(--color-amber)]"
: "text-[var(--color-accent)]/60",
)}
>
reason
</span>
)}
</button>
);
})
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,62 @@
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import type { AutoFillMenu } from "@/lib/tauri";
export function SlashMenu({
menu,
selected,
onSelect,
onHover,
}: {
menu: AutoFillMenu;
selected: number;
onSelect: (index: number) => void;
onHover: (index: number) => void;
}) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = listRef.current?.querySelector(`[data-index="${selected}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [selected]);
return (
<div className="absolute bottom-full left-0 z-40 mb-2 w-[min(90vw,30rem)] border border-[var(--color-line)] bg-[var(--color-bg-elev)] shadow-[0_24px_60px_-20px_rgba(0,0,0,0.7)]">
<div className="flex items-center justify-between border-b border-[var(--color-line)] px-3 py-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
{menu.title}
</span>
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-[var(--color-fg-dim)]">
{menu.items.length}
</span>
</div>
<div ref={listRef} className="max-h-60 overflow-y-auto py-1">
{menu.items.map((item, idx) => {
const active = idx === selected;
return (
<button
key={idx}
type="button"
data-index={idx}
onClick={() => onSelect(idx)}
onMouseEnter={() => onHover(idx)}
className={cn(
"flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors",
active && "bg-[var(--color-accent-glow)]",
)}
>
<span className="truncate font-mono text-xs text-[var(--color-fg)]">
{item.label}
</span>
{item.detail && (
<span className="truncate font-mono text-[10px] text-[var(--color-fg-dim)]">
{item.detail}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import type { AccessMode, ReasoningEffort } from "@/lib/tauri";
import type { TurnState } from "@/hooks/useTurn";
export function StatusFooter({
chatId,
cwd,
model,
accessMode,
reasoningEffort,
state,
status,
}: {
chatId: string;
cwd: string;
model: string;
accessMode: AccessMode;
reasoningEffort: ReasoningEffort;
state: TurnState;
status: string;
}) {
const shortId = chatId.length > 8 ? `${chatId.slice(0, 8)}…` : chatId;
const shortCwd = cwd.replace(/^\/Users\/[^/]+/, "~");
const stateLabel = state === "approval" ? "approval" : status;
const stateColor =
state === "running"
? "text-[var(--color-accent)]"
: state === "approval"
? "text-[var(--color-amber)]"
: state === "error"
? "text-[var(--color-amber)]"
: "text-[var(--color-fg-dim)]";
return (
<div className="border-t border-[var(--color-line)] bg-[var(--color-bg-soft)]/60 px-5 py-2 font-mono text-[11px] text-[var(--color-fg-dim)]">
<span className="text-[var(--color-fg-muted)]">cass</span>
<span className="mx-1.5">·</span>
<span>{accessMode}</span>
<span className="mx-1.5">·</span>
<span className={cn(stateColor)}>{stateLabel}</span>
<span className="mx-1.5">·</span>
<span>{model}</span>
<span className="mx-1.5">·</span>
<span>{shortCwd}</span>
<span className="mx-1.5">·</span>
<span>{shortId}</span>
<span className="mx-1.5">·</span>
<span>reasoning:{reasoningEffort}</span>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from "react";
import logo from "@/assets/cass-logo-transparent.png";
import { Button } from "@/components/ui/button";
import { Plus, FolderOpen, TerminalSquare } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ChatSummary, ConversationInfo } from "@/lib/tauri";
import { listChats, newSession, resumeSession } from "@/lib/tauri";
interface TopBarProps {
chat: ConversationInfo | null;
onNewChat: () => void;
onOpenChat: () => void;
chats: ChatSummary[];
cwd: string;
}
export function TopBar({ chat, onNewChat, onOpenChat }: TopBarProps) {
return (
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-[var(--color-line)] bg-[var(--color-bg)]/85 px-4 backdrop-blur-md">
<div className="flex items-center gap-2">
<img src={logo} alt="cass" className="h-6 w-6" />
<span className="font-mono text-sm font-medium tracking-tight text-[var(--color-fg)]">
cassady
</span>
{chat && (
<span className="ml-3 font-mono text-xs text-[var(--color-fg-dim)]">
{chat.id.slice(0, 8)}…
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={onNewChat}>
<Plus className="h-3 w-3" />
new
</Button>
<Button size="sm" variant="ghost" onClick={onOpenChat}>
<FolderOpen className="h-3 w-3" />
open
</Button>
</div>
</header>
);
}
export function OpenChatList({
chats,
onPick,
onClose,
}: {
chats: ChatSummary[];
onPick: (id: string) => void;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className={cn(
"w-full max-w-md border border-[var(--color-line)] bg-[var(--color-bg-soft)]",
)}
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-[var(--color-line)] px-4 py-3 font-mono text-xs uppercase tracking-[0.2em] text-[var(--color-fg-dim)]">
open chat
</div>
<div className="max-h-80 overflow-y-auto">
{chats.length === 0 ? (
<div className="px-4 py-6 font-mono text-xs text-[var(--color-fg-dim)]">
no chats for this directory.
</div>
) : (
chats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onPick(c.id)}
className="flex w-full flex-col gap-0.5 border-b border-[var(--color-line)] px-4 py-3 text-left hover:bg-[var(--color-bg-elev)]/50 transition-colors"
>
<span className="font-mono text-xs text-[var(--color-fg)]">
{c.id}
</span>
<span className="font-mono text-xs text-[var(--color-fg-dim)]">
{c.model} · {c.firstUserPreview.slice(0, 60) || "(empty)"}
</span>
</button>
))
)}
</div>
</div>
</div>
);
}
export function NoConfigCard({ cwd }: { cwd: string }) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="max-w-md border border-[var(--color-line)] bg-[var(--color-bg-soft)]/60 p-8 text-center backdrop-blur-sm">
<TerminalSquare className="mx-auto h-8 w-8 text-[var(--color-accent)]" />
<h2 className="mt-4 font-mono text-base font-medium text-[var(--color-fg)]">
run <span className="text-[var(--color-accent)]">cass setup</span> first
</h2>
<p className="mt-2 font-sans text-sm text-[var(--color-fg-muted)]">
Cassady needs a provider and model configured under{" "}
<code className="font-mono text-[var(--color-fg)]">~/.cass</code>.
Run the setup wizard in a terminal:
</p>
<pre className="mt-4 overflow-x-auto border border-[var(--color-line)] bg-[var(--color-bg)] px-3 py-2 text-left font-mono text-xs text-[var(--color-fg)]">
<span className="text-[var(--color-accent)]">▸</span> cass setup
</pre>
<p className="mt-4 font-mono text-xs text-[var(--color-fg-dim)]">
cwd: {cwd.replace(/^\/Users\/[^/]+/, "~")}
</p>
</div>
</div>
);
}
export function useSessionManager(cwd: string) {
const [chat, setChat] = useState<ConversationInfo | null>(null);
const [chats, setChats] = useState<ChatSummary[]>([]);
const [showOpen, setShowOpen] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
const refreshChats = async () => {
try {
setChats(await listChats(cwd));
} catch {
// ignore list errors
}
};
useEffect(() => {
void refreshChats();
}, [cwd]);
const handleNew = async () => {
try {
const info = await newSession({ cwd });
setChat(info);
setConfigError(null);
await refreshChats();
} catch (e) {
setConfigError(String(e));
}
};
const handleOpen = async () => {
await refreshChats();
setShowOpen(true);
};
const handlePick = async (id: string) => {
setShowOpen(false);
try {
const info = await resumeSession(id, cwd);
setChat(info);
setConfigError(null);
} catch (e) {
setConfigError(String(e));
}
};
return {
chat,
setChat,
chats,
showOpen,
setShowOpen,
configError,
handleNew,
handleOpen,
handlePick,
refreshChats,
};
}
@@ -0,0 +1,55 @@
import { useEffect, useRef } from "react";
import { type TranscriptBlock } from "@/hooks/useTurn";
import { UserBlock } from "@/components/blocks/UserBlock";
import { AssistantBlock } from "@/components/blocks/AssistantBlock";
import { ReasoningBlock } from "@/components/blocks/ReasoningBlock";
import { ToolBlock } from "@/components/blocks/ToolBlock";
import { StatusBlock } from "@/components/blocks/StatusBlock";
export function Transcript({ blocks }: { blocks: TranscriptBlock[] }) {
const endRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [blocks]);
return (
<div
ref={containerRef}
className="relative flex-1 overflow-y-auto px-5 py-4 font-mono text-[13px] leading-relaxed"
>
<div className="pointer-events-none absolute inset-0 z-0 opacity-[0.04] bg-[repeating-linear-gradient(to_bottom,transparent_0,transparent_2px,rgba(255,255,255,1)_2px,rgba(255,255,255,1)_3px)]" />
<div className="relative z-10">
{blocks.length === 0 ? (
<div className="font-mono text-xs text-[var(--color-fg-dim)]">
send a message to start.
</div>
) : (
blocks.map((b) => {
switch (b.kind) {
case "user":
return <UserBlock key={b.id} text={b.text} />;
case "assistant":
return <AssistantBlock key={b.id} text={b.text} />;
case "reasoning":
return <ReasoningBlock key={b.id} text={b.text} />;
case "tool":
return <ToolBlock key={b.id} block={b} />;
case "status":
return <StatusBlock key={b.id} text={b.text} />;
case "error":
return <StatusBlock key={b.id} text={b.text} marker="✗" />;
default:
return null;
}
})
)}
<div ref={endRef} />
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
export function AssistantBlock({
text,
className,
}: {
text: string;
className?: string;
}) {
return (
<div className={cn("py-1.5", className)}>
<span className="text-[var(--color-accent)] text-glow">cass</span>
<div className="mt-1 pl-4 text-[var(--color-fg)] prose-cass">
{text ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className: c, children, ...props }) {
const isInline = !c;
return isInline ? (
<code
className="font-mono text-[0.85em] text-[var(--color-accent)] bg-[var(--color-bg-soft)] px-1 py-0.5 rounded-[3px]"
{...props}
>
{children}
</code>
) : (
<code className={cn("font-mono", c)} {...props}>
{children}
</code>
);
},
pre({ children }) {
return (
<pre className="my-2 overflow-x-auto border border-[var(--color-line)] bg-[var(--color-bg-soft)] p-3 text-[13px] font-mono text-[var(--color-fg-muted)] rounded-[6px]">
{children}
</pre>
);
},
a({ children, href }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-[var(--color-accent)] underline underline-offset-2 hover:opacity-80"
>
{children}
</a>
);
},
}}
>
{text}
</ReactMarkdown>
) : (
<span className="text-[var(--color-fg-dim)]">…</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export function ReasoningBlock({
text,
className,
}: {
text: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<div className={cn("py-1", className)}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 font-mono text-xs text-[var(--color-fg-dim)] hover:text-[var(--color-fg-muted)] transition-colors"
>
<ChevronRight
className={cn(
"h-3 w-3 transition-transform duration-200",
open && "rotate-90",
)}
/>
reasoning
</button>
{open && (
<div className="mt-1 pl-4 font-mono text-xs text-[var(--color-fg-dim)] whitespace-pre-wrap break-words">
{text}
</div>
)}
</div>
);
}
@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";
export function StatusBlock({
text,
marker = "·",
className,
}: {
text: string;
marker?: string;
className?: string;
}) {
return (
<div className={cn("whitespace-pre-wrap py-1 font-mono text-xs text-[var(--color-amber)]", className)}>
{marker} {text}
</div>
);
}
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import type { TranscriptBlock } from "@/hooks/useTurn";
export function ToolBlock({
block,
className,
}: {
block: TranscriptBlock;
className?: string;
}) {
const ok = block.toolOk;
const done = block.toolDone;
const args = block.toolArguments
? JSON.stringify(block.toolArguments, null, 2)
: "";
return (
<div className={cn("py-1.5", className)}>
<div className="font-mono text-xs text-[var(--color-fg-dim)]">
· {block.toolName}
{done && (
<span>
{" "}
<span
className={
ok
? "text-[var(--color-accent)]"
: "text-[var(--color-amber)]"
}
>
{ok ? "✓" : "✗"}
</span>
</span>
)}
</div>
{args && (
<pre className="mt-1 overflow-x-auto pl-4 font-mono text-xs text-[var(--color-fg-dim)] whitespace-pre-wrap break-words">
{args}
</pre>
)}
{block.toolOutput && (
<pre className="mt-1 overflow-x-auto pl-4 font-mono text-xs text-[var(--color-fg-muted)] whitespace-pre-wrap break-words">
{block.toolOutput}
</pre>
)}
{!done && block.text && (
<pre className="mt-1 overflow-x-auto pl-4 font-mono text-xs text-[var(--color-fg-dim)] whitespace-pre-wrap break-words">
{block.text}
</pre>
)}
</div>
);
}
@@ -0,0 +1,18 @@
import { cn } from "@/lib/utils";
export function UserBlock({
text,
className,
}: {
text: string;
className?: string;
}) {
return (
<div className={cn("py-1.5", className)}>
<span className="text-[var(--color-accent)]">› you</span>
<div className="mt-0.5 pl-4 text-[var(--color-fg-muted)] whitespace-pre-wrap break-words">
{text}
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn(
"border-b border-[var(--color-line)] last:border-b-0",
className,
)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"group flex flex-1 items-center justify-between py-5 font-mono text-sm font-medium text-[var(--color-fg)] transition-all hover:text-[var(--color-accent)] [&[data-state=open]>svg]:rotate-90",
className,
)}
{...props}
>
{children}
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--color-fg-dim)] transition-transform duration-200 group-data-[state=open]:text-[var(--color-accent)]" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-[accordion-up_0.2s_ease-out] data-[state=open]:animate-[accordion-down_0.2s_ease-out]"
{...props}
>
<div
className={cn(
"pb-5 pr-8 font-sans text-[var(--color-fg-muted)] leading-relaxed",
className,
)}
>
{children}
</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,54 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-mono text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-[var(--color-accent)] text-[#0b0d10] hover:bg-[var(--color-accent)]/90 hover:shadow-[0_0_24px_-4px_var(--color-accent-glow)]",
outline:
"border border-[var(--color-line)] bg-transparent text-[var(--color-fg)] hover:border-[var(--color-accent)]/60 hover:text-[var(--color-accent)] hover:bg-[var(--color-accent-glow)]",
ghost:
"text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-white/[0.03]",
link: "text-[var(--color-accent)] underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-5 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-12 px-7 text-base",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -0,0 +1,89 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative border border-[var(--color-line)] bg-[var(--color-bg-soft)]/60 text-[var(--color-fg)] backdrop-blur-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"font-mono text-base font-medium leading-none tracking-tight text-[var(--color-fg)]",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"font-sans text-sm text-[var(--color-fg-muted)]",
className,
)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

Some files were not shown because too many files have changed in this diff Show More