Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions .github/workflows/release-macos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
name: macOS release

on:
push:
branches: [release/macos]
tags: ['v*']
workflow_dispatch:

permissions:
contents: read

concurrency:
group: macos-release-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
name: Signed macOS (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
target: aarch64-apple-darwin
arch: arm64
- runner: macos-15-intel
target: x86_64-apple-darwin
arch: x86_64
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: '1.4.2'
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
toolchain: '1.99.0'
targets: ${{ matrix.target }}
- name: Validate release version
run: python3 scripts/release/check-version.py
- name: Install frontend dependencies
run: bun install --frozen-lockfile
- name: Test backend
run: cargo test --locked --manifest-path src-tauri/Cargo.toml --target '${{ matrix.target }}'
- name: Build app
run: bun tauri build --target '${{ matrix.target }}' --bundles app -- --locked
- name: Prepare signing credentials
env:
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
run: bash scripts/release/keychain.sh setup
- name: Sign, notarize and package
run: bash scripts/release/package-macos.sh '${{ matrix.target }}' '${{ matrix.arch }}'
- name: Test distributed app
run: python3 scripts/release/verify-artifacts.py release-artifacts '${{ matrix.arch }}'
- name: Upload signed downloads
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: macos-${{ matrix.arch }}
path: release-artifacts/*
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Clean up signing credentials
if: always()
run: bash scripts/release/keychain.sh cleanup

publish:
name: Publish macOS downloads
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
with:
pattern: macos-*
merge-multiple: true
path: release-artifacts
- name: Verify downloads and publish release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
cd release-artifacts
sha256sum --check ./*.sha256
if gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft > /tmp/release-draft; then
[[ "$(cat /tmp/release-draft)" == true ]] || { echo 'Refusing to overwrite a published release'; exit 1; }
else
gh release create "$RELEASE_TAG" --verify-tag --draft --title "Termy Code $RELEASE_TAG" --generate-notes
fi
gh release upload "$RELEASE_TAG" ./*.dmg ./*.zip ./*.sha256 --clobber
if [[ "$RELEASE_TAG" == *-* ]]; then
gh release edit "$RELEASE_TAG" --draft=false --prerelease
else
gh release edit "$RELEASE_TAG" --draft=false --latest
fi
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ src-tauri/gen/schemas
.env*
*.pem
*.key
*.p12
*.p8
release-artifacts/
__pycache__/
.vscode
16 changes: 9 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,26 @@ bunx tsc --noEmit # typecheck only
cargo check --manifest-path src-tauri/Cargo.toml
```

There is no test suite or linter configured. `src/routeTree.gen.ts` is generated by the TanStack Router Vite plugin; don't edit it by hand.
There is no frontend test suite or linter. Rust has unit tests (`cargo test --manifest-path src-tauri/Cargo.toml`), including daemon tests that drive a fake ACP agent through `python3`. `src/routeTree.gen.ts` is generated by the TanStack Router Vite plugin; don't edit it by hand.

`termy_core` comes from the `termy-code/raw-pty` branch of termy, checked out as a sibling of this repo at `../termy-raw-pty` and linked by path, which adds `termy_core::pty` (raw PTY bytes for xterm.js, exit status, kill, per-command environment). Rust builds fail without it.
`alacritty_terminal` provides the native PTY transport through `src-tauri/src/pty.rs`: raw output for xterm.js, per-command environment, bounded input, resize, process termination and exit status. Cargo fetches it from crates.io; no sibling repository is required. Interactive shells use the user’s login shell; terminal preferences live in the app settings.

## Architecture

- `src-tauri/`: Rust. Spawns agents and pipes their ndjson stdio to the webview (`agent.rs`), runs shells on Termy's PTY for the terminal panel (`terminal.rs`), runs ACP `terminal/*` commands for agents on Termy PTYs with sanitized text output (`agent_terminal.rs`), stores and searches projects and threads in SQLite (`db.rs`), serves ACP `fs/*` requests and image attachments (`workspace.rs`), lists project files and agent skills for `@` and `$` mentions (`mentions.rs`), and resolves the login-shell `PATH` so GUI-launched agents can find `npx`/`agent` (`shell_env.rs`). Every command is registered in `lib.rs`.
- `src/services/`: Effect services. `Workspace` owns all app state (a `SubscriptionRef`) and agent session lifecycles; `AcpClient` wraps the ACP SDK; `AgentHost` turns Tauri events into an ACP stream; `Database` and `TerminalHost` front the Rust commands; `Tauri` is the single Effect boundary over `invoke`/`listen`/dialogs.
- `src-tauri/`: Rust. Agents and shells run in a background daemon (`daemon/`): the same binary started with `--daemon`, which outlives the app like a terminal multiplexer. It proxies each agent's ACP stdio (renumbering the window's request ids, answering `fs/*` and `terminal/*` itself, logging notifications to disk, holding permission prompts until a window attaches) in `daemon/agents.rs`, and keeps shells with scrollback on Alacritty's PTY in `daemon/shells.rs`. The app connects over a Unix socket (`daemon_client.rs`, paths in `paths.rs`) and exposes `daemon_request` plus `daemon://event` to the webview. The rest runs in the app: ACP `terminal/*` commands with sanitized text output (`agent_terminal.rs`, used by the daemon), SQLite projects and threads (`db.rs`), file reads and image attachments (`workspace.rs`), `@`/`$` mention lists (`mentions.rs`), and the login-shell `PATH` (`shell_env.rs`). Every command is registered in `lib.rs`.
- `src/services/`: Effect services. `Workspace` owns all app state (a `SubscriptionRef`) and agent session lifecycles; `AcpClient` wraps the ACP SDK; `Daemon` fronts the background daemon; `AgentHost` turns a daemon agent into an ACP stream (spawn, or attach with log replay); `TerminalHost` opens or reattaches daemon shells by key; `Database` fronts the SQLite commands; `Tauri` is the single Effect boundary over `invoke`/`listen`/dialogs.
- `src/domain/`: pure types and reducers (session timeline, tool stacks, diffs, Cursor's `cursor/*` ACP extensions). Agent launch specs live in `domain/agents.ts`.
- `src/routes/`: TanStack Router file routes. UI primitives come from shadcn on Base UI (`src/components/ui`); the Notra registry provides shimmer and the step slider.

### How an agent session flows

1. `agent_spawn` (Rust) starts the process and emits `agent://stdout|stderr|exit` events tagged with a numeric id. `AgentHost` routes those by id into a `ReadableStream`/`WritableStream` pair (non-JSON stdout lines are dropped) and kills the process when its `Scope` closes.
2. `AcpClient.connect` builds an ACP `ClientSideConnection` over that stream. The client side of ACP (`fs/*`, `terminal/*`) is served by Tauri commands; permission prompts and `cursor/*` extensions are delegated to handlers supplied by `Workspace`.
1. The daemon's `agent.spawn` starts the process, keyed by thread id, and `agent.attach` streams its messages as `agent.entry` (numbered, logged notifications) and `agent.message` (requests and responses) events. `AgentHost` turns those into a `ReadableStream`/`WritableStream` pair. Closing its `Scope` only detaches; `AgentHost.kill` stops the agent (closing a tab or deleting a thread).
2. `AcpClient.connect` builds an ACP `ClientSideConnection` over that stream, skipping `initialize` when attaching to an agent that already did it. The client side of ACP (`fs/*`, `terminal/*`) is answered by the daemon and never reaches the webview; permission prompts and `cursor/*` extensions are delegated to handlers supplied by `Workspace`.
3. `Workspace.ensureConnected` keeps one `Live` handle per thread (a `Scope`, a `ready` Deferred shared by concurrent callers, the pending user request). `establish` resumes, loads (replaying history, ignored while `replaying`), natively forks, or creates a new ACP session, in that order of preference.
4. Session updates go through `applyUpdate` in `domain/session.ts` into the thread's `Session` inside the `SubscriptionRef`. Requests that need user input (`Permission`, `Question`, `PlanApproval`) are set as `session.request` and block on a Deferred until the UI answers.
5. Threads are persisted once they contain a user message: the whole `items` timeline is stored as a JSON column in SQLite (search is a `LIKE` over title and items). Open tabs and each agent's last mode are kept in `localStorage`.
5. Threads are persisted once they contain a user message: the whole `items` timeline is stored as a JSON column in SQLite (search is a `LIKE` over title and items), with `log_seq`, the daemon log entry it's saved up to (acked so the daemon can trim). A turn ends on the daemon's `_termy/turn_end` notification, not the `session/prompt` response, so turns that finish with no window open still end. On launch, `Workspace` reattaches threads whose agents are still running and replays their log after `log_seq`. Open tabs, panel shells, the last page and each agent's last mode are kept in `localStorage`.

The daemon is replaced automatically when it's idle and from another build; with work running it's kept (Settings shows it as stale and can restart it). `TERMY_DATA_DIR` points both processes at another data folder, e.g. for tests.

### React ↔ Effect

Expand Down
33 changes: 16 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Termy Code is a desktop app for working with AI coding agents. Instead of running Claude Code, Codex and Cursor each in its own terminal tab, you open a project once and talk to any of them from the same window. Each conversation is saved as a thread you can search and return to later.

When an agent edits files, the changes show up as diffs you can review before moving on. When it runs commands, they run in a real terminal built on [Termy](https://github.com/lassejlv/termy)'s PTY, and you can see the output as it happens. A terminal panel is also there for your own commands.
When an agent edits files, the changes show up as diffs you can review before moving on. When it runs commands, they run in a real terminal built on [Alacritty](https://github.com/alacritty/alacritty)'s PTY, and you can see the output as it happens. A terminal panel is also there for your own commands.

Termy Code talks to the agents over the [Agent Client Protocol](https://agentclientprotocol.com) (ACP), the open protocol these CLIs use to work with editors. The agents keep their own models, tools and logins. Termy Code gives them a better interface.

Expand All @@ -11,7 +11,7 @@ Termy Code talks to the agents over the [Agent Client Protocol](https://agentcli
- **Multiple agents.** Claude Code, Codex and Cursor connect over ACP.
- **Projects and threads.** Conversations are saved per project in a local SQLite database, and you can search them.
- **Tool calls in the timeline.** Agent tool calls are grouped into stacks. You can review file diffs in a side pane.
- **Built-in terminal.** The terminal panel uses xterm.js on top of Termy's raw PTY. Commands that agents run through ACP `terminal/*` use the same PTY layer.
- **Built-in terminal.** The terminal panel uses xterm.js on top of Alacritty's native PTY. Commands that agents run through ACP `terminal/*` use the same PTY layer.
- **Permission prompts.** Agent requests for permission or input appear inline.
- **Composer.** You can attach images, switch models and set reasoning effort. Type `@` to mention a file or `$` to use one of the agent's skills.
- **Command palette.** Jump between projects and threads from the keyboard.
Expand Down Expand Up @@ -40,37 +40,36 @@ macOS starts GUI apps with a minimal `PATH`. Termy Code reads `PATH` from your l

### Setup

`termy_core` comes from the `termy-code/raw-pty` branch of Termy and is linked by path. Check that branch out as a sibling of this repo:
The Rust terminal backend uses `alacritty_terminal` from crates.io. No sibling checkout is needed.

```sh
git clone https://github.com/termylabs/code termycode
git clone -b termy-code/raw-pty https://github.com/lassejlv/termy termy-raw-pty
cd termycode
bun install
```

You should end up with this layout:

```
Dev/
├── termycode/ # this repo
└── termy-raw-pty/ # termy @ termy-code/raw-pty
```

### Run

```sh
bun tauri dev # run the app with hot reload
bun tauri build # build a release bundle
```

### Signed macOS releases

The [macOS release workflow](.github/workflows/release-macos.yml) builds signed,
Apple-notarized DMG and ZIP downloads for Apple Silicon and Intel. Manual runs
produce test artifacts; pushing a matching `v*` version tag publishes a release
after both architectures pass. See [release setup and verification](docs/macos-releases.md).

## Architecture

```
src-tauri/ Rust backend (Tauri v2)
├── agent.rs spawns agents and pipes ndjson stdio to the webview
├── agent_terminal.rs ACP terminal/* commands on Termy PTYs with sanitized output
├── terminal.rs interactive shells for the terminal panel
├── daemon/ persistent agents and interactive shells
├── daemon_client.rs connects the app to the background daemon
├── pty.rs raw Alacritty PTY transport and process lifecycle
├── agent_terminal.rs ACP terminal/* commands with sanitized output
├── workspace.rs ACP fs/* requests, git branch and image attachments
├── mentions.rs project files and agent skills for @ and $ mentions
├── db.rs SQLite storage and search for projects and threads
Expand All @@ -80,9 +79,9 @@ src/ React frontend
├── services/ Effect services
│ ├── Workspace.ts app state (SubscriptionRef) and agent session lifecycles
│ ├── AcpClient.ts wraps the ACP SDK
│ ├── AgentHost.ts turns Tauri events into an ACP stream
│ ├── AgentHost.ts turns daemon events into an ACP stream
│ ├── Database.ts fronts the db_* commands
│ └── TerminalHost.ts fronts the term_* commands
│ └── TerminalHost.ts opens and reattaches daemon shells
├── domain/ pure types and reducers (timeline, tool stacks, diffs, cursor/* extensions)
├── routes/ TanStack Router file routes
└── components/ app UI, shadcn on Base UI, Notra registry components
Expand Down
Loading
Loading