Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ce998b4e | ||
|
|
415275c5a1 | ||
|
|
99ea3fed50 | ||
|
|
e564777877 | ||
|
|
b4ff4f9f14 | ||
|
|
ac86ee3933 | ||
|
|
da577aed1c | ||
|
|
ab0c45aff4 | ||
|
|
b35c4fb7e1 | ||
|
|
d05bb87a23 | ||
|
|
52eca6477e | ||
|
|
8b87e11e53 | ||
|
|
62e1d53f60 | ||
|
|
39e0c14eec | ||
|
|
909c28d587 | ||
|
|
a2d2549df4 | ||
|
|
b37bc677bd | ||
|
|
6767bef88e | ||
|
|
cd6b2ca034 | ||
|
|
4687f835cb | ||
|
|
1ab982167b | ||
|
|
1c1eeb18f7 | ||
|
|
93348269a8 | ||
|
|
a59c9fbbae | ||
|
|
2845529682 | ||
|
|
bcbb9dfa1b | ||
|
|
7c84a54e6a | ||
|
|
71f84c03ce | ||
|
|
4d021fe2ab | ||
|
|
7c8cc9eac5 | ||
|
|
52c879e7c8 | ||
|
|
60dfdf3806 | ||
|
|
8ffc5284e3 | ||
|
|
7f0f1619af | ||
|
|
009a39d748 | ||
|
|
a3e4248957 | ||
|
|
df88563808 | ||
|
|
2077894896 | ||
|
|
6ec48a9d4a | ||
|
|
ccf6d79fa4 |
@@ -0,0 +1,101 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible problem with Cassady / cass
|
||||
title: "bug: "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a bug. Please include enough detail for someone to reproduce it.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What went wrong?
|
||||
placeholder: Cassady crashes when...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: List the exact commands, key presses, or config changes needed to trigger the issue.
|
||||
placeholder: |
|
||||
1. Run `cass ...`
|
||||
2. Press ...
|
||||
3. See ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What happened instead? Paste errors, logs, or terminal output when useful.
|
||||
render: text
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Cassady version
|
||||
description: Output of `cass --version` or `cassady --version`.
|
||||
placeholder: cassady 0.2.x
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install
|
||||
attributes:
|
||||
label: Install method
|
||||
options:
|
||||
- Release archive
|
||||
- cargo install --git
|
||||
- cargo install --path / local build
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
options:
|
||||
- macOS Apple Silicon
|
||||
- macOS Intel
|
||||
- Linux x86_64
|
||||
- Linux ARM64
|
||||
- Windows x86_64
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment details
|
||||
description: Terminal, shell, provider/model, access mode, and anything notable from `cass check`. Do not include API keys.
|
||||
placeholder: |
|
||||
Terminal: ...
|
||||
Shell: ...
|
||||
Provider/model: ...
|
||||
Access mode: ...
|
||||
`cass check`: ...
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Relevant configuration
|
||||
description: Paste sanitized snippets from `~/.cass/config.json`, `providers.json`, or `models.json` if relevant. Remove API keys and secrets.
|
||||
render: json
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I removed API keys, tokens, and other secrets from this report.
|
||||
required: true
|
||||
- label: I searched existing issues for a duplicate.
|
||||
required: true
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and usage help
|
||||
url: https://github.com/owenqwenstarsky/cassady/discussions
|
||||
about: Ask questions, share setup notes, or discuss ideas before filing an issue.
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Documentation issue
|
||||
description: Report missing, confusing, or incorrect documentation
|
||||
title: "docs: "
|
||||
labels: [documentation]
|
||||
body:
|
||||
- type: textarea
|
||||
id: location
|
||||
attributes:
|
||||
label: Documentation location
|
||||
description: Link to the page or name the file/section, if known.
|
||||
placeholder: README.md, docs/providers.md, docs/access-modes.md, ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: issue
|
||||
attributes:
|
||||
label: What is unclear or incorrect?
|
||||
description: Describe the gap, mistake, or confusing wording.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: suggested
|
||||
attributes:
|
||||
label: Suggested improvement
|
||||
description: If you have specific wording or examples in mind, add them here.
|
||||
- type: dropdown
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact
|
||||
options:
|
||||
- Blocks installation or first use
|
||||
- Causes incorrect configuration
|
||||
- Confuses normal usage
|
||||
- Typo or small polish
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I searched existing issues for a duplicate.
|
||||
required: true
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Feature request
|
||||
description: Suggest an improvement or new capability for Cassady / cass
|
||||
title: "feat: "
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting an improvement. Please focus on the user problem and desired outcome.
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or use case
|
||||
description: What are you trying to do, and what makes it hard today?
|
||||
placeholder: I want to...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed behavior
|
||||
description: Describe the change you would like to see.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: What workarounds or other designs have you considered?
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
options:
|
||||
- Terminal UI
|
||||
- Setup and configuration
|
||||
- Providers and models
|
||||
- Tools and file editing
|
||||
- Safety and access modes
|
||||
- Conversation history and resume
|
||||
- Documentation
|
||||
- Packaging and releases
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: examples
|
||||
attributes:
|
||||
label: Examples or mockups
|
||||
description: Add sample commands, UI text, config snippets, screenshots, or links that clarify the request.
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I searched existing issues for related requests.
|
||||
required: true
|
||||
- label: This request is in scope for a terminal coding agent, not a general package manager or updater.
|
||||
required: false
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Maintenance task
|
||||
description: Track internal cleanup, refactoring, testing, CI, or release work
|
||||
title: "chore: "
|
||||
labels: [maintenance]
|
||||
body:
|
||||
- type: textarea
|
||||
id: goal
|
||||
attributes:
|
||||
label: Goal
|
||||
description: What should be done, and why?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: scope
|
||||
attributes:
|
||||
label: Scope
|
||||
description: List concrete files, modules, or workflows that are in scope.
|
||||
placeholder: |
|
||||
- src/...
|
||||
- tests/...
|
||||
- .github/workflows/...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: out_of_scope
|
||||
attributes:
|
||||
label: Out of scope
|
||||
description: Note anything this task should intentionally avoid.
|
||||
- type: textarea
|
||||
id: acceptance
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: What must be true before this can be closed?
|
||||
placeholder: |
|
||||
- [ ] ...
|
||||
- [ ] `cargo test --locked --all-targets` passes
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
options:
|
||||
- Refactoring
|
||||
- Tests
|
||||
- CI
|
||||
- Release process
|
||||
- Dependencies
|
||||
- Documentation maintenance
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the docs Markdown files into a static GitHub Pages site.
|
||||
|
||||
This intentionally avoids themed site generators and template files. Each Markdown
|
||||
file is converted to a minimal standalone HTML page, and relative .md links are
|
||||
rewritten to the generated .html filenames.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path, PurePosixPath
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import markdown
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
SITE_DIR = ROOT / "site"
|
||||
|
||||
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "toc"]
|
||||
HREF_RE = re.compile(r'href="([^"]+)"')
|
||||
|
||||
|
||||
def output_path(source: Path) -> Path:
|
||||
if source.name == "README.md":
|
||||
return SITE_DIR / "index.html"
|
||||
return SITE_DIR / f"{source.stem}.html"
|
||||
|
||||
|
||||
def page_title(text: str, fallback: str) -> str:
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def rewrite_markdown_links(rendered: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
href = html.unescape(match.group(1))
|
||||
parts = urlsplit(href)
|
||||
if parts.scheme or parts.netloc or not parts.path.endswith(".md"):
|
||||
return match.group(0)
|
||||
|
||||
url_path = PurePosixPath(parts.path)
|
||||
if url_path.name == "README.md":
|
||||
new_path = str(url_path.with_name("index.html"))
|
||||
else:
|
||||
new_path = parts.path[:-3] + ".html"
|
||||
|
||||
new_href = urlunsplit(("", "", new_path, parts.query, parts.fragment))
|
||||
return f'href="{html.escape(new_href, quote=True)}"'
|
||||
|
||||
return HREF_RE.sub(replace, rendered)
|
||||
|
||||
|
||||
def render_page(source: Path) -> str:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
title = page_title(text, "Cassady docs")
|
||||
body = markdown.markdown(
|
||||
text,
|
||||
extensions=MARKDOWN_EXTENSIONS,
|
||||
output_format="html5",
|
||||
)
|
||||
body = rewrite_markdown_links(body)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
"<head>",
|
||||
' <meta charset="utf-8">',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
f" <title>{html.escape(title)}</title>",
|
||||
"</head>",
|
||||
"<body>",
|
||||
body,
|
||||
"</body>",
|
||||
"</html>",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def copy_static_assets() -> None:
|
||||
for item in DOCS_DIR.iterdir():
|
||||
if item.suffix == ".md":
|
||||
continue
|
||||
destination = SITE_DIR / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, destination)
|
||||
elif item.is_file():
|
||||
shutil.copy2(item, destination)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if SITE_DIR.exists():
|
||||
shutil.rmtree(SITE_DIR)
|
||||
SITE_DIR.mkdir(parents=True)
|
||||
|
||||
for source in sorted(DOCS_DIR.glob("*.md")):
|
||||
output_path(source).write_text(render_page(source), encoding="utf-8")
|
||||
|
||||
copy_static_assets()
|
||||
(SITE_DIR / ".nojekyll").write_text("", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,20 @@
|
||||
name: CI
|
||||
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'tests/**'
|
||||
- 'cassady-desktop/**'
|
||||
- 'docs/**'
|
||||
- 'plans/**'
|
||||
- '*.md'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'build.rs'
|
||||
- 'package.json'
|
||||
- 'npm/**'
|
||||
- '.github/workflows/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,6 +30,18 @@ jobs:
|
||||
- name: Check out repository
|
||||
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
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -32,6 +58,18 @@ jobs:
|
||||
- name: Check out repository
|
||||
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
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Publish Markdown site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/scripts/build-pages.py'
|
||||
- '.github/workflows/pages.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/scripts/build-pages.py'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: github-pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Markdown site
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install Markdown converter
|
||||
run: python -m pip install Markdown
|
||||
|
||||
- name: Build site
|
||||
run: python .github/scripts/build-pages.py
|
||||
|
||||
- name: Configure Pages
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload Pages artifact
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy Pages artifact
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -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
|
||||
@@ -1 +1,6 @@
|
||||
/target/
|
||||
/dist/
|
||||
/site/
|
||||
*node_modules/
|
||||
*dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Release process: build artifacts and create a draft GitHub release
|
||||
|
||||
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
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
gh release list --repo owenqwenstarsky/cassady --limit 5
|
||||
gh release view --repo owenqwenstarsky/cassady --json tagName,body --jq '.tagName + "\n\n" + .body'
|
||||
```
|
||||
|
||||
Keep release notes consistent with the existing format:
|
||||
|
||||
- Title: `## Cassady vX.Y.Z`
|
||||
- One short summary paragraph.
|
||||
- `### Downloads` with four bullets in this order: macOS Apple Silicon, Linux x86_64, Linux ARM64, Windows x86_64.
|
||||
- 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.
|
||||
- Optional `### Upgrade notes` only when compatibility, config, or migration details matter.
|
||||
- `### Install from source` with a `cargo install --git ... --tag vX.Y.Z` command.
|
||||
- `### Verification` listing the exact test/build commands used.
|
||||
|
||||
### 2. Confirm the version and starting state
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
VERSION=$(awk -F\" '/^version = / { print $2; exit }' Cargo.toml)
|
||||
TAG="v${VERSION}"
|
||||
echo "$TAG"
|
||||
git log --oneline --decorate -20
|
||||
```
|
||||
|
||||
If the version is wrong, update `Cargo.toml` and `Cargo.lock` first, then commit that change before tagging. Release tags should point at the intended release commit on `main`.
|
||||
|
||||
### 3. Run verification before packaging
|
||||
|
||||
```sh
|
||||
cargo +stable test --locked --all-targets
|
||||
```
|
||||
|
||||
### 4. Build all release binaries manually only when needed
|
||||
|
||||
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
|
||||
rustup target add aarch64-apple-darwin x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-pc-windows-gnu
|
||||
cargo install cargo-zigbuild tauri-cli --locked
|
||||
```
|
||||
|
||||
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
|
||||
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)
|
||||
# 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
|
||||
|
||||
This creates the unpacked artifact directories, compressed archives, and checksum files for the current `$TAG`. Generate checksums from inside `dist/` so the `.sha256` files contain archive names without a `dist/` prefix.
|
||||
|
||||
```sh
|
||||
VERSION=$(awk -F\" '/^version = / { print $2; exit }' Cargo.toml)
|
||||
TAG="v${VERSION}"
|
||||
|
||||
rm -rf \
|
||||
"dist/cassady-${TAG}-aarch64-apple-darwin"* \
|
||||
"dist/cassady-${TAG}-x86_64-unknown-linux-gnu"* \
|
||||
"dist/cassady-${TAG}-aarch64-unknown-linux-gnu"* \
|
||||
"dist/cassady-${TAG}-x86_64-pc-windows-gnu"*
|
||||
|
||||
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
|
||||
name="cassady-${TAG}-${target}"
|
||||
desktop_bin=$(desktop_binary_for "$target")
|
||||
test -f "$desktop_bin"
|
||||
mkdir -p "dist/${name}"
|
||||
cp "target/${target}/release/cass" "dist/${name}/cass"
|
||||
cp "target/${target}/release/cassady" "dist/${name}/cassady"
|
||||
cp "$desktop_bin" "dist/${name}/cassady-desktop"
|
||||
cp README.md "dist/${name}/README.md"
|
||||
tar -C dist -czf "dist/${name}.tar.gz" "${name}"
|
||||
done
|
||||
|
||||
win_target=x86_64-pc-windows-gnu
|
||||
win_name="cassady-${TAG}-${win_target}"
|
||||
mkdir -p "dist/${win_name}"
|
||||
cp "target/${win_target}/release/cass.exe" "dist/${win_name}/cass.exe"
|
||||
cp "target/${win_target}/release/cassady.exe" "dist/${win_name}/cassady.exe"
|
||||
cp README.md "dist/${win_name}/README.md"
|
||||
(cd dist && zip -qr "${win_name}.zip" "${win_name}")
|
||||
|
||||
(cd dist && for artifact in cassady-${TAG}-*.tar.gz cassady-${TAG}-*.zip; do
|
||||
shasum -a 256 "$artifact" > "${artifact}.sha256"
|
||||
done)
|
||||
```
|
||||
|
||||
Sanity-check the generated artifacts:
|
||||
|
||||
```sh
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
### 6. Write the release notes
|
||||
|
||||
Create `dist/RELEASE_NOTES_${TAG}.md` using the same structure as prior releases. Use this template and replace the summary/highlights with the actual changes:
|
||||
|
||||
````md
|
||||
## Cassady vX.Y.Z
|
||||
|
||||
Cassady vX.Y.Z ...
|
||||
|
||||
### Downloads
|
||||
|
||||
- macOS Apple Silicon: `cassady-vX.Y.Z-aarch64-apple-darwin.tar.gz`
|
||||
- Linux x86_64: `cassady-vX.Y.Z-x86_64-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`
|
||||
|
||||
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
|
||||
|
||||
- ...
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
...
|
||||
|
||||
### Install from source
|
||||
|
||||
```sh
|
||||
cargo install --git https://github.com/owenqwenstarsky/cassady --tag vX.Y.Z
|
||||
```
|
||||
|
||||
### Verification
|
||||
|
||||
Built and tested with:
|
||||
|
||||
```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 and copied into the Linux archives.
|
||||
```
|
||||
````
|
||||
|
||||
Check the notes before uploading:
|
||||
|
||||
```sh
|
||||
sed -n '1,220p' "dist/RELEASE_NOTES_${TAG}.md"
|
||||
```
|
||||
|
||||
### 7. Tag the release commit
|
||||
|
||||
Only tag after tests pass, artifacts are built, and release notes are ready.
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
git status --short # should be clean except generated dist/ files
|
||||
git tag -a "$TAG" -m "Cassady ${TAG}"
|
||||
git push origin "$TAG"
|
||||
```
|
||||
|
||||
If the tag already exists, stop and ask before deleting or moving it.
|
||||
|
||||
### 8. Create the GitHub release as a draft
|
||||
|
||||
Upload only the current version's archives and checksum files. Keep `--draft` and `--verify-tag` in the command.
|
||||
|
||||
```sh
|
||||
gh release create "$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 owenqwenstarsky/cassady \
|
||||
--title "Cassady ${TAG}" \
|
||||
--notes-file "dist/RELEASE_NOTES_${TAG}.md" \
|
||||
--draft \
|
||||
--verify-tag
|
||||
```
|
||||
|
||||
Verify the draft:
|
||||
|
||||
```sh
|
||||
gh release view "$TAG" --repo owenqwenstarsky/cassady --json tagName,name,isDraft,isPrerelease,assets --jq .
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
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.
|
||||
|
||||
### 1. Review the existing roadmap format
|
||||
|
||||
```sh
|
||||
sed -n '1,220p' ROADMAP.md
|
||||
ls plans
|
||||
```
|
||||
|
||||
Current conventions:
|
||||
|
||||
- Keep the `# Cassady (Cass) Roadmap` title at the top.
|
||||
- Add the newest release immediately below the title, above older releases.
|
||||
- Use headings like `## vX.Y.Z — Short Theme` for planned releases.
|
||||
- Add `✅ Completed` to the heading only after the release is actually complete.
|
||||
- Start with a short paragraph: `This release focuses on ...`.
|
||||
- If there is a detailed plan, reference it with a sentence like: See `plans/PLAN_FILE.md`.
|
||||
- Group work under `###` area headings such as `Interactive Setup`, `Agent Control`, or `Safety and Reviewability`.
|
||||
- Use checklist items: `- [ ]` for planned work, `- [x]` for completed work.
|
||||
- Write main tasks as bold, user-facing outcomes: `**Add a first-run setup wizard.** ...`.
|
||||
- Use indented bullets for scope details, constraints, and explicit deferrals.
|
||||
|
||||
### 2. Decide the release scope
|
||||
|
||||
Before editing `ROADMAP.md`, identify:
|
||||
|
||||
- Version number: `vX.Y.Z`.
|
||||
- Short theme: a concise release name after the em dash.
|
||||
- One-paragraph goal: what the release changes for users.
|
||||
- 2-4 major areas to group the work.
|
||||
- Concrete checklist tasks under each area.
|
||||
- Any intentionally deferred work, especially broad integrations or risky scope.
|
||||
- Optional plan file under `plans/` if the release needs deeper implementation detail.
|
||||
|
||||
Prefer roadmap items that describe outcomes and acceptance criteria, not implementation minutiae. Keep them concise enough to scan.
|
||||
|
||||
### 3. Insert the new entry
|
||||
|
||||
Place the new release section directly under the top-level title:
|
||||
|
||||
```md
|
||||
# Cassady (Cass) Roadmap
|
||||
|
||||
## vX.Y.Z — Short Release Theme
|
||||
|
||||
This release focuses on ... See `plans/VX_Y_Z_SHORT_PLAN.md`.
|
||||
|
||||
### Area Name
|
||||
|
||||
- [ ] **User-facing task title.** Describe the outcome in one sentence.
|
||||
- Add key behavior or acceptance criteria.
|
||||
- Note constraints or non-goals.
|
||||
|
||||
- [ ] **Second task title.** Describe the next outcome.
|
||||
- Include compatibility, docs, tests, or safety requirements when relevant.
|
||||
|
||||
### Another Area
|
||||
|
||||
- [ ] **Another task title.** Describe the outcome.
|
||||
- Keep details specific and checkable.
|
||||
|
||||
## vPrevious — Existing Theme ✅ Completed
|
||||
```
|
||||
|
||||
If there is no detailed plan file yet, omit the `See ...` sentence rather than linking to a nonexistent file.
|
||||
|
||||
### 4. Keep old entries stable
|
||||
|
||||
- Do not reorder completed releases except to insert the new release at the top.
|
||||
- Do not rewrite old completed scopes unless correcting a clear error.
|
||||
- Use `[x]` only for work that has landed.
|
||||
- Add `✅ Completed` only when the release has shipped or the user explicitly asks to mark it complete.
|
||||
- Keep wording consistent with earlier entries: concise headings, bold task names, and nested details.
|
||||
|
||||
### 5. Check the edit
|
||||
|
||||
```sh
|
||||
sed -n '1,180p' ROADMAP.md
|
||||
git diff -- ROADMAP.md
|
||||
```
|
||||
|
||||
## Plan process: write implementation plans in `plans/`
|
||||
|
||||
Use this process when creating or updating an implementation plan. All project plans belong in the `plans/` directory; do not put new plan documents at the repository root.
|
||||
|
||||
### 1. Review previous plans first
|
||||
|
||||
```sh
|
||||
ls plans
|
||||
sed -n '1,240p' plans/V0_2_2_ONBOARDING_SETUP_WIZARD_PLAN.md
|
||||
sed -n '1,220p' plans/V0_2_1_MESSAGE_RENDERING_POLISH_PLAN.md
|
||||
sed -n '1,220p' plans/V0_2_1_COLLAPSED_TOOL_DENSITY_PLAN.md
|
||||
```
|
||||
|
||||
Also read any plan that is directly related to the new work. For example, read `plans/SECURITY_ACCESS_MODES_PLAN.md` before planning access-control work, or `plans/PROVIDER_MODEL_CONFIG_PLAN.md` before planning provider/config changes.
|
||||
|
||||
### 2. Choose the plan filename
|
||||
|
||||
Write new plans under `plans/` using uppercase snake-case names:
|
||||
|
||||
- Release-scoped plan: `plans/VX_Y_Z_SHORT_THEME_PLAN.md`, e.g. `plans/V0_2_3_CONTEXT_MANAGEMENT_PLAN.md`.
|
||||
- Feature/follow-up plan: `plans/FEATURE_OR_AREA_PLAN.md`, e.g. `plans/SECURITY_ACCESS_MODES_PLAN.md`.
|
||||
- Follow-up to an existing release plan: include the version and specific topic, e.g. `plans/V0_2_1_COLLAPSED_TOOL_DENSITY_PLAN.md`.
|
||||
|
||||
Prefer one focused plan per coherent feature or release theme. Do not edit `plans/PLAN.md` for new release work; it is the historical MVP plan.
|
||||
|
||||
### 3. Match the existing plan structure
|
||||
|
||||
Most plans should use this shape, adapted to the task size:
|
||||
|
||||
````md
|
||||
# vX.Y.Z Short Theme Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
State the user-facing outcome in a short paragraph. If helpful, add a success statement.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Concrete included work.
|
||||
- Supported behavior and user-visible changes.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Explicit non-goals and deferred work.
|
||||
- Integrations or risky scope that should not be pulled into this plan.
|
||||
|
||||
## Context or Current State
|
||||
|
||||
Describe the relevant files, modules, existing behavior, and constraints.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Principle that guides tradeoffs.
|
||||
2. Safety, compatibility, or UX constraint.
|
||||
3. Simplicity or deferral rule.
|
||||
|
||||
## Design
|
||||
|
||||
Describe the proposed behavior and architecture. Include tables, examples, CLI output, JSON shapes, or module/type sketches when they make implementation clearer.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. First concrete code/docs step.
|
||||
2. Next step.
|
||||
3. Final integration step.
|
||||
|
||||
## Tests
|
||||
|
||||
- Specific unit/integration tests to add or update.
|
||||
- Manual checks when automated tests are not enough.
|
||||
|
||||
## Documentation
|
||||
|
||||
- README, bundled docs, release notes, or roadmap updates required.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Checkable condition that proves the plan is done.
|
||||
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||
````
|
||||
|
||||
For small follow-ups, it is okay to use the shorter style from `V0_2_1_COLLAPSED_TOOL_DENSITY_PLAN.md`: `Context`, `Goal`, `Scope`, `Design`, `Implementation Steps`, and `Acceptance Criteria`.
|
||||
|
||||
### 4. Writing guidelines
|
||||
|
||||
- Keep plans implementation-oriented but readable by a future agent.
|
||||
- Be explicit about in-scope vs out-of-scope work to prevent scope creep.
|
||||
- Mention exact files/modules when known, such as `src/ui/render.rs` or `src/config.rs`.
|
||||
- Include examples of expected CLI output, JSON, or UI text when behavior matters.
|
||||
- Add compatibility and migration notes when config, storage, provider behavior, or public commands change.
|
||||
- Add docs and tests sections for any user-facing change.
|
||||
- Prefer ordered implementation steps over vague tasks.
|
||||
- Do not mark roadmap items complete just because a plan was written.
|
||||
|
||||
### 5. Check the plan
|
||||
|
||||
```sh
|
||||
sed -n '1,260p' plans/YOUR_PLAN_FILE.md
|
||||
git diff -- plans/YOUR_PLAN_FILE.md ROADMAP.md
|
||||
```
|
||||
|
||||
## General project notes
|
||||
|
||||
- This is a Rust project. Use `cargo test --locked --all-targets` before handing off code changes when practical.
|
||||
- Keep release notes user-facing and concise; avoid dumping raw commit logs.
|
||||
- `dist/` contains generated release artifacts. Rebuild current-version files rather than editing archives by hand.
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cassady"
|
||||
version = "0.2.1"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
description = "Cassady/Cass minimal terminal coding agent"
|
||||
license = "MIT"
|
||||
@@ -23,23 +23,32 @@ anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
crossterm = "0.28"
|
||||
crossterm = "0.29"
|
||||
dirs = "5"
|
||||
futures-util = "0.3"
|
||||
ignore = "0.4"
|
||||
include_dir = "0.7"
|
||||
libc = "0.2"
|
||||
nanoid = "0.4"
|
||||
ratatui = "0.28"
|
||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm", "underline-color"] }
|
||||
regex = "1"
|
||||
pulldown-cmark = "0.12"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
flate2 = "1"
|
||||
tar = "0.4"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "process", "io-util"] }
|
||||
tui-textarea = "0.6"
|
||||
unicode-width = "0.1"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
|
||||
[workspace]
|
||||
members = ["cassady-desktop/src-tauri"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -1,103 +1,176 @@
|
||||
# Cassady / Cass
|
||||
|
||||
Cassady (`cass`) is a minimal Rust terminal coding agent with a looped chat UI, filesystem tools, access modes, JSONL conversation persistence, and OpenAI-compatible LLM support. The default endpoint is Fireworks.
|
||||
> ⚠️ This project is in early beta. It may not run perfectly on all hardware or operating systems, and setup may require some manual work.
|
||||
|
||||
## Install
|
||||
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`.
|
||||
|
||||
## Current scope and limitations
|
||||
|
||||
- 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; v0.4.0 also includes a desktop app preview launched with `cass desktop` on macOS/Linux binary installs.
|
||||
- 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`.
|
||||
- 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, including the companion desktop binary when present in a prebuilt archive; external package managers should still be updated through their own tools.
|
||||
|
||||
## Install with npm
|
||||
|
||||
The recommended installation method is npm:
|
||||
|
||||
```sh
|
||||
cargo install --path .
|
||||
npm install -g cassady
|
||||
```
|
||||
|
||||
This installs both commands:
|
||||
|
||||
```sh
|
||||
cass --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
|
||||
|
||||
Start Cassady in a project directory:
|
||||
|
||||
```sh
|
||||
cass
|
||||
cassady
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
By default Cass creates `~/.cass/providers.json` and `~/.cass/models.json` with Fireworks configured:
|
||||
|
||||
- base URL: `https://api.fireworks.ai/inference/v1`
|
||||
- model: `accounts/fireworks/models/qwen3p7-plus`
|
||||
- API key: `"$FIREWORKS_API_KEY"`
|
||||
|
||||
Set your key:
|
||||
|
||||
```sh
|
||||
export FIREWORKS_API_KEY=...
|
||||
```
|
||||
|
||||
User preferences live at `~/.cass/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"default_model": "accounts/fireworks/models/qwen3p7-plus",
|
||||
"default_access_mode": "read-only",
|
||||
"show_reasoning": false
|
||||
}
|
||||
```
|
||||
|
||||
Provider connection details belong in `~/.cass/providers.json`. Model metadata belongs in `~/.cass/models.json`. API keys may be literal strings or environment-variable references like `"$FIREWORKS_API_KEY"`.
|
||||
|
||||
Validate config with:
|
||||
If Cassady cannot resolve a usable provider, model, or API key, it offers to run setup before opening a chat. You can also run setup or provider login explicitly:
|
||||
|
||||
```sh
|
||||
cass login
|
||||
cass setup
|
||||
cass check
|
||||
cass update --check
|
||||
cass
|
||||
```
|
||||
|
||||
Extra global instructions can be placed in `~/.cass/global.md`.
|
||||
The setup wizard lets you choose one or more providers. OpenAI-compatible providers use an API key environment variable and can discover models from `GET /models` when the key is available. `ChatGPT Codex` uses your existing local Codex login (`~/.codex/auth.json`) and the Codex responses endpoint instead of a Cassady API-key environment variable.
|
||||
|
||||
Bundled documentation from this build is embedded into the binary and installed to `~/.cass/docs` on startup. See `~/.cass/docs/configuration.md` for full configuration docs.
|
||||
|
||||
## Usage
|
||||
Set your provider key in the shell where you run Cassady. For example, on macOS/Linux:
|
||||
|
||||
```sh
|
||||
cass [--model MODEL] [--base-url URL] [--api-key-env ENV] [--cwd PATH]
|
||||
export OPENAI_API_KEY=...
|
||||
```
|
||||
|
||||
In PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:OPENAI_API_KEY = "..."
|
||||
```
|
||||
|
||||
Run `cass check` any time to validate JSON config, provider/model references, active model resolution, and API key availability.
|
||||
|
||||
## Everyday usage
|
||||
|
||||
```sh
|
||||
cass [--model MODEL] [--cwd PATH]
|
||||
cass --resume <chat-id>
|
||||
cass --resume
|
||||
cass desktop
|
||||
cass check
|
||||
cass login
|
||||
cass logout
|
||||
cass setup
|
||||
cass update
|
||||
```
|
||||
|
||||
`cass --resume` without an ID lists chats for the current directory.
|
||||
`cass --resume` without an id lists saved chats for the current directory. `cass desktop` launches the desktop app in the background when the `cassady-desktop` binary is installed next to `cass` or on `PATH`; after it starts, you can close the terminal. `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.
|
||||
|
||||
## Keys
|
||||
Common in-chat commands:
|
||||
|
||||
- Type `/`: show command autocomplete, including command arguments like `/model <model>` and `/new`
|
||||
- `Up`/`Down`: move through an autocomplete menu
|
||||
- `Enter`: fill autocomplete selection when a menu is open; otherwise send message / run command
|
||||
- `Tab`: cycle reasoning effort (`off` → `low` → `medium` → `high`; required-reasoning models skip `off`)
|
||||
- `Ctrl-J`: insert newline
|
||||
- `Shift-Tab`: toggle read-only/full-access mode while idle
|
||||
- `Ctrl-O`: toggle compact/full tool output display
|
||||
- `Ctrl-Shift-R`: toggle reasoning display
|
||||
- `Up`/`Down` or mouse wheel: scroll transcript when no autocomplete menu is open
|
||||
- `PageUp`/`PageDown`: transcript scroll
|
||||
- `Ctrl-C` twice within 1.5 seconds: exit
|
||||
- `/branch` or `/restore`: open the branch/restore menu.
|
||||
- `/login`: configure or update provider login settings.
|
||||
- `/logout`: remove saved provider config and associated model entries.
|
||||
- `/fast`, `/fast on`, `/fast off`, `/fast status`: prefer faster inference when the active provider/model supports it. ChatGPT Codex models, including `gpt-5.5`, are treated as fast-capable.
|
||||
- `/model <model>`: switch to a model from `~/.cass/models.json`.
|
||||
- `/new`: create a new chat for the current directory.
|
||||
- `/resume <chat>`: resume a saved chat for the current directory.
|
||||
- `/status`: show chat id, model, fast-mode state, mode, cwd, record count, and current status.
|
||||
|
||||
## Commands
|
||||
Helpful keys:
|
||||
|
||||
- `cass check`: validate Cass config files
|
||||
- `/model <model>`: switch the model for future turns; model autocomplete lists entries from `~/.cass/models.json`
|
||||
- `/new`: create a new chat for the current directory
|
||||
- `/resume <chat>`: resume a saved chat; chat autocomplete lists chats for the current directory
|
||||
- `/status`: show current chat status
|
||||
- `/`: show command autocomplete.
|
||||
- `Enter`: send the message or accept an autocomplete item.
|
||||
- `Ctrl-J` or `Ctrl-Enter`: insert a newline.
|
||||
- `Shift-Tab`: cycle access mode while idle.
|
||||
- `Tab`: cycle reasoning effort while idle.
|
||||
- `Ctrl-O`: toggle compact/full tool output display.
|
||||
- `Ctrl-Shift-R` or `Ctrl-R`: toggle reasoning display.
|
||||
- `Esc`: request turn cancellation while a turn is running; while idle, press twice within 1.5 seconds to open branch/restore.
|
||||
- `Ctrl-C` twice within 1.5 seconds: exit.
|
||||
|
||||
On exit Cass prints:
|
||||
## Safety model
|
||||
|
||||
```text
|
||||
Resume this chat with: cass --resume <id>
|
||||
Cassady exposes tools according to the active access mode:
|
||||
|
||||
- `read-only`: read/list/search the workspace and bundled docs. No edits or shell commands.
|
||||
- `workspace-edit`: read/list/search plus write/edit inside the launch workspace. Shell commands require explicit approval.
|
||||
- `full-access`: read/write/edit broadly under your OS permissions and run shell commands without the workspace-edit approval prompt. Bundled docs remain read-only.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
Conversation-only branching is the safe default. If you choose file restore, Cassady restores only file changes it tracked from successful `write` and `edit` tools. Shell commands, manual edits, unsupported files, and hash conflicts are reported or skipped rather than overwritten blindly.
|
||||
|
||||
## Configuration and docs
|
||||
|
||||
Cassady stores user-editable files in `~/.cass`:
|
||||
|
||||
- `config.json`: active defaults and preferences.
|
||||
- `providers.json`: provider base URLs and API key references.
|
||||
- `models.json`: model metadata.
|
||||
- `global.md`: optional global instructions added to new chat system prompts when they fit the active request; they cannot override access modes, tool denials, approvals, or workspace boundaries.
|
||||
- `docs/`: bundled documentation installed from the current binary.
|
||||
|
||||
API key references should usually be written as environment variables such as `"$OPENAI_API_KEY"`. The `ChatGPT Codex` provider is different: it stores no key in `~/.cass` and reads the bearer token from local Codex auth at check/request time.
|
||||
|
||||
Detailed bundled docs live in this repository under [`docs/`](docs/README.md) and are installed to `~/.cass/docs` at runtime.
|
||||
|
||||
## Experimental Rust embedding API
|
||||
|
||||
Rust applications can import Cassady and run headless sessions without launching the TUI:
|
||||
|
||||
```rust
|
||||
use cassady::prelude::*;
|
||||
|
||||
let session = SessionBuilder::new()
|
||||
.cwd(".")
|
||||
.access_mode(AccessMode::ReadOnly)
|
||||
.build()
|
||||
.await?;
|
||||
```
|
||||
|
||||
## Tools
|
||||
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.
|
||||
|
||||
Tool calls are shown compactly by default; press `Ctrl-O` to expand full tool output.
|
||||
## More documentation
|
||||
|
||||
Reasoning is hidden by default unless `show_reasoning` is enabled; press `Ctrl-Shift-R` to toggle it. Press `Tab` to choose the reasoning effort for future turns. Model metadata controls whether reasoning is supported or required and how the effort is sent to the provider. When providers stream reasoning fields, Cass persists that reasoning and sends it back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`.
|
||||
|
||||
Read-only mode allows `ls`, `read`, and `grep` within the launch cwd/`--cwd` and the bundled docs directory at `~/.cass/docs`.
|
||||
|
||||
Full-access mode additionally allows `write`, `edit`, and `shell`. Mutating tools use atomic writes where practical: Cass writes to a temporary file first, then renames it into place after validation/write success. `write` and `edit` are always blocked under `~/.cass/docs`. The `shell` tool runs commands via `sh -c` in the launch working directory with a configurable timeout (default 30 seconds) and streams stdout/stderr into the transcript while the command is running.
|
||||
|
||||
The `shell` tool is available in full-access mode and runs shell commands in the launch working directory.
|
||||
- [Commands](docs/commands.md)
|
||||
- [Configuration](docs/configuration.md)
|
||||
- [Providers and models](docs/providers.md)
|
||||
- [Access modes and tool safety](docs/access-modes.md)
|
||||
- [Experimental Rust embedding API](docs/embedding.md)
|
||||
- [Workflows](docs/workflows.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
- [Platform notes](docs/platforms.md)
|
||||
- [Glossary](docs/glossary.md)
|
||||
|
||||
@@ -1,5 +1,496 @@
|
||||
# Cassady (Cass) Roadmap
|
||||
|
||||
## 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.
|
||||
|
||||
### Fast Mode Compatibility
|
||||
|
||||
- [x] **Treat ChatGPT Codex models as fast-capable at runtime.** Make `/fast` active for any active `chatgpt-codex` provider model even when legacy `models.json` metadata says `fast_mode.supported` is false.
|
||||
- Keep non-Codex providers governed by their model metadata.
|
||||
- Preserve the saved fast-mode preference behavior and status reporting.
|
||||
|
||||
- [x] **Document the Codex capability fallback.** Update README and bundled docs so users understand that ChatGPT Codex models, including `gpt-5.5`, can honor fast mode without refreshed metadata.
|
||||
- Keep docs clear that provider-specific fast-mode request shaping remains Codex-only.
|
||||
|
||||
- [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.
|
||||
|
||||
## v0.3.2 — Provider Fast Mode ✅ Completed
|
||||
|
||||
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`.
|
||||
|
||||
### Fast Mode Command
|
||||
|
||||
- [ ] **Add a persisted `/fast` preference.** Let users toggle fast mode from an idle chat and keep that preference across sessions.
|
||||
- Store the preference in `config.json` without disturbing provider/model configuration.
|
||||
- Keep the preference separate from whether the current provider/model can honor it.
|
||||
|
||||
- [ ] **Show capability-aware fast-mode status.** Display fast mode as enabled only when the active provider/model supports it.
|
||||
- If the user switches to an unsupported provider/model, hide the enabled state and report fast mode as unavailable when relevant.
|
||||
- If the user switches back to a supported provider/model, apply the existing preference again.
|
||||
|
||||
### Provider Support
|
||||
|
||||
- [ ] **Implement ChatGPT Codex fast-mode requests.** Add the provider-specific request option for Codex when fast mode is active.
|
||||
- Verify and test the exact Codex responses request shape during implementation.
|
||||
- Do not send Codex-specific fast-mode fields to OpenAI-compatible providers.
|
||||
|
||||
- [ ] **Add extensible provider/model capability metadata.** Model fast-mode support as provider/model metadata so future providers can opt in case by case.
|
||||
- Default unknown and custom providers to unsupported.
|
||||
- Mark built-in ChatGPT Codex model metadata as supported when Cassady can send the fast-mode request.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [ ] **Document fast mode behavior and limits.** Update README and bundled docs for `/fast`, `default_fast_mode`, model capability metadata, and Codex-only initial support.
|
||||
- Explain the difference between a saved fast-mode preference and active fast-mode support.
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
## 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.
|
||||
|
||||
### TUI Reliability
|
||||
|
||||
- [x] **Fix bottom-scroll row counting for whitespace-only lines.** Count indented blank transcript rows the same way Ratatui renders them so accumulated blank rows no longer hide recent transcript content above the footer.
|
||||
- Add regression coverage for whitespace-only wrapped row counting.
|
||||
|
||||
## v0.3.0 — ChatGPT Codex Provider ✅ Completed
|
||||
|
||||
This release focuses on letting users who are already signed in to Codex with a ChatGPT subscription use that account from Cassady. `ChatGPT Codex` becomes a provider preset that calls the Codex responses endpoint and reads its bearer token from local Codex auth instead of an API-key environment variable. See `plans/V0_3_0_CHATGPT_CODEX_PROVIDER_PLAN.md`.
|
||||
|
||||
### Provider Setup
|
||||
|
||||
- [x] **Add a ChatGPT Codex provider preset.** Make `ChatGPT Codex` available from `cass login`, `/login`, and first-run setup as a distinct provider option.
|
||||
- Use provider id `chatgpt-codex` and endpoint `https://chatgpt.com/backend-api/codex/responses`.
|
||||
- Skip the normal API-key environment-variable prompt for this preset.
|
||||
- Prefer the model configured in local Codex config when available, with manual model entry as a fallback.
|
||||
|
||||
- [x] **Read local Codex auth safely.** Resolve the bearer token from `$CODEX_HOME/auth.json` or `~/.codex/auth.json` at check/request time.
|
||||
- Support the local Codex `tokens.access_token` shape without copying the token into `~/.cass`.
|
||||
- Give clear recovery steps when the user has not run `codex login`, signed in to the Codex app, or has an expired/missing token.
|
||||
|
||||
### Provider Runtime
|
||||
|
||||
- [x] **Add a ChatGPT Codex responses client.** Route `chatgpt-codex` providers to the exact Codex responses endpoint instead of the OpenAI-compatible `/chat/completions` path.
|
||||
- Translate Cassady messages, tools, tool calls, and tool outputs to the endpoint's expected responses format.
|
||||
- Stream assistant text, safe reasoning summaries when available, and function-call deltas back into the existing agent loop.
|
||||
|
||||
- [x] **Keep OpenAI-compatible providers unchanged.** Refactor provider dispatch only as much as needed to support the new provider kind.
|
||||
- Existing provider config, setup, model discovery, API-key env vars, `/model`, and `cass check` behavior should continue to work.
|
||||
- Avoid leaking ChatGPT access tokens in errors, logs, transcripts, or config files.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [x] **Document ChatGPT Codex prerequisites and caveats.** Update README and bundled docs for setup, config examples, `cass check`, troubleshooting, and the distinction between ChatGPT subscription-backed access and API-key providers.
|
||||
- Make clear that Cassady uses an existing Codex login and does not implement its own browser login or token refresh flow in this release.
|
||||
- Note that the ChatGPT backend endpoint may change outside Cassady's control.
|
||||
|
||||
- [x] **Test Codex auth and provider behavior.** Cover Codex auth fixtures, config validation, setup catalog behavior, provider dispatch, streaming response parsing, tool calls, and secret redaction.
|
||||
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
|
||||
|
||||
## v0.2.9 — Provider Login Management ✅ Completed
|
||||
|
||||
This release focuses on making provider configuration available from both the shell and an active Cassady chat. Users can add or update OpenAI-compatible provider/model settings with `cass login` or `/login`, and remove saved providers and their associated models with `cass logout` or `/logout`. See `plans/V0_2_9_PROVIDER_LOGIN_MANAGEMENT_PLAN.md`.
|
||||
|
||||
### Login Commands
|
||||
|
||||
- [x] **Add login commands for provider setup.** Make `cass login` and `/login` open the provider setup flow so users can configure providers without remembering that setup is the underlying implementation.
|
||||
- Reuse the existing provider catalog, model discovery, model capability prompts, and safe JSON writes.
|
||||
- Keep `/login` idle-only and reload active provider/model config after the menu closes.
|
||||
|
||||
- [x] **Keep existing setup behavior intact.** Preserve `cass setup` and first-run setup while making login language feel natural for account/provider management.
|
||||
- Direct `cass login` should save configuration and exit rather than unexpectedly starting a chat.
|
||||
- Existing setup validation and missing API key guidance should continue to apply.
|
||||
|
||||
### Logout Commands
|
||||
|
||||
- [x] **Add a safe provider removal menu.** Make `cass logout` and `/logout` let users choose saved providers to remove from `providers.json`.
|
||||
- Remove associated `models.json` entries for selected providers.
|
||||
- Confirm the removal before writing changes.
|
||||
|
||||
- [x] **Repair active defaults after removal.** Ensure `config.json` never points at a provider/model that was just removed.
|
||||
- Select a valid remaining provider/model when possible.
|
||||
- Clear active provider/model defaults when no providers remain so the next startup offers login/setup.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [x] **Document provider login and logout workflows.** Update command, configuration, and workflow docs with the new shell and in-chat commands.
|
||||
- Clarify that logout removes Cassady provider config, not environment variables or external provider accounts.
|
||||
|
||||
- [x] **Test provider management behavior.** Cover provider/model removal, active default repair, local command parsing, and autocomplete.
|
||||
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
|
||||
|
||||
## v0.2.8 — Conversation Branch and Restore ✅ Completed
|
||||
|
||||
This release focuses on making conversation recovery safe and explorable. Pressing `Esc` twice while idle opens a branch/restore menu where users can browse prior user messages, assistant messages, and tool calls, create a new branch from a selected checkpoint, and optionally restore Cassady-tracked file edits without destroying the original conversation. See `plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md`.
|
||||
|
||||
### Branch Navigation
|
||||
|
||||
- [x] **Open a branch/restore menu with double Esc.** Add an idle `Esc`-twice shortcut that mirrors the discoverability of double `Ctrl-C` while preserving current busy-turn cancellation and approval-denial behavior.
|
||||
- Keep draft input intact when the menu is opened or cancelled.
|
||||
- Provide clear status text after the first `Esc` so users know a second press opens branch/restore.
|
||||
|
||||
- [x] **Browse checkpoints across the related branch family.** Show user messages, assistant messages, tool-call requests, and tool results from the current chat and related branches.
|
||||
- Include enough preview text, tool names, paths, timestamps, and branch labels to choose the right point.
|
||||
- Allow switching back to the original conversation or another existing branch from the same menu.
|
||||
|
||||
### Safe Conversation Branching
|
||||
|
||||
- [x] **Create branches instead of destructive reverts.** Selecting a checkpoint should write a new conversation JSONL with parent/checkpoint metadata, leaving the source conversation unchanged.
|
||||
- Support repeated branching so users can return to the menu later and branch or switch again.
|
||||
- Keep older conversations without branch metadata loadable.
|
||||
|
||||
- [x] **Handle tool-call checkpoints cleanly.** Branching at a specific tool call or tool result should preserve a valid provider message history.
|
||||
- Repair partial multi-tool assistant turns with synthetic cancelled/omitted tool results where needed.
|
||||
- Add tests for branching at user, assistant, and tool boundaries.
|
||||
|
||||
### File Edit Restoration
|
||||
|
||||
- [x] **Journal Cassady file edits with restorable snapshots.** Record successful `write` and `edit` tool mutations outside the model-visible transcript with before/after hashes and snapshots.
|
||||
- Keep restore support limited to Cassady-tracked file tools; warn that shell commands and manual edits are not automatically reversible.
|
||||
- Store enough data to restore both backward and forward between tracked checkpoints.
|
||||
|
||||
- [x] **Offer explicit conversation-only or conversation-plus-files restore actions.** Make conversation-only branching the safe default, and require confirmation before changing workspace files.
|
||||
- Preview files to update or delete, detect hash conflicts, and refuse unsafe overwrites by default.
|
||||
- Use atomic writes for restored files and preserve clear status/transcript messages for skipped or conflicted paths.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [x] **Document branch/restore workflows and limitations.** Update README and bundled docs with the double-`Esc` shortcut, menu controls, branch semantics, and file-restore safety model.
|
||||
- Include troubleshooting for restore conflicts and unsupported shell/manual filesystem changes.
|
||||
|
||||
- [x] **Test the branch and restore model.** Cover branch metadata, checkpoint extraction, tool-call repair, edit journaling, restore planning, and keybinding behavior where practical.
|
||||
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before release.
|
||||
|
||||
## v0.2.7 — Self-Update Command
|
||||
|
||||
This release focuses on making Cassady easy to keep current after installation. The goal is to let users run one clean command, `cass update`, to check GitHub releases, choose the recommended prebuilt binary or a source-build fallback, verify what will be installed, and update both `cass` and `cassady` safely. See `plans/V0_2_7_SELF_UPDATE_COMMAND_PLAN.md`.
|
||||
|
||||
### Update Command Experience
|
||||
|
||||
- [x] **Add a polished `cass update` command.** Check the official Cassady GitHub releases, compare the running version to the selected release, and guide the user through an interactive update flow.
|
||||
- Keep update independent of provider/model setup so it works even when config is missing or invalid.
|
||||
- Support script-friendly checks with flags such as `--check`, `--dry-run`, and `--yes`.
|
||||
|
||||
- [x] **Select the right update path.** Prefer a matching prebuilt release archive when available, with explicit `--prebuilt` and `--source` modes for users who want to choose.
|
||||
- Support the same macOS, Linux, and Windows targets used by Cassady releases.
|
||||
- Offer source builds for unsupported targets or users who prefer building locally.
|
||||
|
||||
### Safe Installation
|
||||
|
||||
- [x] **Verify and stage prebuilt artifacts before replacing binaries.** Download archives and SHA-256 files from the release, verify checksums, extract safely, and validate staged `cass`/`cassady` binaries.
|
||||
- Reject checksum mismatches, unsafe archive paths, missing binaries, and unexpected versions.
|
||||
- Show clear progress and failure messages without dumping raw implementation details.
|
||||
|
||||
- [x] **Replace installed binaries cleanly.** Update the current binary and same-directory companion binary when possible, using backups and rollback on failure.
|
||||
- Do not auto-run `sudo` or administrator prompts.
|
||||
- Handle Windows executable replacement with a staged helper or documented manual fallback if necessary.
|
||||
|
||||
### Source Build Fallback and Documentation
|
||||
|
||||
- [x] **Build from release source when requested.** Download the selected release source, validate its version, check for Rust tooling, run a locked release build, and install the resulting local binaries.
|
||||
- Keep source mode tied to release tags rather than arbitrary branches.
|
||||
- Do not attempt cross-compilation or Rust toolchain installation in this release.
|
||||
|
||||
- [x] **Document and test update behavior.** Update README and bundled docs for `cass update`, platform notes, troubleshooting, and package-manager caveats.
|
||||
- Add tests for release parsing, target mapping, asset selection, checksum validation, archive extraction safety, install planning, and mocked update flows.
|
||||
|
||||
## v0.2.6 — Rust Embedding API ✅ Completed
|
||||
|
||||
This release focuses on adding the first intentional Rust library surface for embedding Cassady in other Rust projects. The goal is to provide the bones for programmatic, headless agent sessions: configure a workspace, start or resume an agent session, send turns, stream typed events, and handle approvals without launching the TUI. See `plans/V0_2_6_RUST_EMBEDDING_API_PLAN.md`.
|
||||
|
||||
### Experimental Public API
|
||||
|
||||
- [x] **Add a supported embedding module.** Provide a small `cassady::embedding` API with builder, session, turn, event, approval, and error types so callers do not need to stitch together internal modules directly.
|
||||
- Mark the API experimental for v0.2.6 rather than promising long-term semver stability.
|
||||
- Keep existing CLI/TUI behavior unchanged while steering library users toward the new module.
|
||||
|
||||
- [x] **Support host-configured agent sessions.** Let Rust callers create or resume headless sessions with explicit cwd, access mode, model/provider overrides, reasoning effort, and Cassady config root.
|
||||
- Reuse existing config files, global instructions, bundled docs, security policy, and JSONL conversation storage.
|
||||
- Avoid requiring callers to construct CLI-specific types.
|
||||
|
||||
### Headless Turn Execution
|
||||
|
||||
- [x] **Run agent turns programmatically.** Add a Tokio-native API for sending one user message, streaming assistant/tool/status events, and returning the updated session or conversation state.
|
||||
- Prevent or clearly reject overlapping turns unless the type design makes them impossible.
|
||||
- Preserve provider streaming, tool execution, prompt generation, and context behavior from the existing agent loop.
|
||||
|
||||
- [x] **Expose approval handling to host applications.** Allow embedded callers to approve or deny tool approval requests, especially shell commands in `workspace-edit` mode.
|
||||
- Include request id, tool call id, tool name, arguments, and reason in approval events.
|
||||
- Document cancellation/drop behavior for active turns.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [x] **Add a minimal headless example.** Include a compilable Rust example that imports Cassady, starts a session, sends a prompt, and prints streamed assistant output.
|
||||
- Note that a configured OpenAI-compatible provider and API key are still required.
|
||||
- Show where to handle approval requests even if the first example defaults to `read-only`.
|
||||
|
||||
- [x] **Document the experimental Rust API.** Add bundled docs and README links for setup requirements, basic usage, event handling, approvals, limitations, and current non-goals.
|
||||
- Make clear that multi-agent orchestration, custom providers, custom tools, daemons, and stable plugin APIs are deferred.
|
||||
|
||||
- [x] **Test embedding without a terminal.** Add integration tests that use temporary config/conversation roots and mock provider responses to verify session creation, turn streaming, resume, approval flow, and access-mode behavior.
|
||||
- Ensure `cargo test --locked --all-targets` covers the new public API and examples.
|
||||
|
||||
## v0.2.4 — System Prompt Refinement
|
||||
|
||||
This release focuses on making Cassady's system prompt clearer, more intuitive, and more useful for everyday coding work without letting it become bulky. The target is a well-structured prompt around 1,000 tokens that gives the model enough product context, safety expectations, and workflow guidance to behave consistently across read-only, workspace-edit, and full-access sessions. See `plans/V0_2_4_SYSTEM_PROMPT_REFINEMENT_PLAN.md`.
|
||||
|
||||
### Prompt Structure and Content
|
||||
|
||||
- [x] **Restructure the prompt into clear sections.** Replace the current compact prompt with a polished, scannable structure that explains identity, operating principles, tool use, editing, safety, and response style in a predictable order.
|
||||
- Keep headings short and model-friendly so the prompt is easy to follow during long sessions.
|
||||
- Preserve the existing split between the reusable base prompt, user global instructions, and runtime constraints.
|
||||
- Avoid duplicating long documentation that already lives in README or bundled docs.
|
||||
|
||||
- [x] **Add enough product context for intuitive behavior.** Teach the model what Cassady is, how the terminal chat works, and what the user can see without over-explaining implementation details.
|
||||
- Explain that tool calls, tool results, diffs, approvals, and streamed assistant text are visible in the transcript.
|
||||
- Clarify that Cassady is a coding assistant for real project work, so it should inspect before changing files, make targeted edits, and summarize outcomes honestly.
|
||||
- Include guidance for asking focused follow-up questions only when necessary, instead of over-planning or guessing.
|
||||
|
||||
- [x] **Keep the prompt intentionally compact.** Aim for roughly 900-1,100 tokens for the normal effective system prompt, including runtime access-mode guidance but excluding user-provided global instructions.
|
||||
- Prefer dense, high-signal instructions over broad lists of examples.
|
||||
- Remove redundant wording when new guidance overlaps with existing safety or response rules.
|
||||
- Add a lightweight test or snapshot check so future prompt changes do not accidentally grow far beyond the intended size.
|
||||
|
||||
### Tool, Editing, and Safety Guidance
|
||||
|
||||
- [x] **Improve tool-use instructions.** Make the prompt explicit about when to read, grep, edit, write, and shell while still letting the model choose the right tool for the task.
|
||||
- Encourage targeted inspection before edits and `grep`/search before opening large or unknown files.
|
||||
- Explain that the model should request tools directly when useful; Cassady will enforce access policy, denials, and approval prompts at runtime.
|
||||
- Remind the model not to claim a tool succeeded until the tool result confirms it.
|
||||
|
||||
- [x] **Sharpen editing guidance.** Make file-change behavior safer and more reliable, especially for exact-text edits.
|
||||
- Prefer `edit` for focused modifications and `write` only for new files or intentional full rewrites.
|
||||
- Instruct the model to keep replacements minimal, unique, and non-overlapping.
|
||||
- Encourage running or suggesting relevant tests after meaningful code changes.
|
||||
|
||||
- [x] **Make access-mode behavior easy for the model to follow.** Rewrite read-only, workspace-edit, and full-access guidance in plain language that maps directly to available tools.
|
||||
- Keep workspace and bundled-doc boundaries clear.
|
||||
- State that shell approval is handled by Cassady's UI rather than by asking for permission in chat.
|
||||
- Preserve conservative behavior when a task requires permissions the current mode does not allow.
|
||||
|
||||
### Validation and Documentation
|
||||
|
||||
- [x] **Add prompt-focused tests.** Verify that the generated prompt includes the required sections, preserves global instructions, reflects the active access mode, and stays within the intended size range.
|
||||
- Cover read-only, workspace-edit, and full-access effective prompts.
|
||||
- Include a regression check for prompt ordering so runtime constraints remain near the end.
|
||||
|
||||
- [x] **Update user-facing references to global instructions.** Refresh docs only where needed to explain how `~/.cass/global.md` fits into the structured prompt.
|
||||
- Avoid exposing the full internal prompt in documentation.
|
||||
- Mention that user global instructions are respected unless they conflict with runtime safety constraints.
|
||||
|
||||
## v0.2.3 — Documentation and README Refresh ✅ Completed
|
||||
|
||||
This release focuses on making Cassady understandable, trustworthy, and easy to operate by rewriting the README and bringing all bundled documentation up to date with the current CLI behavior. The work should cover user-facing documentation only; broad CLI feature work and Windows-specific runtime improvements are deferred to the planned Windows CLI usability work. See `plans/V0_2_3_DOCUMENTATION_README_REFRESH_PLAN.md`.
|
||||
|
||||
### README Rewrite
|
||||
|
||||
- [x] **Rewrite the README around the current Cassady experience.** Replace stale or incomplete sections with a clear, accurate guide to what Cassady is, who it is for, and how to start using it.
|
||||
- Add a concise product summary, core capabilities, supported workflows, and current limitations.
|
||||
- Document both `cass` and `cassady` command names where relevant.
|
||||
- Keep examples aligned with the current setup wizard, active provider/model configuration, access modes, tools, and TUI behavior.
|
||||
- Remove outdated MVP language, obsolete commands, and instructions that no longer match the code.
|
||||
|
||||
- [x] **Add a complete first-use walkthrough.** Make the README guide a new user from launching the CLI to a successful first chat without requiring them to infer missing steps.
|
||||
- Cover first-run setup, `cass setup`, `cass check`, provider selection, API key environment variables, model discovery, and manual model entry fallback.
|
||||
- Include copy/paste-ready examples for common providers without exposing secrets or implying one provider is required.
|
||||
- Explain what happens when setup is incomplete and how the user should recover.
|
||||
|
||||
- [x] **Document everyday workflows.** Add practical examples for the CLI actions users are most likely to perform after setup.
|
||||
- Starting a chat in a project workspace.
|
||||
- Asking Cassady to inspect files, explain code, propose edits, and apply edits.
|
||||
- Reviewing tool calls, collapsed tool output, edit diffs, and assistant Markdown rendering.
|
||||
- Cancelling a turn, recovering after cancellation, and exiting cleanly.
|
||||
|
||||
### Reference Documentation
|
||||
|
||||
- [x] **Create or refresh the CLI command reference.** Document all supported commands, flags, aliases, and expected output modes in one place.
|
||||
- Include `cass`, `cassady`, `setup`, `check`, chat startup behavior, config overrides, access-mode flags, and any non-interactive/script-friendly commands.
|
||||
- Show when commands are interactive versus non-interactive.
|
||||
- Keep examples shell-neutral where possible, and label platform-specific syntax when needed.
|
||||
|
||||
- [x] **Rewrite the configuration reference.** Explain where config lives, how active provider/model selection works, and how users should safely edit or validate config.
|
||||
- Document provider entries, model entries, active defaults, API key environment variable names, custom OpenAI-compatible providers, and model IDs.
|
||||
- Explain precedence between config files, environment variables, command-line overrides, and setup wizard changes.
|
||||
- Include valid example config snippets and common invalid configurations with fixes.
|
||||
|
||||
- [x] **Document providers and model setup thoroughly.** Add a dedicated guide for built-in OpenAI-compatible providers and custom provider setup.
|
||||
- List supported built-in providers, base URLs, expected API key env vars, and any known model-discovery limitations.
|
||||
- Explain the difference between provider configuration, model selection, and API key availability.
|
||||
- Document manual model entry, provider health checks, and how `cass check` reports provider problems.
|
||||
- Explicitly note protocols or providers that are not supported yet to avoid user confusion.
|
||||
|
||||
- [x] **Document access modes and tool safety.** Make the security model understandable before users let Cassady inspect or edit a repository.
|
||||
- Explain `read-only`, `workspace-edit`, and `full-access` in user-facing terms.
|
||||
- Document what read, write, edit, shell, and bundled-doc access mean in each mode.
|
||||
- Explain shell approval prompts, optional destructive-operation confirmation, workspace boundaries, symlink handling, and edit diff review.
|
||||
- Add examples of denied operations and the exact kind of message a user should expect.
|
||||
|
||||
### Usage Guides and Troubleshooting
|
||||
|
||||
- [x] **Add troubleshooting for common failure modes.** Give users actionable fixes for the errors they are most likely to hit.
|
||||
- Missing API keys, invalid env vars, unreachable provider URLs, model discovery failures, unsupported model IDs, and rate/authentication errors.
|
||||
- Broken or incomplete config, unreadable config paths, invalid TOML/JSON if applicable, and permission problems.
|
||||
- Terminal rendering issues, non-interactive terminals, redirected output, shell command failures, and cancellation behavior.
|
||||
- File edit failures, failed exact-text replacements, binary/large files, line ending issues, and workspace access denials.
|
||||
|
||||
- [x] **Add task-oriented examples.** Include short, realistic examples that demonstrate how Cassady should be used on real projects.
|
||||
- Code explanation and navigation.
|
||||
- Safe file editing with diff review.
|
||||
- Running tests or build commands with shell approval.
|
||||
- Updating config or switching providers/models.
|
||||
- Resuming work after a failed provider request or cancelled turn.
|
||||
|
||||
- [x] **Document platform expectations without duplicating future Windows work.** Add accurate notes for macOS, Linux, and Windows users while keeping deep Windows CLI usability improvements scoped to the planned Windows CLI usability work.
|
||||
- Include path, shell, and environment-variable examples for each platform when documentation needs them.
|
||||
- Mark known Windows limitations clearly until the planned Windows CLI usability work lands.
|
||||
- Avoid promising installer, package manager, or auto-update behavior that is not implemented.
|
||||
|
||||
### Documentation Quality and Maintenance
|
||||
|
||||
- [x] **Improve prose quality and readability.** Rewrite docs in clear sentences and paragraphs instead of relying on long, list-heavy outlines.
|
||||
- Use bullets and tables only when they improve scanning, such as setup steps, command references, provider lists, or troubleshooting checklists.
|
||||
- Prefer short explanatory paragraphs for concepts, workflows, tradeoffs, and safety guidance.
|
||||
- Avoid turning every section into nested bullets; the README should feel like polished documentation, not an implementation checklist.
|
||||
|
||||
- [x] **Synchronize README, bundled docs, and CLI help text.** Ensure every user-facing description of commands, modes, providers, setup, and tools says the same thing.
|
||||
- Audit README, docs, inline help, setup prompts, `cass check` guidance, and release notes templates for contradictions.
|
||||
- Update terminology consistently: Cassady/Cass, provider, model, workspace, access mode, tool call, tool result, and session.
|
||||
- Make sure future docs can be updated from one source of truth where practical.
|
||||
|
||||
- [x] **Improve documentation structure and navigation.** Make the docs easy to scan and hard to misuse.
|
||||
- Add a table of contents or clear section links where the document is long.
|
||||
- Move long reference material out of the README when it distracts from first-use guidance, and link to it clearly.
|
||||
- Add a glossary for recurring concepts such as workspace, provider, model, access mode, context, and tool call.
|
||||
- Ensure headings, examples, and filenames follow a consistent style.
|
||||
|
||||
- [x] **Verify documentation against the actual CLI.** Treat docs as tested user experience, not prose written from memory.
|
||||
- Run the documented commands and update examples to match real output.
|
||||
- Check every internal link, file path, command name, provider URL, env var, and config snippet.
|
||||
- Add lightweight docs checks where practical, such as link validation or command-output smoke tests.
|
||||
- Complete the release only when README and bundled docs accurately reflect the shipped CLI.
|
||||
|
||||
## v0.2.2 — First-Run Onboarding and Setup Wizard ✅ Completed
|
||||
|
||||
This release focuses on making Cassady easy to start using from a fresh install. See `plans/V0_2_2_ONBOARDING_SETUP_WIZARD_PLAN.md`.
|
||||
|
||||
### Interactive Setup
|
||||
|
||||
- [x] **Add a first-run setup wizard.** When `cass` cannot resolve a usable active provider/model/API key, guide the user through setup instead of starting a chat that will fail.
|
||||
- Trigger automatically on first run or incomplete setup.
|
||||
- Add `cass setup` to run the wizard explicitly.
|
||||
- Keep `cass check` non-interactive.
|
||||
|
||||
- [x] **Support OpenAI-compatible provider selection.** Present a reusable keyboard menu with multi-select built-in providers and a custom provider option.
|
||||
- OpenAI: `https://api.openai.com/v1`
|
||||
- xAI: `https://api.x.ai/v1`
|
||||
- Fireworks: `https://api.fireworks.ai/inference/v1`
|
||||
- Groq: `https://api.groq.com/openai/v1`
|
||||
- OpenRouter: `https://openrouter.ai/api/v1`
|
||||
- OpenCode Zen: `https://opencode.ai/zen/v1`
|
||||
- OpenCode Go: `https://opencode.ai/zen/go/v1`
|
||||
- Cerebras: `https://api.cerebras.ai/v1`
|
||||
- Novita: `https://api.novita.ai/v3/openai`
|
||||
- Together: `https://api.together.xyz/v1`
|
||||
- Custom OpenAI-compatible provider.
|
||||
- Do not add Anthropic-native or non-OpenAI-compatible protocols in this release.
|
||||
|
||||
- [x] **Guide API key configuration.** Default to environment-variable based API keys, check whether the selected env var is set, and provide exact next steps when it is missing.
|
||||
|
||||
- [x] **Guide first-model selection.** Attempt OpenAI-compatible `GET /models` discovery when the API key is available, allow selecting a discovered model, and always provide manual model id entry as a fallback.
|
||||
|
||||
- [x] **Write valid config and start the first session.** Upsert provider/model entries, set active defaults, run setup validation, and automatically start a new chat only when the active API key is available.
|
||||
|
||||
### Setup Diagnostics and Docs
|
||||
|
||||
- [x] **Improve `cass check` onboarding output.** Show active provider, base URL, model, API key env var status, and actionable next steps such as `cass setup` or `export PROVIDER_API_KEY=...`.
|
||||
|
||||
- [x] **Refresh onboarding documentation.** Update README and bundled docs for `cass setup`, first-run behavior, OpenAI-compatible provider selection, API key env vars, model discovery/manual fallback, and current access modes including `workspace-edit`.
|
||||
|
||||
## v0.2.1 — Message Rendering Polish ✅ Completed
|
||||
|
||||
### Transcript Rendering
|
||||
@@ -52,3 +543,101 @@ This release focuses on making Cass easier to interrupt, easier to audit, and sa
|
||||
- [x] **Edit diff output.** Make `edit` changes reviewable in the transcript.
|
||||
- First version: show a unified before/after diff after the edit is applied.
|
||||
- Later versions may add pre-apply approval, but that requires a confirmation flow between tools and the TUI.
|
||||
|
||||
## Planned within the next major release
|
||||
|
||||
These sections describe work Cassady intends to complete before or as part of the next major release, but which has not yet been assigned to a specific version. Scope, order, and version numbers may change.
|
||||
|
||||
### Windows CLI Usability
|
||||
|
||||
This work focuses on making Cassady feel reliable and native when the CLI is run on Windows. It covers runtime usability after `cass` or `cassady` is already available on the machine; installers, package managers, PATH setup, code signing, and update delivery are intentionally out of scope.
|
||||
|
||||
#### Terminal Experience
|
||||
|
||||
- [ ] **Make interactive rendering robust in Windows terminals.** Ensure chat, setup, confirmation prompts, streamed output, spinners, diffs, and tool summaries render cleanly in Windows Terminal, PowerShell, Command Prompt, and common VS Code integrated terminals.
|
||||
- Enable or gracefully detect ANSI/VT support instead of emitting broken escape sequences.
|
||||
- Respect `NO_COLOR`, non-interactive output, redirected stdout/stderr, and narrow terminal widths.
|
||||
- Avoid relying on glyphs, emoji, box drawing, or cursor control sequences that render poorly on default Windows fonts.
|
||||
- Keep wrapping and cursor positioning correct for multi-line input, Markdown output, and long tool-call summaries.
|
||||
|
||||
- [ ] **Harden keyboard handling on Windows.** Make the TUI and prompts respond predictably to Windows console input events.
|
||||
- Verify `Enter`, `Backspace`, `Delete`, arrow keys, `Home`, `End`, `PageUp`, `PageDown`, `Tab`, and paste behavior.
|
||||
- Preserve existing `Ctrl-C` cancellation semantics and handle `Ctrl-Break`/console close events gracefully where supported.
|
||||
- Ensure `Esc` cancellation and prompt dismissal work consistently across PowerShell, Command Prompt, and Windows Terminal.
|
||||
|
||||
- [ ] **Improve plain CLI output for Windows users.** Commands such as `cass check`, setup diagnostics, validation errors, and usage text should remain readable without a fully interactive terminal.
|
||||
- Prefer actionable Windows examples using PowerShell syntax when the current platform is Windows.
|
||||
- Avoid POSIX-only command snippets in runtime guidance unless explicitly labeled.
|
||||
- Keep error messages copy/paste-friendly and free of terminal control characters when output is redirected.
|
||||
|
||||
#### Windows Paths and Files
|
||||
|
||||
- [ ] **Support Windows path syntax everywhere the CLI accepts paths.** Normalize and validate paths consistently across arguments, tool calls, diffs, session metadata, and model-visible file references.
|
||||
- Handle drive-letter paths such as `C:\Users\name\project`, rooted paths such as `\temp`, UNC paths such as `\\server\share\repo`, and mixed `/`/`\` separators.
|
||||
- Preserve user-facing paths in a readable Windows form while using canonicalized paths for safety decisions.
|
||||
- Avoid treating `:` in drive letters as URL schemes or command separators.
|
||||
- Add tests for relative path resolution from Windows workspaces and for paths containing spaces, apostrophes, parentheses, brackets, and non-ASCII characters.
|
||||
|
||||
- [ ] **Respect Windows filesystem semantics in workspace policy.** Keep read, write, edit, and shell safety checks correct on NTFS and common Windows filesystems.
|
||||
- Account for case-insensitive path comparisons, symlinks, junctions, directory symlinks, and network shares.
|
||||
- Prevent workspace escapes through `..`, junctions, symlink targets, alternate path spellings, and UNC aliases.
|
||||
- Handle reserved device names, trailing dots/spaces, invalid filename characters, and long-path edge cases with clear errors.
|
||||
- Preserve current access modes (`read-only`, `workspace-edit`, `full-access`) with Windows-specific authorization tests.
|
||||
|
||||
- [ ] **Handle line endings and encodings cleanly.** Make file reads, edits, diffs, and generated files predictable on Windows projects.
|
||||
- Preserve existing CRLF/LF style when editing files where practical.
|
||||
- Render diffs clearly even when files use CRLF line endings.
|
||||
- Avoid corrupting UTF-8 with BOM, UTF-16, or non-UTF-8 files; detect unsupported text encodings and explain the limitation.
|
||||
- Keep binary-file detection reliable for Windows executables, images, archives, and generated build artifacts.
|
||||
|
||||
#### Shell and Process Integration
|
||||
|
||||
- [ ] **Use the right shell behavior on Windows.** Make `shell` tool execution, approval prompts, command summaries, cancellation, and exit status reporting work with Windows process semantics.
|
||||
- Prefer PowerShell-friendly examples and diagnostics while still supporting `cmd.exe`-style commands when users provide them.
|
||||
- Quote paths with spaces safely and avoid POSIX-only escaping in Windows-generated commands.
|
||||
- Surface the actual executable, working directory, exit code, stdout, and stderr in a way users can debug.
|
||||
- Cancel long-running child processes cleanly, including process trees where possible.
|
||||
|
||||
- [ ] **Normalize environment-variable handling.** Ensure provider API key checks, diagnostics, setup guidance, and spawned tools work with Windows environment conventions.
|
||||
- Treat environment variable names consistently despite Windows case-insensitive lookup behavior.
|
||||
- Show PowerShell examples such as `$env:OPENAI_API_KEY = "..."` for temporary values.
|
||||
- Avoid relying on POSIX shell expansion, `export`, `$VAR`, or `~` in Windows-specific guidance.
|
||||
|
||||
- [ ] **Support common Windows external commands and editors.** When Cassady suggests or launches helper commands, make the behavior compatible with typical Windows environments.
|
||||
- Detect missing tools and explain alternatives rather than assuming Unix utilities are present.
|
||||
- Avoid hard dependencies on `sh`, `bash`, `grep`, `sed`, `cat`, `less`, or `/tmp` during normal CLI operation.
|
||||
- Respect configured editor/browser commands and quote file paths correctly when opening files or URLs.
|
||||
|
||||
#### Config, State, and Session Usability
|
||||
|
||||
- [ ] **Use Windows-appropriate runtime locations.** Keep config, logs, caches, sessions, temporary files, and diagnostics in locations that align with Windows conventions.
|
||||
- Prefer the existing cross-platform directory abstraction where available, and verify behavior with `APPDATA`, `LOCALAPPDATA`, `TEMP`, and `USERPROFILE`.
|
||||
- Expand `~` and environment-derived paths consistently in config values.
|
||||
- Keep session history portable enough to display Windows paths without breaking transcript replay.
|
||||
|
||||
- [ ] **Make diagnostics expose Windows-specific context.** Improve `cass check` and error reports so Windows users can understand terminal, filesystem, shell, and config problems quickly.
|
||||
- Include OS, architecture, terminal detection, active shell, config path, workspace path, and access mode when relevant.
|
||||
- Clearly distinguish provider/API-key failures from Windows runtime issues.
|
||||
- Recommend Windows-native remediation steps without mentioning installation tasks.
|
||||
|
||||
- [ ] **Keep aliases and command parsing consistent.** Ensure `cass` and `cassady` subcommands, flags, config overrides, and path arguments behave the same on Windows as on Unix-like systems.
|
||||
- Validate quoting behavior for arguments containing spaces and backslashes.
|
||||
- Ensure help text and examples do not imply shell features unavailable in PowerShell or Command Prompt.
|
||||
- Keep machine-readable output stable across platforms when output is consumed by scripts.
|
||||
|
||||
#### Verification and Documentation
|
||||
|
||||
- [ ] **Add Windows-focused automated coverage.** Add unit and integration tests that exercise Windows path parsing, policy checks, config discovery, line endings, environment variables, and command rendering.
|
||||
- Use platform-gated tests for behavior that can only run on Windows.
|
||||
- Add platform-independent tests for Windows path strings where possible.
|
||||
- Include regression tests for spaces in paths, UNC paths, CRLF edits, and workspace escape attempts.
|
||||
|
||||
- [ ] **Run a manual Windows CLI acceptance pass.** Validate the release on a real Windows environment, not just cross-compilation.
|
||||
- Test PowerShell, Command Prompt, Windows Terminal, and VS Code integrated terminal.
|
||||
- Exercise interactive chat, first-run setup, `cass check`, tool approvals, file read/edit/diff, shell cancellation, and redirected output.
|
||||
- Record any unsupported terminal or shell behavior as explicit known limitations.
|
||||
|
||||
- [ ] **Update runtime documentation for Windows usage.** Refresh README and bundled docs with Windows-specific CLI usage guidance while avoiding installation instructions.
|
||||
- Document PowerShell environment-variable examples, path examples, terminal expectations, and known limitations.
|
||||
- Include troubleshooting for broken colors, bad wrapping, path authorization failures, CRLF diffs, and missing Unix helper commands.
|
||||
- Keep all Windows guidance consistent with existing access modes and safety policies.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the latest released version of Cassady. If you are using an older version, please upgrade to the latest release before reporting an issue, unless the issue also affects the latest release.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| Latest | ✅ |
|
||||
| Older releases | ❌ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do **not** report security vulnerabilities in public GitHub issues, discussions, or pull requests.
|
||||
|
||||
To report a vulnerability, use GitHub's private vulnerability reporting for this repository:
|
||||
|
||||
1. Open the repository on GitHub.
|
||||
2. Go to **Security** → **Report a vulnerability**.
|
||||
3. Include as much detail as you can about the issue, impact, affected versions, and steps to reproduce.
|
||||
|
||||
## What to Include
|
||||
|
||||
Helpful reports include:
|
||||
|
||||
- A description of the vulnerability and likely impact.
|
||||
- Steps to reproduce or a minimal proof of concept.
|
||||
- The Cassady version, operating system, shell, and terminal environment.
|
||||
- Relevant configuration details with secrets removed.
|
||||
- Any known mitigations or workarounds.
|
||||
|
||||
Do not include live API keys, tokens, private prompts, or sensitive project files in a report. Redact secrets before sharing logs or configuration.
|
||||
|
||||
## Response Expectations
|
||||
|
||||
After a report is received, the maintainer will aim to:
|
||||
|
||||
- Acknowledge the report within 7 days.
|
||||
- Confirm whether the issue is in scope and reproducible.
|
||||
- Provide status updates when there is meaningful progress.
|
||||
- Coordinate disclosure timing before publishing details publicly.
|
||||
|
||||
Security fixes may be released as patch versions when appropriate. Public disclosure should wait until a fix or mitigation is available, unless otherwise coordinated with the maintainer.
|
||||
|
||||
## Scope
|
||||
|
||||
Examples of in-scope issues include vulnerabilities in Cassady that could:
|
||||
|
||||
- Bypass documented access modes, approval prompts, or workspace boundaries.
|
||||
- Cause unintended file reads, writes, edits, or shell command execution.
|
||||
- Leak API keys, provider credentials, chat history, or local configuration.
|
||||
- Corrupt or expose session data stored under `~/.cass`.
|
||||
- Introduce unsafe behavior in bundled release artifacts.
|
||||
|
||||
Out-of-scope issues generally include:
|
||||
|
||||
- Vulnerabilities in third-party model providers or APIs not controlled by this project.
|
||||
- Prompt-injection behavior that does not bypass Cassady's documented safety controls.
|
||||
- Issues that require a compromised local machine, shell, dependency cache, or provider account.
|
||||
- Reports against unsupported older versions that are fixed in the latest release.
|
||||
|
||||
## Safe Harbor
|
||||
|
||||
Good-faith security research is welcome. Please avoid privacy violations, data destruction, service disruption, and accessing data that does not belong to you. If you follow this policy and make a good-faith effort to avoid harm, the maintainer will not pursue legal action for your research.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
`cargo tauri build` runs the frontend build and embeds the generated assets into the standalone binary. A plain `cargo build -p cassady-desktop` is useful for Rust checks, but do not package that output because it may run in Tauri dev-asset mode and open to a blank window when copied elsewhere.
|
||||
|
||||
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.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "cassady-desktop-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "cassady-desktop"
|
||||
version = "0.4.0"
|
||||
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"] }
|
||||
@@ -0,0 +1,15 @@
|
||||
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");
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default"]}}
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 905 B |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 565 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 868 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,28 @@
|
||||
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,
|
||||
turn::start_turn,
|
||||
turn::approve,
|
||||
turn::deny,
|
||||
turn::cancel_turn,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running cassady desktop");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
cassady_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use crate::state::DesktopState;
|
||||
use crate::types::{
|
||||
ChatSummaryDto, ConversationInfoDto, ListChatsArgs, ModelOptionDto, NewSessionArgs,
|
||||
ResumeSessionArgs, SessionIdArgs, UpdateSessionSettingsArgs,
|
||||
};
|
||||
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 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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use cassady::access::AccessMode;
|
||||
use cassady::config::{ModelDefinition, ReasoningEffort};
|
||||
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,
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cassady",
|
||||
"version": "0.4.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
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,185 @@
|
||||
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 { 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, ReasoningEffort } from "@/lib/tauri";
|
||||
import { listModels, 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 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 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}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModelSelector } from "@/components/ModelSelector";
|
||||
import { ArrowUp, Square } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AccessMode, ModelOption, ReasoningEffort } from "@/lib/tauri";
|
||||
|
||||
export function Composer({
|
||||
disabled,
|
||||
running,
|
||||
onSend,
|
||||
onCancel,
|
||||
accessMode,
|
||||
reasoningEffort,
|
||||
model,
|
||||
modelOptions,
|
||||
onCycleAccessMode,
|
||||
onCycleReasoning,
|
||||
onSelectModel,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
running?: boolean;
|
||||
onSend: (message: string) => void;
|
||||
onCancel: () => void;
|
||||
accessMode: AccessMode;
|
||||
reasoningEffort: ReasoningEffort;
|
||||
model: string;
|
||||
modelOptions: ModelOption[];
|
||||
onCycleAccessMode: () => void;
|
||||
onCycleReasoning: () => void;
|
||||
onSelectModel: (model: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
||||
}, [value]);
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || running) return;
|
||||
onSend(trimmed);
|
||||
setValue("");
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !(e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else 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 className="flex items-end gap-2">
|
||||
<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…"
|
||||
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,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,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>
|
||||
);
|
||||
}
|
||||
@@ -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,53 @@
|
||||
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} />;
|
||||
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,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function StatusBlock({
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("py-1 font-mono text-xs text-[var(--color-amber)]", className)}>
|
||||
· {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>
|
||||
);
|
||||
}
|
||||