diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..7116669 --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -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 diff --git a/.gitignore b/.gitignore index cbc451b..dc0d95e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,8 @@ src-tauri/gen/schemas .env* *.pem *.key +*.p12 +*.p8 +release-artifacts/ +__pycache__/ .vscode diff --git a/CLAUDE.md b/CLAUDE.md index 7c132f7..bbdf318 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 0044e9d..0fc282c 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -40,23 +40,14 @@ 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 @@ -64,13 +55,21 @@ 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 @@ -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 diff --git a/bun.lock b/bun.lock index 28d6c4c..40a38f7 100644 --- a/bun.lock +++ b/bun.lock @@ -9,20 +9,23 @@ "@base-ui/react": "^1.8.0", "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", + "@heroicons/react": "^2.2.0", "@pierre/diffs": "^1.5.1", "@tanstack/react-router": "^1.170.41", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.8.1", "@tauri-apps/plugin-opener": "^2", + "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cn": "^0.4.0", "diff": "^9.0.0", "effect": "^4.0.0", - "lucide-react": "^1.52.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -127,6 +130,8 @@ "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="], + "@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="], + "@hono/node-server": ["@hono/node-server@2.1.3", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.6.0", "@jridgewell/trace-mapping": "0.3.31" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -317,10 +322,16 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], + "@xterm/addon-clipboard": ["@xterm/addon-clipboard@0.2.0", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="], + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + "@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="], + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.2", "negotiator": "1.1.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -631,6 +642,8 @@ "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + "js-base64": ["js-base64@3.9.4", "", {}, "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -685,8 +698,6 @@ "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], - "lucide-react": ["lucide-react@1.52.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.6.0" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], diff --git a/docs/macos-releases.md b/docs/macos-releases.md new file mode 100644 index 0000000..1737258 --- /dev/null +++ b/docs/macos-releases.md @@ -0,0 +1,68 @@ +# macOS releases + +The `macOS release` workflow builds separate Apple Silicon (`arm64`) and Intel +(`x86_64`) downloads on native macOS runners. It uses Developer ID Application +signing, hardened runtime, secure timestamps and Apple notarization. Each release +contains a DMG, a portable ZIP of the stapled app, and SHA-256 checksums per CPU. +No Windows or Linux releases are built. + +## Credentials + +Configure the `APPLE_TEAM_ID` repository variable and these Actions secrets: + +- `APPLE_CERTIFICATE_P12_BASE64`: base64-encoded Developer ID Application P12, including its private key. +- `APPLE_CERTIFICATE_PASSWORD`: P12 password. +- `APPLE_NOTARY_KEY_P8_BASE64`: base64-encoded App Store Connect Team API private key. +- `APPLE_NOTARY_KEY_ID`: Team API key ID. +- `APPLE_NOTARY_ISSUER_ID`: Team API issuer ID. + +These credentials are independent of the app name. Signing fails if credentials +are missing or the imported identity does not match the team. Credentials are +imported only after compilation into a temporary keychain; the original keychain +search list is restored and temporary files are removed in an `always()` step. +Never commit credential files. No signing workflow runs on pull requests. + +## Test a signed build + +Use **Actions → macOS release → Run workflow** after the workflow is on the +default branch. Pushes to the dedicated `release/macos` branch also build signed +artifacts, which allows initial verification before merging. These runs upload +downloads to Actions and never create a GitHub Release. + +Each native runner tests the Rust backend, builds the frontend and app, signs and +notarizes the app, staples it, creates/signs/notarizes/staples the DMG, and finally +creates the ZIP. It then verifies checksums, signatures, signing team, architecture, +hardened runtime, timestamps, notarization tickets and Gatekeeper for both formats. +The downloaded ZIP app and mounted DMG app both run a daemon smoke test exercising +terminal input/output, resize, disconnect/reconnect, scrollback replay and shutdown. + +To verify downloaded Actions artifacts locally: + +```sh +APPLE_TEAM_ID=YOUR_TEAM_ID python3 scripts/release/verify-artifacts.py /path/to/downloads arm64 +APPLE_TEAM_ID=YOUR_TEAM_ID python3 scripts/release/verify-artifacts.py /path/to/downloads x86_64 +``` + +Runtime smoke tests run only for the host's native architecture; signatures and +notarization can be verified for either architecture on the same Mac. The CI jobs +exercise each architecture on its matching runner. Interactive GUI behavior still +needs a manual check before a release. + +## Publish + +1. Set the same version in `package.json`, `src-tauri/Cargo.toml` and + `src-tauri/tauri.conf.json`, and update the Cargo lockfile if needed. +2. Commit the version change and push a tag such as `v0.1.0` at the intended commit. +3. Both architecture jobs must pass before the publish job runs. The tag must + exactly match the app version. + +The publish job creates a draft, attaches all six verified files, then makes the +release public. Tags with a prerelease suffix are marked as prereleases. Existing +public releases are never overwritten; failed draft uploads can be rerun. + +Notarization timeouts retain the submission ID. Inspect that submission with +`notarytool info` or `notarytool log` before retrying an upload. + +References: [Apple notarization](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution), +[GitHub macOS runners](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), +[Tauri macOS distribution](https://v2.tauri.app/distribute/macos-application-bundle/). diff --git a/package.json b/package.json index b8fcf64..e0f9de9 100644 --- a/package.json +++ b/package.json @@ -14,20 +14,23 @@ "@base-ui/react": "^1.8.0", "@fontsource-variable/geist": "^5.3.0", "@fontsource-variable/geist-mono": "^5.3.0", + "@heroicons/react": "^2.2.0", "@pierre/diffs": "^1.5.1", "@tanstack/react-router": "^1.170.41", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.8.1", "@tauri-apps/plugin-opener": "^2", + "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cn": "^0.4.0", "diff": "^9.0.0", "effect": "^4.0.0", - "lucide-react": "^1.52.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/scripts/release/check-version.py b/scripts/release/check-version.py new file mode 100644 index 0000000..491b6ac --- /dev/null +++ b/scripts/release/check-version.py @@ -0,0 +1,17 @@ +"""Reject mismatched versions before building or publishing a release.""" +import json +import os +import re +from pathlib import Path + +package = json.loads(Path('package.json').read_text())['version'] +tauri = json.loads(Path('src-tauri/tauri.conf.json').read_text())['version'] +cargo = re.search(r'^version = "([^"]+)"', Path('src-tauri/Cargo.toml').read_text(), re.M)[1] +assert package == tauri == cargo, f'Versions differ: package={package}, tauri={tauri}, cargo={cargo}' +assert re.fullmatch(r'\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?', tauri), 'Invalid release version' +if os.environ.get('GITHUB_REF', '').startswith('refs/tags/'): + assert os.environ['GITHUB_REF_NAME'] == f'v{tauri}', 'Tag must match the app version' +if 'GITHUB_ENV' in os.environ: + with open(os.environ['GITHUB_ENV'], 'a') as env: + env.write(f'APP_VERSION={tauri}\n') +print(f'App version: {tauri}') diff --git a/scripts/release/keychain.sh b/scripts/release/keychain.sh new file mode 100644 index 0000000..0a9b955 --- /dev/null +++ b/scripts/release/keychain.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "${1:-}" in + cleanup) + [[ -n "${APPLE_SIGNING_DIR:-}" ]] || exit 0 + [[ "$APPLE_SIGNING_DIR" == "${RUNNER_TEMP:?}/apple-signing."* ]] || exit 1 + if [[ -f "$APPLE_SIGNING_DIR/original-keychains.txt" ]]; then + keychains=() + while IFS= read -r entry; do keychains+=("$entry"); done < "$APPLE_SIGNING_DIR/original-keychains.txt" + if [[ ${#keychains[@]} -gt 0 ]]; then security list-keychains -d user -s "${keychains[@]}"; fi + fi + if [[ -f "$APPLE_SIGNING_DIR/signing.keychain-db" ]]; then + security delete-keychain "$APPLE_SIGNING_DIR/signing.keychain-db" + fi + rm -rf "$APPLE_SIGNING_DIR" + exit 0 + ;; + setup) ;; + *) echo 'Usage: keychain.sh setup|cleanup' >&2; exit 1 ;; +esac + +[[ -n "${GITHUB_ENV:-}" && -n "${RUNNER_TEMP:-}" ]] || exit 1 +[[ "${APPLE_TEAM_ID:-}" =~ ^[A-Z0-9]{10}$ ]] || { echo 'Missing APPLE_TEAM_ID repository variable'; exit 1; } +for name in APPLE_CERTIFICATE_P12_BASE64 APPLE_CERTIFICATE_PASSWORD APPLE_NOTARY_KEY_P8_BASE64 APPLE_NOTARY_KEY_ID APPLE_NOTARY_ISSUER_ID; do + [[ -n "${!name:-}" ]] || { echo "Missing $name credential" >&2; exit 1; } +done +umask 077 +signing_dir=$(mktemp -d "$RUNNER_TEMP/apple-signing.XXXXXX") +printf 'APPLE_SIGNING_DIR=%s\n' "$signing_dir" >> "$GITHUB_ENV" +security list-keychains -d user | sed -E 's/^[[:space:]]*"(.*)"$/\1/' > "$signing_dir/original-keychains.txt" +printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 -D > "$signing_dir/certificate.p12" +printf '%s' "$APPLE_NOTARY_KEY_P8_BASE64" | base64 -D > "$signing_dir/notary.p8" +/usr/bin/openssl pkey -in "$signing_dir/notary.p8" -noout >/dev/null +keychain="$signing_dir/signing.keychain-db" +password=$(/usr/bin/openssl rand -hex 32) +security create-keychain -p "$password" "$keychain" +security set-keychain-settings -lut 21600 "$keychain" +security unlock-keychain -p "$password" "$keychain" +security import "$signing_dir/certificate.p12" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" >/dev/null +keychains=("$keychain") +while IFS= read -r entry; do keychains+=("$entry"); done < "$signing_dir/original-keychains.txt" +security list-keychains -d user -s "${keychains[@]}" +identity=$(security find-identity -v -p codesigning "$keychain" | awk -v team="($APPLE_TEAM_ID)" '/Developer ID Application:/ && index($0,team) {print $2}') +[[ "$identity" =~ ^[A-Fa-f0-9]{40}$ ]] || { echo "Expected exactly one Developer ID Application identity for $APPLE_TEAM_ID"; exit 1; } +printf 'APPLE_SIGN_IDENTITY=%s\nAPPLE_SIGNING_KEYCHAIN=%s\nAPPLE_NOTARY_KEY=%s\n' "$identity" "$keychain" "$signing_dir/notary.p8" >> "$GITHUB_ENV" +printf 'APPLE_TEAM_ID=%s\nAPPLE_NOTARY_KEY_ID=%s\nAPPLE_NOTARY_ISSUER_ID=%s\n' "$APPLE_TEAM_ID" "$APPLE_NOTARY_KEY_ID" "$APPLE_NOTARY_ISSUER_ID" >> "$GITHUB_ENV" +echo "Developer ID credentials ready for team $APPLE_TEAM_ID" diff --git a/scripts/release/notarize.sh b/scripts/release/notarize.sh new file mode 100644 index 0000000..3a2b3e9 --- /dev/null +++ b/scripts/release/notarize.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +artifact=${1:?Provide the archive to notarize} +auth=(--key "${APPLE_NOTARY_KEY:?}" --key-id "${APPLE_NOTARY_KEY_ID:?}" --issuer "${APPLE_NOTARY_ISSUER_ID:?}") +result=$(mktemp) +trap 'rm -f "$result"' EXIT +# Submit once. A timeout resumes the same submission instead of uploading again. +status=0 +xcrun notarytool submit "$artifact" "${auth[@]}" --wait --timeout 20m --output-format json > "$result" || status=$? +submission=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("id", ""))' "$result") +[[ -n "$submission" ]] || { cat "$result"; exit 1; } +if [[ $status -ne 0 ]]; then + xcrun notarytool wait "$submission" "${auth[@]}" --timeout 20m --output-format json > "$result" || { + xcrun notarytool info "$submission" "${auth[@]}" || true + echo "Notarization incomplete; inspect existing submission $submission before retrying." + exit 1 + } +fi +status=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["status"])' "$result") +echo "Notarization $submission: $status" +if [[ "$status" != Accepted ]]; then + xcrun notarytool log "$submission" "${auth[@]}" || true + exit 1 +fi diff --git a/scripts/release/package-macos.sh b/scripts/release/package-macos.sh new file mode 100644 index 0000000..7c1e7fd --- /dev/null +++ b/scripts/release/package-macos.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail +target=${1:?Provide a Rust target} +arch=${2:?Provide arm64 or x86_64} +case "$target:$arch" in aarch64-apple-darwin:arm64|x86_64-apple-darwin:x86_64) ;; *) exit 1 ;; esac +app="src-tauri/target/$target/release/bundle/macos/Termy Code.app" +[[ -d "$app" ]] || { echo "Missing $app"; exit 1; } +[[ "$(lipo -archs "$app/Contents/MacOS/termy-code")" == "$arch" ]] || exit 1 +[[ "$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$app/Contents/Info.plist")" == "${APP_VERSION:?}" ]] || exit 1 +sign=(--force --sign "${APPLE_SIGN_IDENTITY:?}" --keychain "${APPLE_SIGNING_KEYCHAIN:?}" --options runtime --timestamp) +# Sign nested Mach-O code first. This app does not bundle Bun or another JIT runtime. +while IFS= read -r -d '' nested; do + if file -b "$nested" | grep -q 'Mach-O'; then + codesign "${sign[@]}" --preserve-metadata=entitlements "$nested" + fi +done < <(find "$app/Contents" -type f -print0) +while IFS= read -r -d '' nested; do + codesign "${sign[@]}" --preserve-metadata=entitlements "$nested" +done < <(find "$app/Contents" -depth -type d \( -name '*.framework' -o -name '*.app' -o -name '*.xpc' \) -print0) +codesign "${sign[@]}" --preserve-metadata=entitlements "$app" +codesign --verify --deep --strict --verbose=2 "$app" +metadata=$(codesign --display --verbose=4 "$app" 2>&1) +grep -q "TeamIdentifier=${APPLE_TEAM_ID:?}" <<< "$metadata" +grep -q 'flags=.*runtime' <<< "$metadata" +grep -q '^Timestamp=' <<< "$metadata" + +scratch=$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/macos-package.XXXXXX") +trap 'rm -rf "$scratch"' EXIT +ditto -c -k --keepParent "$app" "$scratch/submit.zip" +bash scripts/release/notarize.sh "$scratch/submit.zip" +xcrun stapler staple "$app" +xcrun stapler validate "$app" +spctl --assess --type execute --verbose=2 "$app" + +mkdir -p release-artifacts "$scratch/dmg" +stem="Termy-Code-${APP_VERSION}-macos-${arch}" +ditto "$app" "$scratch/dmg/Termy Code.app" +ln -s /Applications "$scratch/dmg/Applications" +hdiutil create -volname 'Termy Code' -srcfolder "$scratch/dmg" -format UDZO -ov "release-artifacts/$stem.dmg" +codesign --force --sign "$APPLE_SIGN_IDENTITY" --keychain "$APPLE_SIGNING_KEYCHAIN" --timestamp "release-artifacts/$stem.dmg" +bash scripts/release/notarize.sh "release-artifacts/$stem.dmg" +xcrun stapler staple "release-artifacts/$stem.dmg" +xcrun stapler validate "release-artifacts/$stem.dmg" +spctl --assess --type open --context context:primary-signature --verbose=2 "release-artifacts/$stem.dmg" +# Only archive the final stapled app; ZIP itself cannot carry a stapled ticket. +ditto -c -k --keepParent "$app" "release-artifacts/$stem.zip" +(cd release-artifacts && shasum -a 256 "$stem.dmg" "$stem.zip" > "$stem.sha256") diff --git a/scripts/release/verify-artifacts.py b/scripts/release/verify-artifacts.py new file mode 100644 index 0000000..c56d336 --- /dev/null +++ b/scripts/release/verify-artifacts.py @@ -0,0 +1,160 @@ +"""Verify final ZIP/DMG downloads and exercise the packaged daemon on native Macs.""" +import base64 +import hashlib +import json +import os +import platform +import plistlib +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + + +def run(*args): + return subprocess.run(args, check=True, capture_output=True).stdout + + +def smoke(app): + with tempfile.TemporaryDirectory(prefix='tc-smoke-', dir='/tmp') as data: + daemon = subprocess.Popen( + [str(app / 'Contents/MacOS/termy-code'), '--daemon'], + env=dict(os.environ, TERMY_DATA_DIR=data), + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + ) + connections = [] + events = [] + sequence = 0 + try: + path = data + '/daemon.sock' + deadline = time.monotonic() + 15 + while not os.path.exists(path): + assert daemon.poll() is None, 'Packaged daemon exited at startup' + assert time.monotonic() < deadline, 'Daemon startup timed out' + time.sleep(.025) + + def connect(): + sock = socket.socket(socket.AF_UNIX) + sock.settimeout(10) + sock.connect(path) + stream = sock.makefile('rwb', buffering=0) + connections.append((stream, sock)) + return stream + + def call(stream, method, params): + nonlocal sequence + sequence += 1 + stream.write((json.dumps(dict(id=sequence, method=method, params=params)) + '\n').encode()) + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + message = json.loads(stream.readline()) + if message.get('id') == sequence: + assert 'error' not in message, message + return message['result'] + events.append(message) + raise AssertionError('Daemon request timed out: ' + method) + + def output_until(stream, marker): + seen = '' + deadline = time.monotonic() + 15 + while marker not in seen: + assert time.monotonic() < deadline, 'Missing shell output: ' + marker + message = events.pop(0) if events else json.loads(stream.readline()) + if message.get('event') == 'shell.output': + seen += base64.b64decode(message['data']['data']).decode(errors='replace') + + first = connect() + hello = call(first, 'hello', {}) + params = dict(key='release-smoke', cwd=data, cols=80, rows=24) + assert call(first, 'shell.open', params)['created'] + call(first, 'shell.write', dict(key=params['key'], data="printf 'release-%s\\n' 42\n")) + output_until(first, 'release-42') + call(first, 'shell.resize', dict(key=params['key'], cols=100, rows=40)) + call(first, 'shell.write', dict(key=params['key'], data='stty size\n')) + output_until(first, '40 100') + first.close() + connections[0][1].close() + events.clear() + second = connect() + assert call(second, 'hello', {})['pid'] == hello['pid'] + params.update(cols=100, rows=40) + restored = call(second, 'shell.open', params) + assert not restored['created'] + assert b'release-42' in base64.b64decode(restored['scrollback']) + call(second, 'shell.write', dict(key=params['key'], data="printf 'resumed-%s\\n' 84\n")) + output_until(second, 'resumed-84') + call(second, 'shell.close', dict(key=params['key'])) + assert call(second, 'status', {})['shells'] == 0 + second.write(b'{"id":999,"method":"daemon.shutdown","params":{}}\n') + assert daemon.wait(timeout=10) == 0 + print('Packaged daemon: terminal I/O, resize, reattachment, replay and shutdown passed') + finally: + for stream, sock in connections: + stream.close() + sock.close() + if daemon.poll() is None: + daemon.terminate() + daemon.wait(timeout=10) + + +def verify_app(app, arch, team): + run('codesign', '--verify', '--deep', '--strict', '--verbose=2', str(app)) + candidates = [app] + for path in (app / 'Contents').rglob('*'): + if path.is_file() and not path.is_symlink() and b'Mach-O' in run('file', '-b', str(path)): + candidates.append(path) + for path in candidates: + result = subprocess.run(['codesign', '--display', '--verbose=4', str(path)], check=True, capture_output=True) + metadata = result.stderr.decode() + assert f'TeamIdentifier={team}' in metadata, metadata + assert 'runtime' in metadata and 'Timestamp=' in metadata, metadata + entitlements = run('codesign', '--display', '--entitlements', ':-', str(path)) + if entitlements.strip(): + values = plistlib.loads(entitlements) + assert not values.get('com.apple.security.get-task-allow'), 'Debug entitlement in release' + executable = app / 'Contents/MacOS/termy-code' + assert run('lipo', '-archs', str(executable)).decode().strip() == arch + run('xcrun', 'stapler', 'validate', str(app)) + run('spctl', '--assess', '--type', 'execute', '--verbose=2', str(app)) + if platform.machine() == arch: + smoke(app) + else: + print(f'{arch}: signature verified; native runtime check requires a {arch} Mac') + + +def main(): + directory = Path(sys.argv[1]).resolve() + arch = sys.argv[2] + team = os.environ['APPLE_TEAM_ID'] + manifests = list(directory.glob(f'*-macos-{arch}.sha256')) + assert len(manifests) == 1, 'Expected one checksum manifest for this architecture' + files = {} + for line in manifests[0].read_text().splitlines(): + checksum, name = line.split(maxsplit=1) + assert Path(name).name == name, 'Invalid artifact filename' + path = directory / name + assert hashlib.sha256(path.read_bytes()).hexdigest() == checksum, f'Checksum mismatch: {name}' + files[path.suffix] = path + assert set(files) == {'.zip', '.dmg'}, 'Expected a DMG and a ZIP' + with tempfile.TemporaryDirectory(prefix='tc-verify-', dir='/tmp') as scratch: + extracted = Path(scratch) / 'zip' + run('ditto', '-x', '-k', str(files['.zip']), str(extracted)) + verify_app(extracted / 'Termy Code.app', arch, team) + dmg = str(files['.dmg']) + run('codesign', '--verify', '--strict', '--verbose=2', dmg) + run('xcrun', 'stapler', 'validate', dmg) + run('spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', '--verbose=2', dmg) + mount = Path(scratch) / 'mount' + mount.mkdir() + run('hdiutil', 'attach', dmg, '-readonly', '-nobrowse', '-mountpoint', str(mount)) + try: + verify_app(mount / 'Termy Code.app', arch, team) + finally: + run('hdiutil', 'detach', str(mount)) + print(f'{arch}: ZIP and DMG checksums, signatures, tickets and Gatekeeper checks passed') + + +if __name__ == '__main__': + main() diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6e0beb2..fa2e20b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -9,23 +9,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "aes" -version = "0.9.3" +name = "aho-corasick" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ - "cipher", - "cpubits", - "cpufeatures 0.3.1", + "memchr", ] [[package]] -name = "aho-corasick" -version = "1.1.5" +name = "alacritty_terminal" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" dependencies = [ - "memchr", + "base64 0.22.1", + "bitflags 2.13.2", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix", + "rustix-openpty", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", ] [[package]] @@ -59,15 +72,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "apple-native-keyring-store" -version = "1.0.2" +name = "arrayvec" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" -dependencies = [ - "keyring-core", - "log", - "security-framework", -] +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-broadcast" @@ -253,15 +261,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -301,24 +300,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block-padding" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" -dependencies = [ - "hybrid-array", -] - [[package]] name = "block2" version = "0.6.2" @@ -476,24 +457,6 @@ dependencies = [ "toml 1.1.6+spec-1.1.0", ] -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - -[[package]] -name = "cbc" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.6.0" @@ -549,22 +512,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "cipher" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" -dependencies = [ - "crypto-common 0.2.2", - "inout", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - [[package]] name = "combine" version = "4.6.8" @@ -575,21 +522,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "serde", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -599,12 +531,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "cookie" version = "0.18.2" @@ -655,21 +581,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -679,15 +590,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.2" @@ -741,15 +643,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - [[package]] name = "cssparser" version = "0.37.0" @@ -780,13 +673,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] -name = "ctutils" -version = "0.4.2" +name = "cursor-icon" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "darling" @@ -900,29 +790,8 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", - "ctutils", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", + "block-buffer", + "crypto-common", ] [[package]] @@ -1191,18 +1060,6 @@ dependencies = [ "zlib-rs", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "nanorand", - "spin", -] - [[package]] name = "fnv" version = "1.0.7" @@ -1215,29 +1072,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "fontconfig-parser" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" -dependencies = [ - "roxmltree", -] - -[[package]] -name = "fontdb" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" -dependencies = [ - "fontconfig-parser", - "log", - "memmap2", - "slotmap", - "tinyvec", - "ttf-parser", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -1274,16 +1108,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs4" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" -dependencies = [ - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "futures-channel" version = "0.3.34" @@ -1477,19 +1301,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -1732,21 +1543,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hkdf" -version = "0.13.0" +name = "home" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", + "windows-sys 0.61.2", ] [[package]] @@ -1798,15 +1600,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "1.11.1" @@ -2043,16 +1836,6 @@ dependencies = [ "cfb", ] -[[package]] -name = "inout" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" -dependencies = [ - "block-padding", - "hybrid-array", -] - [[package]] name = "ipnet" version = "2.12.2" @@ -2248,27 +2031,6 @@ dependencies = [ "serde", ] -[[package]] -name = "keyring" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" -dependencies = [ - "apple-native-keyring-store", - "keyring-core", - "windows-native-keyring-store", - "zbus-secret-service-keyring-store", -] - -[[package]] -name = "keyring-core" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" -dependencies = [ - "log", -] - [[package]] name = "libappindicator" version = "0.9.0" @@ -2318,12 +2080,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.25" @@ -2388,15 +2144,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "memmap2" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" -dependencies = [ - "libc", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -2443,6 +2190,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "muda" version = "0.20.0" @@ -2464,15 +2220,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "ndk" version = "0.9.0" @@ -2509,75 +2256,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3156,15 +2840,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3297,26 +2972,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -3381,53 +3036,12 @@ dependencies = [ "rustix", ] -[[package]] -name = "rustls" -version = "0.23.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -3494,48 +3108,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "secret-service" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" -dependencies = [ - "aes", - "cbc", - "futures-util", - "getrandom 0.4.3", - "hkdf", - "hybrid-array", - "num", - "once_cell", - "serde", - "sha2 0.11.0", - "zbus", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.2", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "selectors" version = "0.38.0" @@ -3731,19 +3303,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -3752,6 +3313,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3780,15 +3351,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - [[package]] name = "smallvec" version = "1.16.2" @@ -3853,15 +3415,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] - [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -3880,12 +3433,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "string_cache" version = "0.9.0" @@ -3916,12 +3463,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "swift-rs" version = "1.0.8" @@ -4064,7 +3605,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs 7.0.0", + "dirs", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4114,7 +3655,7 @@ checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45" dependencies = [ "anyhow", "cargo_toml", - "dirs 7.0.0", + "dirs", "glob", "heck 0.5.0", "json-patch", @@ -4144,7 +3685,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "syn 2.0.119", "tauri-utils", "thiserror 2.0.21", @@ -4374,8 +3915,11 @@ dependencies = [ name = "termy-code" version = "0.1.0" dependencies = [ + "alacritty_terminal", "base64 0.22.1", "ignore", + "libc", + "polling", "rusqlite", "serde", "serde_json", @@ -4383,46 +3927,9 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", - "termy_core", "tokio", ] -[[package]] -name = "termy_core" -version = "0.2.0" -dependencies = [ - "anyhow", - "base64 0.22.1", - "bincode", - "compact_str", - "dirs 6.0.0", - "flate2", - "flume", - "fontdb", - "fs4", - "keyring", - "libc", - "log", - "png 0.18.1", - "polling", - "rand_core", - "regex", - "rustix", - "rustix-openpty", - "semver", - "serde", - "serde_json", - "sha2 0.10.9", - "tempfile", - "ttf-parser", - "unicode-width", - "ureq", - "url", - "uuid", - "windows-sys 0.61.2", - "winreg", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -4728,7 +4235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", - "dirs 7.0.0", + "dirs", "libappindicator", "muda", "objc2", @@ -4749,15 +4256,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] - [[package]] name = "typeid" version = "1.0.3" @@ -4799,30 +4297,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "url" version = "2.5.8" @@ -4904,6 +4378,19 @@ dependencies = [ "libc", ] +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "cursor-icon", + "log", + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -5083,24 +4570,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webview2-com" version = "0.39.1" @@ -5255,19 +4724,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-native-keyring-store" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" -dependencies = [ - "byteorder", - "keyring-core", - "regex", - "windows-sys 0.61.2", - "zeroize", -] - [[package]] name = "windows-numerics" version = "0.3.1" @@ -5305,15 +4761,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -5595,7 +5042,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs 7.0.0", + "dirs", "dom_query", "dpi", "dunce", @@ -5615,7 +5062,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2 0.10.9", + "sha2", "soup3", "tao-macros", "thiserror 2.0.21", @@ -5708,17 +5155,6 @@ dependencies = [ "zvariant", ] -[[package]] -name = "zbus-secret-service-keyring-store" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" -dependencies = [ - "keyring-core", - "secret-service", - "zbus", -] - [[package]] name = "zbus_macros" version = "5.19.0" @@ -5775,12 +5211,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ca24dc..0ad9cad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,17 +13,18 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["macos-private-api"] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22" ignore = "0.4" -tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt-multi-thread", "time"] } +tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt-multi-thread", "time", "net", "fs"] } rusqlite = { version = "0.40", features = ["bundled"] } -# Raw PTY API lives on the `termy-code/raw-pty` branch of termy (worktree at ../termy-raw-pty). -termy_core = { path = "../../termy-raw-pty/crates/core" } +alacritty_terminal = { version = "0.26", default-features = false } +polling = "3" +libc = "0.2" [profile.release] codegen-units = 1 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ef4c7d2..03a3020 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "core:window:allow-start-dragging", + "core:window:allow-set-effects", "opener:default", "dialog:allow-open" ] diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs deleted file mode 100644 index 57bb5c9..0000000 --- a/src-tauri/src/agent.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Spawns ACP agents as child processes and bridges their stdio to the webview. -//! ACP is newline-delimited JSON, so stdout is forwarded line by line. - -use std::{ - collections::HashMap, - process::Stdio, - sync::atomic::{AtomicU32, Ordering}, -}; - -use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, State}; -use tokio::{ - io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, - process::{ChildStdin, Command}, - sync::{oneshot, Mutex}, -}; - -use crate::shell_env; - -#[derive(Default)] -pub struct Agents { - next_id: AtomicU32, - running: Mutex>, -} - -struct Running { - stdin: ChildStdin, - kill: Option>, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SpawnRequest { - command: String, - args: Vec, - cwd: String, - #[serde(default)] - env: HashMap, -} - -#[derive(Clone, Serialize)] -struct Line<'a> { - id: u32, - line: &'a str, -} - -#[derive(Clone, Serialize)] -struct Exit { - id: u32, - code: Option, -} - -#[tauri::command] -pub async fn agent_spawn( - app: AppHandle, - agents: State<'_, Agents>, - request: SpawnRequest, -) -> Result { - let mut child = Command::new(&request.command) - .args(&request.args) - .current_dir(&request.cwd) - .env("PATH", shell_env::login_path()) - .envs(&request.env) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(|error| format!("Couldn't start `{}`: {error}", request.command))?; - - let id = agents.next_id.fetch_add(1, Ordering::Relaxed) + 1; - let stdin = child.stdin.take().ok_or("Agent has no stdin")?; - let stdout = child.stdout.take().ok_or("Agent has no stdout")?; - let stderr = child.stderr.take().ok_or("Agent has no stderr")?; - let (kill_tx, kill_rx) = oneshot::channel(); - - agents.running.lock().await.insert( - id, - Running { - stdin, - kill: Some(kill_tx), - }, - ); - - forward_lines(app.clone(), id, stdout, "agent://stdout"); - forward_lines(app.clone(), id, stderr, "agent://stderr"); - - tauri::async_runtime::spawn(async move { - let status = tokio::select! { - status = child.wait() => status.ok(), - _ = kill_rx => { - let _ = child.kill().await; - child.wait().await.ok() - } - }; - let _ = app.emit( - "agent://exit", - Exit { - id, - code: status.and_then(|status| status.code()), - }, - ); - }); - - Ok(id) -} - -fn forward_lines(app: AppHandle, id: u32, reader: R, event: &'static str) -where - R: tokio::io::AsyncRead + Unpin + Send + 'static, -{ - tauri::async_runtime::spawn(async move { - let mut lines = BufReader::new(reader).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if !line.is_empty() { - let _ = app.emit(event, Line { id, line: &line }); - } - } - }); -} - -#[tauri::command] -pub async fn agent_write(agents: State<'_, Agents>, id: u32, line: String) -> Result<(), String> { - let mut running = agents.running.lock().await; - let agent = running.get_mut(&id).ok_or("Agent is not running")?; - agent - .stdin - .write_all(format!("{line}\n").as_bytes()) - .await - .map_err(|error| error.to_string())?; - agent.stdin.flush().await.map_err(|error| error.to_string()) -} - -#[tauri::command] -pub async fn agent_kill(agents: State<'_, Agents>, id: u32) -> Result<(), String> { - if let Some(mut agent) = agents.running.lock().await.remove(&id) { - if let Some(kill) = agent.kill.take() { - let _ = kill.send(()); - } - } - Ok(()) -} diff --git a/src-tauri/src/agent_terminal.rs b/src-tauri/src/agent_terminal.rs index 350f0b6..0fddaf0 100644 --- a/src-tauri/src/agent_terminal.rs +++ b/src-tauri/src/agent_terminal.rs @@ -1,5 +1,5 @@ //! ACP `terminal/*` requests: commands the agent runs through the client. -//! Each command gets its own Termy PTY. Output is kept as plain text (escape +//! Each command gets its own Alacritty PTY. Output is kept as plain text (escape //! sequences removed, carriage returns applied) because it goes back to a //! model, and is also streamed to the webview for the live step. @@ -12,11 +12,8 @@ use std::{ }; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, State}; -use termy_core::{ - pty::{Pty, PtyCommand, PtySize}, - TerminalLaunch, -}; +use serde_json::{json, Value}; +use crate::pty::{Pty, PtyCommand, PtySize, TerminalLaunch}; use tokio::sync::watch; use crate::shell_env; @@ -26,6 +23,9 @@ const COLS: u16 = 200; const ROWS: u16 = 50; const DEFAULT_OUTPUT_LIMIT: usize = 1024 * 1024; +/// Where live output and exits go: `(event, data)`, e.g. to every attached window. +pub type Sink = Arc; + #[derive(Default)] pub struct AgentTerminals { next_id: AtomicU32, @@ -33,6 +33,8 @@ pub struct AgentTerminals { } struct AgentTerminal { + /// The agent that created it; its terminals go when it does. + owner: String, pty: Mutex>, /// Shared with the PTY reader thread, which appends to it. output: Arc>, @@ -46,6 +48,7 @@ pub struct EnvVariable { value: String, } +/// ACP `terminal/create` params (the session id is ignored). #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateRequest { @@ -73,19 +76,6 @@ pub struct OutputResponse { exit_status: Option, } -#[derive(Clone, Serialize)] -struct OutputEvent<'a> { - id: &'a str, - text: &'a str, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ExitEvent { - id: String, - exit_status: ExitStatus, -} - fn signal_name(signal: i32) -> String { match signal { 1 => "SIGHUP".into(), @@ -119,146 +109,155 @@ impl AgentTerminals { .cloned() .ok_or_else(|| format!("Unknown terminal {id}")) } -} -#[tauri::command] -pub fn acp_terminal_create( - app: AppHandle, - terminals: State<'_, AgentTerminals>, - request: CreateRequest, -) -> Result { - let id = format!("term-{}", terminals.next_id.fetch_add(1, Ordering::Relaxed) + 1); - // A bare command line (no separate args) runs through the shell, like the agent expects. - let launch = if request.args.is_empty() && request.command.contains(char::is_whitespace) { - TerminalLaunch::ShellCommand(request.command.clone()) - } else { - TerminalLaunch::Program { - program: request.command.clone(), - args: request.args.clone(), - } - }; - let mut environment = vec![ - ("PATH".to_owned(), shell_env::login_path().to_owned()), - // Most tools respect these and skip pagers and colour, which keeps output readable. - ("PAGER".to_owned(), "cat".to_owned()), - ("GIT_PAGER".to_owned(), "cat".to_owned()), - ]; - environment.extend(request.env.into_iter().map(|variable| (variable.name, variable.value))); - - let output = Arc::new(Mutex::new(Output::new( - request.output_byte_limit.unwrap_or(DEFAULT_OUTPUT_LIMIT), - ))); - let (exited_tx, exited_rx) = watch::channel(false); - let terminal = Arc::new(AgentTerminal { - pty: Mutex::new(None), - output: output.clone(), - exited: exited_rx, - }); - - let sink = output; - let stream_app = app.clone(); - let stream_id = id.clone(); - let mut sanitizer = Sanitizer::default(); - let exit_app = app.clone(); - let exit_id = id.clone(); - let exit_terminal = Arc::downgrade(&terminal); - - let pty = Pty::spawn( - &termy_core::TerminalRuntimeConfig::default(), - PtyCommand { - launch: Some(&launch), - working_directory: request.cwd.as_deref(), - environment: &environment, - ..PtyCommand::default() - }, - PtySize::new(COLS, ROWS), - move |bytes| { - let text = sanitizer.feed(bytes); - if text.is_empty() { - return; - } - if let Ok(mut output) = sink.lock() { - output.push(&text); - } - let _ = stream_app.emit("acp-terminal://output", OutputEvent { id: &stream_id, text: &text }); - }, - move || { - let _ = exited_tx.send(true); - if let Some(status) = exit_terminal.upgrade().and_then(|terminal| terminal.exit_status()) { - let _ = exit_app.emit( - "acp-terminal://exit", - ExitEvent { - id: exit_id, - exit_status: status, - }, - ); + /// ACP `terminal/create`. Output streams to `sink` as `acpTerminal.output` / `acpTerminal.exit`. + pub fn create(&self, owner: &str, request: CreateRequest, sink: Sink) -> Result { + let id = format!("term-{}", self.next_id.fetch_add(1, Ordering::Relaxed) + 1); + // A bare command line (no separate args) runs through the shell, like the agent expects. + let launch = if request.args.is_empty() && request.command.contains(char::is_whitespace) { + TerminalLaunch::ShellCommand(request.command.clone()) + } else { + TerminalLaunch::Program { + program: request.command.clone(), + args: request.args.clone(), } - }, - ) - .map_err(|error| format!("Couldn't run `{}`: {error}", request.command))?; - - *terminal.pty.lock().map_err(|_| "Terminal poisoned")? = Some(pty); - terminals - .open - .lock() - .map_err(|_| "Terminal registry poisoned")? - .insert(id.clone(), terminal); - Ok(id) -} + }; + let mut environment = vec![ + ("PATH".to_owned(), shell_env::login_path().to_owned()), + // Most tools respect these and skip pagers and colour, which keeps output readable. + ("PAGER".to_owned(), "cat".to_owned()), + ("GIT_PAGER".to_owned(), "cat".to_owned()), + ]; + environment.extend(request.env.into_iter().map(|variable| (variable.name, variable.value))); + + let output = Arc::new(Mutex::new(Output::new( + request.output_byte_limit.unwrap_or(DEFAULT_OUTPUT_LIMIT), + ))); + let (exited_tx, exited_rx) = watch::channel(false); + let terminal = Arc::new(AgentTerminal { + owner: owner.to_owned(), + pty: Mutex::new(None), + output: output.clone(), + exited: exited_rx, + }); + + let sink_output = output; + let stream_sink = sink.clone(); + let stream_id = id.clone(); + let mut sanitizer = Sanitizer::default(); + let exit_id = id.clone(); + let exit_terminal = Arc::downgrade(&terminal); -#[tauri::command] -pub fn acp_terminal_output(terminals: State<'_, AgentTerminals>, id: String) -> Result { - let terminal = terminals.get(&id)?; - let (output, truncated) = terminal - .output - .lock() - .map_err(|_| "Terminal poisoned")? - .snapshot(); - Ok(OutputResponse { - output, - truncated, - exit_status: terminal.exit_status(), - }) -} + let pty = Pty::spawn( + PtyCommand { + launch: Some(&launch), + working_directory: request.cwd.as_deref(), + environment: &environment, + ..PtyCommand::default() + }, + PtySize::new(COLS, ROWS), + move |bytes| { + let text = sanitizer.feed(bytes); + if text.is_empty() { + return; + } + if let Ok(mut output) = sink_output.lock() { + output.push(&text); + } + stream_sink("acpTerminal.output", json!({ "id": stream_id, "text": text })); + }, + move || { + let _ = exited_tx.send(true); + if let Some(status) = exit_terminal.upgrade().and_then(|terminal| terminal.exit_status()) { + sink("acpTerminal.exit", json!({ "id": exit_id, "exitStatus": status })); + } + }, + ) + .map_err(|error| format!("Couldn't run `{}`: {error}", request.command))?; -#[tauri::command] -pub async fn acp_terminal_wait(terminals: State<'_, AgentTerminals>, id: String) -> Result { - let terminal = terminals.get(&id)?; - let mut exited = terminal.exited.clone(); - exited - .wait_for(|exited| *exited) - .await - .map_err(|_| "Terminal was released".to_owned())?; - Ok(terminal.exit_status().unwrap_or(ExitStatus { - exit_code: None, - signal: None, - })) -} + *terminal.pty.lock().map_err(|_| "Terminal poisoned")? = Some(pty); + self.open + .lock() + .map_err(|_| "Terminal registry poisoned")? + .insert(id.clone(), terminal); + Ok(id) + } -#[tauri::command] -pub fn acp_terminal_kill(terminals: State<'_, AgentTerminals>, id: String) -> Result<(), String> { - let terminal = terminals.get(&id)?; - let pty = terminal.pty.lock().map_err(|_| "Terminal poisoned")?; - match pty.as_ref() { - Some(pty) => pty.kill().map_err(|error| error.to_string()), - None => Ok(()), + /// ACP `terminal/output`. + pub fn output(&self, id: &str) -> Result { + let terminal = self.get(id)?; + let (output, truncated) = terminal.output.lock().map_err(|_| "Terminal poisoned")?.snapshot(); + Ok(OutputResponse { + output, + truncated, + exit_status: terminal.exit_status(), + }) } -} -/// Drops the PTY, which hangs up anything still running. -#[tauri::command] -pub fn acp_terminal_release(terminals: State<'_, AgentTerminals>, id: String) -> Result<(), String> { - let removed = terminals - .open - .lock() - .map_err(|_| "Terminal registry poisoned")? - .remove(&id); - if let Some(terminal) = removed { - if let Ok(mut pty) = terminal.pty.lock() { - pty.take(); + /// ACP `terminal/wait_for_exit`. + pub async fn wait(&self, id: &str) -> Result { + let terminal = self.get(id)?; + let mut exited = terminal.exited.clone(); + exited + .wait_for(|exited| *exited) + .await + .map_err(|_| "Terminal was released".to_owned())?; + Ok(terminal.exit_status().unwrap_or(ExitStatus { + exit_code: None, + signal: None, + })) + } + + /// ACP `terminal/kill`: stops the command but keeps its output readable. + pub fn kill(&self, id: &str) -> Result<(), String> { + let terminal = self.get(id)?; + let pty = terminal.pty.lock().map_err(|_| "Terminal poisoned")?; + match pty.as_ref() { + Some(pty) => pty.kill().map_err(|error| error.to_string()), + None => Ok(()), + } + } + + /// ACP `terminal/release`: drops the PTY, which hangs up anything still running. + pub fn release(&self, id: &str) -> Result<(), String> { + let removed = self + .open + .lock() + .map_err(|_| "Terminal registry poisoned")? + .remove(id); + if let Some(terminal) = removed { + if let Ok(mut pty) = terminal.pty.lock() { + pty.take(); + } } + Ok(()) + } + + /// Releases every terminal an agent created, when the agent exits or is stopped. + pub fn release_owned_by(&self, owner: &str) { + let ids: Vec = match self.open.lock() { + Ok(open) => open + .iter() + .filter(|(_, terminal)| terminal.owner == owner) + .map(|(id, _)| id.clone()) + .collect(), + Err(_) => return, + }; + for id in ids { + let _ = self.release(&id); + } + } + + /// Every open terminal's output so far, for a window that just attached. + pub fn snapshot(&self) -> Vec { + let Ok(open) = self.open.lock() else { return Vec::new() }; + open.iter() + .filter_map(|(id, terminal)| { + let (text, _) = terminal.output.lock().ok()?.snapshot(); + Some(json!({ "id": id, "text": text, "exitStatus": terminal.exit_status() })) + }) + .collect() } - Ok(()) } // ── output ─────────────────────────────────────────────────────────────── @@ -414,7 +413,6 @@ mod tests { ); let environment = [("TERMY_AGENT".to_owned(), "codex".to_owned())]; let pty = Pty::spawn( - &termy_core::TerminalRuntimeConfig::default(), PtyCommand { launch: Some(&launch), environment: &environment, diff --git a/src-tauri/src/daemon/agents.rs b/src-tauri/src/daemon/agents.rs new file mode 100644 index 0000000..b858e82 --- /dev/null +++ b/src-tauri/src/daemon/agents.rs @@ -0,0 +1,693 @@ +//! Agents owned by the daemon, keyed by the thread they belong to. +//! +//! The daemon sits between the window's ACP client and the agent process and +//! rewrites just enough JSON-RPC to let windows come and go: +//! +//! - Requests from a window get daemon-wide ids, so a new window's ids never +//! collide with an earlier window's requests that are still in flight. Responses +//! go back to the window that asked, under its own id. +//! - `fs/*` and `terminal/*` requests from the agent are answered here, so an +//! agent keeps working with no window open. +//! - Other agent requests (permission prompts, questions) wait for a window and +//! are handed to every window that attaches until one answers. +//! - Notifications are numbered and written to a log on disk. A window attaches +//! with the last number it saved and gets everything after it. Notifications +//! that arrive during `session/load` are marked `replay`, since they repeat +//! history the window already has. +//! - The end of every `session/prompt` is logged as a `_termy/turn_end` +//! notification, so a turn that finished while no window watched still ends +//! in the thread. +//! - The newest session-level update of each kind (commands, mode, config, +//! usage, title) is kept apart from the log, including mode and config changes +//! a window made itself, so a window attaching after the log was trimmed still +//! shows the session as it is. + +use std::{ + collections::{HashMap, HashSet, VecDeque}, + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + process::Stdio, + sync::{Arc, Mutex}, +}; + +use serde_json::{json, Value}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + process::{ChildStdin, Command}, + sync::oneshot, +}; + +use super::{now_ms, param, string, ConnId, Daemon}; +use crate::{agent_terminal::CreateRequest, paths, shell_env, workspace}; + +const STDERR_LINES: usize = 20; +const TURN_END: &str = "_termy/turn_end"; + +/// Updates that set session state rather than add to the timeline; only the newest of each counts. +const STATE_UPDATES: &[&str] = &[ + "available_commands_update", + "config_option_update", + "current_mode_update", + "usage_update", + "session_info_update", +]; + +pub struct Agents { + map: Mutex>>, +} + +pub struct Agent { + key: String, + cwd: String, + stdin: tokio::sync::Mutex>, + kill: Mutex>>, + state: Mutex, +} + +struct Outgoing { + conn: ConnId, + client_id: Value, + method: String, + session_id: Option, + /// For `session/set_mode`, the mode being switched to. + mode_id: Option, +} + +struct AgentState { + next_id: u64, + outgoing: HashMap, + /// Agent requests a window has to answer, as received. + pending: Vec, + log: AgentLog, + /// The prompt in flight: `{ sessionId, startedAt }`. + turn: Option, + initialize: Option, + session: Option, + session_id: Option, + /// The newest state update of each kind, as `session/update` notifications. + latest: HashMap, + /// `session/load` requests in flight; their notifications are replays. + loads: u32, + /// `Some` once the process is gone, with its exit code if it had one. + exited: Option>, + stderr: VecDeque, + attached: HashSet, +} + +impl AgentState { + fn new(log: AgentLog) -> Self { + Self { + next_id: 1, + outgoing: HashMap::new(), + pending: Vec::new(), + log, + turn: None, + initialize: None, + session: None, + session_id: None, + latest: HashMap::new(), + loads: 0, + exited: None, + stderr: VecDeque::new(), + attached: HashSet::new(), + } + } + + fn summary(&self, key: &str, cwd: &str) -> Value { + json!({ + "key": key, + "cwd": cwd, + "alive": self.exited.is_none(), + "exitCode": self.exited.flatten(), + "stderr": self.stderr.iter().cloned().collect::>().join("\n"), + "turn": self.turn, + "initialize": self.initialize, + "session": self.session, + "sessionId": self.session_id, + "lastSeq": self.log.next_seq - 1, + }) + } + + fn remember_state(&mut self, session_id: Option<&str>, update: Value) { + let Some(kind) = update.get("sessionUpdate").and_then(Value::as_str) else { return }; + if !STATE_UPDATES.contains(&kind) { + return; + } + let message = json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { "sessionId": session_id, "update": update }, + }); + self.latest.insert(kind.to_owned(), message); + } +} + +/// Numbered notifications, mirrored to `.ndjson` so they survive a daemon restart. +struct AgentLog { + path: PathBuf, + entries: Vec, + next_seq: u64, +} + +impl AgentLog { + fn fresh(path: PathBuf) -> Self { + let _ = fs::remove_file(&path); + Self { + path, + entries: Vec::new(), + next_seq: 1, + } + } + + fn recover(path: PathBuf) -> Self { + let entries: Vec = fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + let next_seq = entries + .iter() + .filter_map(|entry| entry.get("seq").and_then(Value::as_u64)) + .max() + .unwrap_or(0) + + 1; + Self { path, entries, next_seq } + } + + fn append(&mut self, replay: bool, message: Value) -> Value { + let entry = json!({ "seq": self.next_seq, "replay": replay, "message": message }); + self.next_seq += 1; + if let Some(parent) = self.path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&self.path) { + let _ = writeln!(file, "{entry}"); + } + self.entries.push(entry.clone()); + entry + } + + fn after(&self, seq: u64) -> Vec { + self.entries + .iter() + .filter(|entry| entry.get("seq").and_then(Value::as_u64).is_some_and(|entry_seq| entry_seq > seq)) + .cloned() + .collect() + } + + /// The window saved everything up to `seq`, so those entries can go. + fn ack(&mut self, seq: u64) { + let before = self.entries.len(); + self.entries + .retain(|entry| entry.get("seq").and_then(Value::as_u64).is_some_and(|entry_seq| entry_seq > seq)); + if self.entries.len() == before { + return; + } + if self.entries.is_empty() { + let _ = fs::remove_file(&self.path); + return; + } + let text: String = self.entries.iter().map(|entry| format!("{entry}\n")).collect(); + let _ = fs::write(&self.path, text); + } + + fn delete(&self) { + let _ = fs::remove_file(&self.path); + } +} + +fn log_path(key: &str) -> PathBuf { + let safe: String = key + .chars() + .map(|character| if character.is_ascii_alphanumeric() || character == '-' { character } else { '_' }) + .collect(); + paths::agent_log_dir().join(format!("{safe}.ndjson")) +} + +fn turn_end(session_id: Option<&str>, response: &Value) -> Value { + json!({ + "jsonrpc": "2.0", + "method": TURN_END, + "params": { + "sessionId": session_id, + "stopReason": response.get("result").and_then(|result| result.get("stopReason")), + "error": response.get("error"), + }, + }) +} + +impl Agents { + /// Agents whose logs outlived a previous daemon: their processes are gone, + /// but a window can still collect what they reported. + pub fn recover(dir: &Path) -> Self { + let mut map = HashMap::new(); + for entry in fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("ndjson") { + continue; + } + let Some(key) = path.file_stem().and_then(|stem| stem.to_str()).map(str::to_owned) else { continue }; + let mut state = AgentState::new(AgentLog::recover(path)); + state.exited = Some(None); + state + .stderr + .push_back("Termy Code's background service restarted, so this agent stopped.".to_owned()); + map.insert( + key.clone(), + Arc::new(Agent { + key, + cwd: String::new(), + stdin: tokio::sync::Mutex::new(None), + kill: Mutex::new(None), + state: Mutex::new(state), + }), + ); + } + Self { map: Mutex::new(map) } + } + + fn get(&self, key: &str) -> Result, String> { + self.map + .lock() + .map_err(|_| "Agent registry poisoned".to_owned())? + .get(key) + .cloned() + .ok_or_else(|| "That agent isn't running.".to_owned()) + } + + fn all(&self) -> Vec> { + self.map + .lock() + .map(|map| map.values().cloned().collect()) + .unwrap_or_default() + } + + pub fn alive_count(&self) -> usize { + self.all() + .iter() + .filter(|agent| agent.state.lock().is_ok_and(|state| state.exited.is_none())) + .count() + } + + pub fn list(&self) -> Value { + Value::Array( + self.all() + .iter() + .filter_map(|agent| Some(agent.state.lock().ok()?.summary(&agent.key, &agent.cwd))) + .collect(), + ) + } + + /// Everything after `after`, the requests still waiting for an answer, and live updates from now on. + pub fn attach(&self, conn: ConnId, key: &str, after: u64) -> Result { + let agent = self.get(key)?; + let mut state = agent.state.lock().map_err(|_| "Agent poisoned")?; + state.attached.insert(conn); + let mut snapshot = state.summary(&agent.key, &agent.cwd); + snapshot["entries"] = Value::Array(state.log.after(after)); + snapshot["pending"] = Value::Array(state.pending.clone()); + snapshot["latest"] = Value::Array(state.latest.values().cloned().collect()); + Ok(snapshot) + } + + pub fn detach(&self, conn: ConnId, key: &str) { + if let Ok(agent) = self.get(key) { + if let Ok(mut state) = agent.state.lock() { + state.attached.remove(&conn); + } + } + } + + pub fn detach_all(&self, conn: ConnId) { + for agent in self.all() { + if let Ok(mut state) = agent.state.lock() { + state.attached.remove(&conn); + } + } + } + + pub fn ack(&self, key: &str, seq: u64) { + if let Ok(agent) = self.get(key) { + if let Ok(mut state) = agent.state.lock() { + state.log.ack(seq); + } + } + } + + fn remove(&self, key: &str) -> Option> { + self.map.lock().ok()?.remove(key) + } + + pub fn kill_all(&self, daemon: &Daemon) { + for agent in self.all() { + agent.stop(); + daemon.terminals.release_owned_by(&agent.key); + } + } +} + +impl Agent { + fn stop(&self) { + if let Some(kill) = self.kill.lock().ok().and_then(|mut kill| kill.take()) { + let _ = kill.send(()); + } + } + + async fn write(&self, message: &Value) -> Result<(), String> { + let mut stdin = self.stdin.lock().await; + let stdin = stdin.as_mut().ok_or("The agent has exited.")?; + let mut line = message.to_string(); + line.push('\n'); + stdin.write_all(line.as_bytes()).await.map_err(|error| error.to_string())?; + stdin.flush().await.map_err(|error| error.to_string()) + } +} + +impl Daemon { + pub async fn spawn_agent(self: &Arc, params: &Value) -> Result { + let key = string(params, "key")?; + let command = string(params, "command")?; + let cwd = string(params, "cwd")?; + let args: Vec = serde_json::from_value(param(params, "args")?.clone()).map_err(|error| error.to_string())?; + let env: HashMap = params + .get("env") + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(|error| error.to_string())? + .unwrap_or_default(); + + if let Ok(existing) = self.agents.get(&key) { + if existing.state.lock().is_ok_and(|state| state.exited.is_none()) { + return Err("An agent is already running for this thread.".to_owned()); + } + self.kill_agent(&key); + } + + let mut child = Command::new(&command) + .args(&args) + .current_dir(&cwd) + .env("PATH", shell_env::login_path()) + .envs(&env) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|error| format!("Couldn't start `{command}`: {error}"))?; + + let stdin = child.stdin.take().ok_or("Agent has no stdin")?; + let stdout = child.stdout.take().ok_or("Agent has no stdout")?; + let stderr = child.stderr.take().ok_or("Agent has no stderr")?; + let (kill_tx, kill_rx) = oneshot::channel(); + let agent = Arc::new(Agent { + key: key.clone(), + cwd, + stdin: tokio::sync::Mutex::new(Some(stdin)), + kill: Mutex::new(Some(kill_tx)), + state: Mutex::new(AgentState::new(AgentLog::fresh(log_path(&key)))), + }); + self.agents + .map + .lock() + .map_err(|_| "Agent registry poisoned")? + .insert(key, agent.clone()); + + let daemon = self.clone(); + let reader = agent.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + // Agents sometimes log to stdout; anything that isn't JSON-RPC is noise. + if let Ok(message) = serde_json::from_str::(&line) { + daemon.from_agent(&reader, message); + } + } + }); + + let tail = agent.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if let Ok(mut state) = tail.state.lock() { + state.stderr.push_back(line); + if state.stderr.len() > STDERR_LINES { + state.stderr.pop_front(); + } + } + } + }); + + let daemon = self.clone(); + let watched = agent.clone(); + tokio::spawn(async move { + let status = tokio::select! { + status = child.wait() => status.ok(), + _ = kill_rx => { + let _ = child.kill().await; + child.wait().await.ok() + } + }; + daemon.agent_exited(&watched, status.and_then(|status| status.code())); + }); + + Ok(Value::Null) + } + + fn agent_exited(&self, agent: &Arc, code: Option) { + self.terminals.release_owned_by(&agent.key); + let Ok(mut state) = agent.state.lock() else { return }; + state.exited = Some(code); + state.pending.clear(); + state.turn = None; + let stderr = state.stderr.iter().cloned().collect::>().join("\n"); + let attached: Vec = state.attached.iter().copied().collect(); + self.hub + .event(attached, "agent.exit", json!({ "key": agent.key, "code": code, "stderr": stderr })); + } + + /// Stops the agent for good and forgets its log. + pub fn kill_agent(&self, key: &str) { + if let Some(agent) = self.agents.remove(key) { + agent.stop(); + if let Ok(state) = agent.state.lock() { + state.log.delete(); + } + } + self.terminals.release_owned_by(key); + } + + /// A JSON-RPC message from a window to the agent. + pub async fn send_to_agent(&self, conn: ConnId, key: &str, mut message: Value) -> Result { + let agent = self.agents.get(key)?; + { + let mut state = agent.state.lock().map_err(|_| "Agent poisoned")?; + if state.exited.is_some() { + return Err("The agent has exited.".to_owned()); + } + let method = message.get("method").and_then(Value::as_str).map(str::to_owned); + let id = message.get("id").cloned(); + match (method, id) { + (Some(method), Some(client_id)) => { + let daemon_id = state.next_id; + state.next_id += 1; + let session_id = message + .pointer("/params/sessionId") + .and_then(Value::as_str) + .map(str::to_owned); + if method == "session/prompt" { + state.turn = Some(json!({ "sessionId": session_id, "startedAt": now_ms() })); + } + if method == "session/load" { + state.loads += 1; + } + let mode_id = (method == "session/set_mode") + .then(|| message.pointer("/params/modeId").and_then(Value::as_str).map(str::to_owned)) + .flatten(); + state.outgoing.insert( + daemon_id, + Outgoing { + conn, + client_id, + method, + session_id, + mode_id, + }, + ); + message["id"] = json!(daemon_id); + } + // An answer to one of the agent's requests. + (None, Some(id)) => state.pending.retain(|request| request.get("id") != Some(&id)), + _ => {} + } + } + agent.write(&message).await?; + Ok(Value::Null) + } + + /// A JSON-RPC message from the agent. + fn from_agent(self: &Arc, agent: &Arc, message: Value) { + let method = message.get("method").and_then(Value::as_str).map(str::to_owned); + let id = message.get("id").cloned(); + match (method, id) { + (Some(method), Some(id)) if method.starts_with("fs/") || method.starts_with("terminal/") => { + let daemon = self.clone(); + let agent = agent.clone(); + let params = message.get("params").cloned().unwrap_or(Value::Null); + tokio::spawn(async move { + let response = match daemon.client_request(&agent.key, &method, ¶ms).await { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err(error) => json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32603, "message": error }, + }), + }; + let _ = agent.write(&response).await; + }); + } + (Some(_), Some(_)) => { + let Ok(mut state) = agent.state.lock() else { return }; + state.pending.push(message.clone()); + let attached: Vec = state.attached.iter().copied().collect(); + self.hub + .event(attached, "agent.message", json!({ "key": agent.key, "message": message })); + } + (Some(method), None) => { + let Ok(mut state) = agent.state.lock() else { return }; + if method == "session/update" { + if let Some(update) = message.pointer("/params/update").cloned() { + let session_id = message.pointer("/params/sessionId").and_then(Value::as_str).map(str::to_owned); + state.remember_state(session_id.as_deref(), update); + } + } + let replay = state.loads > 0; + let entry = state.log.append(replay, message); + let attached: Vec = state.attached.iter().copied().collect(); + self.hub.event(attached, "agent.entry", json!({ "key": agent.key, "entry": entry })); + } + (None, Some(id)) => { + let Ok(mut state) = agent.state.lock() else { return }; + let Some(outgoing) = id.as_u64().and_then(|id| state.outgoing.remove(&id)) else { return }; + let result = message.get("result"); + match outgoing.method.as_str() { + "initialize" => state.initialize = result.cloned(), + "session/new" | "session/resume" | "session/load" | "session/fork" => { + if outgoing.method == "session/load" { + state.loads = state.loads.saturating_sub(1); + } + if let Some(result) = result { + state.session = Some(result.clone()); + state.session_id = result + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_owned) + .or(outgoing.session_id.clone()); + } + } + "session/set_config_option" => { + if let Some(options) = result.and_then(|result| result.get("configOptions")) { + let update = json!({ "sessionUpdate": "config_option_update", "configOptions": options }); + state.remember_state(outgoing.session_id.as_deref(), update); + } + } + "session/set_mode" => { + if let (Some(_), Some(mode_id)) = (result, outgoing.mode_id.as_deref()) { + let update = json!({ "sessionUpdate": "current_mode_update", "currentModeId": mode_id }); + state.remember_state(outgoing.session_id.as_deref(), update); + } + } + "session/prompt" => { + state.turn = None; + let entry = state.log.append(false, turn_end(outgoing.session_id.as_deref(), &message)); + let attached: Vec = state.attached.iter().copied().collect(); + self.hub.event(attached, "agent.entry", json!({ "key": agent.key, "entry": entry })); + } + _ => {} + } + let mut reply = message.clone(); + reply["id"] = outgoing.client_id; + self.hub.event( + [outgoing.conn], + "agent.message", + json!({ "key": agent.key, "message": reply }), + ); + } + (None, None) => {} + } + } + + /// The client side of ACP that doesn't need a person: files and commands. + async fn client_request(&self, owner: &str, method: &str, params: &Value) -> Result { + let text = |name: &str| string(params, name); + let count = |name: &str| params.get(name).and_then(Value::as_u64).map(|value| value as usize); + match method { + "fs/read_text_file" => { + let content = workspace::read_text(&text("path")?, count("line"), count("limit")).await?; + Ok(json!({ "content": content })) + } + "fs/write_text_file" => { + workspace::write_text(&text("path")?, &text("content")?).await?; + Ok(json!({})) + } + "terminal/create" => { + let request: CreateRequest = serde_json::from_value(params.clone()).map_err(|error| error.to_string())?; + let hub = self.hub.clone(); + let sink: crate::agent_terminal::Sink = Arc::new(move |name, data| hub.broadcast(name, data)); + let terminal_id = self.terminals.create(owner, request, sink)?; + Ok(json!({ "terminalId": terminal_id })) + } + "terminal/output" => serde_json::to_value(self.terminals.output(&text("terminalId")?)?) + .map_err(|error| error.to_string()), + "terminal/wait_for_exit" => serde_json::to_value(self.terminals.wait(&text("terminalId")?).await?) + .map_err(|error| error.to_string()), + "terminal/kill" => { + self.terminals.kill(&text("terminalId")?)?; + Ok(json!({})) + } + "terminal/release" => { + self.terminals.release(&text("terminalId")?)?; + Ok(json!({})) + } + other => Err(format!("Termy Code doesn't support `{other}`")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_numbers_entries_and_survives_a_restart() { + let dir = std::env::temp_dir().join(format!("termy-log-test-{}", std::process::id())); + let path = dir.join("thread.ndjson"); + let mut log = AgentLog::fresh(path.clone()); + log.append(false, json!({ "method": "session/update", "params": { "n": 1 } })); + log.append(true, json!({ "method": "session/update", "params": { "n": 2 } })); + log.append(false, json!({ "method": "session/update", "params": { "n": 3 } })); + assert_eq!(log.after(1).len(), 2); + + log.ack(2); + let recovered = AgentLog::recover(path.clone()); + assert_eq!(recovered.entries.len(), 1); + assert_eq!(recovered.entries[0]["seq"], 3); + assert_eq!(recovered.next_seq, 4); + + log.ack(3); + assert!(!path.exists()); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn turn_end_carries_the_stop_reason_or_the_error() { + let done = turn_end(Some("s1"), &json!({ "id": 4, "result": { "stopReason": "end_turn" } })); + assert_eq!(done["method"], TURN_END); + assert_eq!(done["params"]["stopReason"], "end_turn"); + assert!(done["params"]["error"].is_null()); + + let failed = turn_end(Some("s1"), &json!({ "id": 4, "error": { "code": -32000, "message": "auth" } })); + assert_eq!(failed["params"]["error"]["message"], "auth"); + } +} diff --git a/src-tauri/src/daemon/mod.rs b/src-tauri/src/daemon/mod.rs new file mode 100644 index 0000000..d0c743b --- /dev/null +++ b/src-tauri/src/daemon/mod.rs @@ -0,0 +1,471 @@ +//! The background service that keeps agents and shells running when the app +//! is closed, like a terminal multiplexer. +//! +//! It is this same binary started with `--daemon`. The app connects over a +//! Unix socket and speaks newline-delimited JSON: +//! +//! - app → daemon: `{"id", "method", "params"}`, answered by `{"id", "result"}` or `{"id", "error"}` +//! - daemon → app: `{"event", "data"}` +//! +//! Closing the app only drops the connection. Agents keep working (the daemon +//! answers their file and terminal requests itself and logs everything they +//! report), shells keep running with their scrollback, and the next window +//! attaches to all of it again. + +mod agents; +mod shells; + +use std::{ + collections::HashMap, + path::Path, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant, UNIX_EPOCH}, +}; + +use serde_json::{json, Value}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::{UnixListener, UnixStream}, + sync::{mpsc, Notify}, +}; + +use crate::{agent_terminal::AgentTerminals, paths}; + +/// Bumped whenever the wire protocol changes, so an app never talks to an incompatible daemon. +pub const PROTOCOL: u64 = 1; + +/// With no window, agent or shell for this long, the daemon exits. +const IDLE_EXIT: Duration = Duration::from_secs(10 * 60); + +pub type ConnId = u64; + +/// The connected windows, each fed through its own writer task. +#[derive(Default)] +pub struct Hub { + conns: Mutex>>, +} + +impl Hub { + fn add(&self, conn: ConnId, sender: mpsc::UnboundedSender) { + if let Ok(mut conns) = self.conns.lock() { + conns.insert(conn, sender); + } + } + + fn remove(&self, conn: ConnId) { + if let Ok(mut conns) = self.conns.lock() { + conns.remove(&conn); + } + } + + fn count(&self) -> usize { + self.conns.lock().map(|conns| conns.len()).unwrap_or(0) + } + + fn send(&self, conn: ConnId, frame: &Value) { + if let Ok(conns) = self.conns.lock() { + if let Some(sender) = conns.get(&conn) { + let _ = sender.send(frame.to_string()); + } + } + } + + /// An event for some connections. Sending never blocks, so it's safe under other locks. + pub fn event(&self, conns: impl IntoIterator, name: &str, data: Value) { + let frame = json!({ "event": name, "data": data }).to_string(); + if let Ok(all) = self.conns.lock() { + for conn in conns { + if let Some(sender) = all.get(&conn) { + let _ = sender.send(frame.clone()); + } + } + } + } + + pub fn broadcast(&self, name: &str, data: Value) { + let conns: Vec = self + .conns + .lock() + .map(|conns| conns.keys().copied().collect()) + .unwrap_or_default(); + self.event(conns, name, data); + } +} + +pub struct Daemon { + pub hub: Arc, + agents: agents::Agents, + shells: shells::Shells, + pub terminals: AgentTerminals, + next_conn: AtomicU64, + /// The binary's modification time when the daemon started, so a rebuilt app can tell it's stale. + exe_mtime: u64, + shutdown: Notify, +} + +impl Daemon { + fn new() -> Arc { + Arc::new(Self { + hub: Arc::new(Hub::default()), + agents: agents::Agents::recover(&paths::agent_log_dir()), + shells: shells::Shells::default(), + terminals: AgentTerminals::default(), + next_conn: AtomicU64::new(1), + exe_mtime: exe_mtime(), + shutdown: Notify::new(), + }) + } +} + +/// Seconds since the epoch the running executable was last modified. +pub fn exe_mtime() -> u64 { + std::env::current_exe() + .and_then(std::fs::metadata) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|age| age.as_secs()) + .unwrap_or(0) +} + +pub fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +/// Runs the daemon until it is told to stop or sits idle. Exits the process. +pub fn run() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("daemon runtime"); + let code = runtime.block_on(serve()); + // Agents and shells are dropped with the runtime, which kills them. + drop(runtime); + std::process::exit(code); +} + +async fn serve() -> i32 { + let socket = paths::socket_path(); + if UnixStream::connect(&socket).await.is_ok() { + eprintln!("termy daemon: already running at {}", socket.display()); + return 0; + } + if let Some(parent) = socket.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::remove_file(&socket); + let listener = match UnixListener::bind(&socket) { + Ok(listener) => listener, + Err(error) => { + eprintln!("termy daemon: couldn't listen on {}: {error}", socket.display()); + return 1; + } + }; + restrict_to_user(&socket); + + let daemon = Daemon::new(); + eprintln!("termy daemon: listening on {} (pid {})", socket.display(), std::process::id()); + + let idle = tokio::spawn(watch_idle(daemon.clone())); + loop { + tokio::select! { + accepted = listener.accept() => match accepted { + Ok((stream, _)) => { + tokio::spawn(connection(daemon.clone(), stream)); + } + Err(error) => eprintln!("termy daemon: accept failed: {error}"), + }, + _ = daemon.shutdown.notified() => break, + } + } + idle.abort(); + daemon.agents.kill_all(&daemon); + daemon.shells.close_all(); + let _ = std::fs::remove_file(&socket); + eprintln!("termy daemon: stopped"); + 0 +} + +#[cfg(unix)] +fn restrict_to_user(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); +} + +async fn watch_idle(daemon: Arc) { + let mut idle_since: Option = None; + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + let busy = daemon.hub.count() > 0 || daemon.agents.alive_count() > 0 || daemon.shells.alive_count() > 0; + if busy { + idle_since = None; + continue; + } + let since = *idle_since.get_or_insert_with(Instant::now); + if since.elapsed() >= IDLE_EXIT { + eprintln!("termy daemon: idle, exiting"); + daemon.shutdown.notify_one(); + return; + } + } +} + +async fn connection(daemon: Arc, stream: UnixStream) { + let conn = daemon.next_conn.fetch_add(1, Ordering::Relaxed); + let (read, mut write) = stream.into_split(); + let (sender, mut outbox) = mpsc::unbounded_channel::(); + daemon.hub.add(conn, sender); + + let writer = tokio::spawn(async move { + while let Some(mut frame) = outbox.recv().await { + frame.push('\n'); + if write.write_all(frame.as_bytes()).await.is_err() { + break; + } + } + }); + + // Requests from one window run in order, so its writes to an agent or shell stay in order. + let mut lines = BufReader::new(read).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(frame) = serde_json::from_str::(&line) else { continue }; + let id = frame.get("id").cloned().unwrap_or(Value::Null); + let method = frame.get("method").and_then(Value::as_str).unwrap_or_default().to_owned(); + let params = frame.get("params").cloned().unwrap_or(Value::Null); + let reply = match dispatch(&daemon, conn, &method, params).await { + Ok(result) => json!({ "id": id, "result": result }), + Err(error) => json!({ "id": id, "error": error }), + }; + daemon.hub.send(conn, &reply); + } + + daemon.hub.remove(conn); + daemon.agents.detach_all(conn); + daemon.shells.detach_all(conn); + writer.abort(); +} + +fn param<'a>(params: &'a Value, name: &str) -> Result<&'a Value, String> { + params.get(name).ok_or_else(|| format!("Missing `{name}`")) +} + +fn string(params: &Value, name: &str) -> Result { + param(params, name)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("`{name}` must be a string")) +} + +fn number(params: &Value, name: &str) -> Result { + param(params, name)? + .as_u64() + .ok_or_else(|| format!("`{name}` must be a number")) +} + +async fn dispatch(daemon: &Arc, conn: ConnId, method: &str, params: Value) -> Result { + match method { + "hello" => Ok(json!({ + "protocol": PROTOCOL, + "version": env!("CARGO_PKG_VERSION"), + "pid": std::process::id(), + "exeMtime": daemon.exe_mtime, + "busy": daemon.agents.alive_count() > 0 || daemon.shells.alive_count() > 0, + })), + "status" => Ok(json!({ + "agents": daemon.agents.alive_count(), + "shells": daemon.shells.alive_count(), + "pid": std::process::id(), + })), + "daemon.shutdown" => { + daemon.shutdown.notify_one(); + Ok(Value::Null) + } + "agent.list" => Ok(daemon.agents.list()), + "agent.spawn" => daemon.spawn_agent(¶ms).await, + "agent.attach" => daemon.agents.attach(conn, &string(¶ms, "key")?, number(¶ms, "after")?), + "agent.detach" => { + daemon.agents.detach(conn, &string(¶ms, "key")?); + Ok(Value::Null) + } + "agent.send" => daemon.send_to_agent(conn, &string(¶ms, "key")?, param(¶ms, "message")?.clone()).await, + "agent.ack" => { + daemon.agents.ack(&string(¶ms, "key")?, number(¶ms, "seq")?); + Ok(Value::Null) + } + "agent.kill" => { + daemon.kill_agent(&string(¶ms, "key")?); + Ok(Value::Null) + } + "terminals.snapshot" => Ok(Value::Array(daemon.terminals.snapshot())), + "shell.list" => Ok(daemon.shells.list()), + "shell.open" => daemon.shells.open(&daemon.hub, conn, ¶ms), + "shell.write" => daemon.shells.write(&string(¶ms, "key")?, &string(¶ms, "data")?), + "shell.resize" => daemon.shells.resize( + &string(¶ms, "key")?, + number(¶ms, "cols")? as u16, + number(¶ms, "rows")? as u16, + ), + "shell.detach" => { + daemon.shells.detach(conn, &string(¶ms, "key")?); + Ok(Value::Null) + } + "shell.close" => { + daemon.shells.close(&string(¶ms, "key")?); + Ok(Value::Null) + } + other => Err(format!("Unknown method `{other}`")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Speaks just enough ACP: a prompt reports an update, reads a file through the + /// client, asks for permission, reports the answer and ends the turn. + const FAKE_AGENT: &str = r#" +import sys, json +def send(m): + sys.stdout.write(json.dumps(m) + "\n"); sys.stdout.flush() +def read(): + return json.loads(sys.stdin.readline()) +path = sys.argv[1] +for line in sys.stdin: + m = json.loads(line) + if m.get("method") == "initialize": + send({"jsonrpc": "2.0", "id": m["id"], "result": {"protocolVersion": 1}}) + elif m.get("method") == "session/prompt": + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s", "update": {"step": 1}}}) + send({"jsonrpc": "2.0", "id": "read", "method": "fs/read_text_file", "params": {"sessionId": "s", "path": path}}) + content = read()["result"]["content"] + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s", "update": {"file": content}}}) + send({"jsonrpc": "2.0", "id": "ask", "method": "session/request_permission", "params": {"sessionId": "s"}}) + answer = read()["result"] + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s", "update": {"answer": answer}}}) + send({"jsonrpc": "2.0", "id": m["id"], "result": {"stopReason": "end_turn"}}) +"#; + + async fn next_event(inbox: &mut mpsc::UnboundedReceiver, name: &str) -> Value { + loop { + let frame = tokio::time::timeout(Duration::from_secs(5), inbox.recv()) + .await + .expect("timed out waiting for an event") + .expect("hub closed"); + let frame: Value = serde_json::from_str(&frame).unwrap(); + if frame["event"] == name { + return frame["data"].clone(); + } + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn agent_keeps_working_without_a_window_and_replays_to_the_next_one() { + let dir = std::env::temp_dir().join(format!("termy-daemon-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::env::set_var("TERMY_DATA_DIR", &dir); + let file = dir.join("notes.txt"); + std::fs::write(&file, "hello from disk").unwrap(); + + let daemon = Daemon::new(); + let (first, mut first_inbox) = mpsc::unbounded_channel(); + daemon.hub.add(1, first); + + let spawn = json!({ + "key": "thread-1", + "command": "python3", + "args": ["-c", FAKE_AGENT, file.to_str().unwrap()], + "cwd": dir.to_str().unwrap(), + }); + dispatch(&daemon, 1, "agent.spawn", spawn).await.unwrap(); + dispatch(&daemon, 1, "agent.attach", json!({ "key": "thread-1", "after": 0 })).await.unwrap(); + + let send = |id: u64, method: &str| { + json!({ "key": "thread-1", "message": { "jsonrpc": "2.0", "id": id, "method": method, "params": { "sessionId": "s" } } }) + }; + dispatch(&daemon, 1, "agent.send", send(1, "initialize")).await.unwrap(); + let reply = next_event(&mut first_inbox, "agent.message").await; + assert_eq!(reply["message"]["id"], 1, "the window gets its own id back"); + + dispatch(&daemon, 1, "agent.send", send(1, "session/prompt")).await.unwrap(); + let ask = next_event(&mut first_inbox, "agent.message").await; + assert_eq!(ask["message"]["method"], "session/request_permission"); + + // The window goes away mid-turn, with the agent blocked on a permission prompt. + daemon.hub.remove(1); + daemon.agents.detach_all(1); + let listed = dispatch(&daemon, 2, "agent.list", Value::Null).await.unwrap(); + assert!(listed[0]["turn"].is_object(), "the turn is still running"); + + let (second, mut second_inbox) = mpsc::unbounded_channel(); + daemon.hub.add(2, second); + let attached = dispatch(&daemon, 2, "agent.attach", json!({ "key": "thread-1", "after": 0 })).await.unwrap(); + let updates: Vec<&Value> = attached["entries"] + .as_array() + .unwrap() + .iter() + .map(|entry| &entry["message"]["params"]["update"]) + .collect(); + assert_eq!(updates[0]["step"], 1); + assert_eq!(updates[1]["file"], "hello from disk", "the daemon answered fs/read_text_file"); + assert_eq!(attached["pending"][0]["method"], "session/request_permission"); + assert_eq!(attached["initialize"]["protocolVersion"], 1); + + let answer = json!({ + "key": "thread-1", + "message": { "jsonrpc": "2.0", "id": "ask", "result": { "outcome": "allowed" } }, + }); + dispatch(&daemon, 2, "agent.send", answer).await.unwrap(); + let answered = next_event(&mut second_inbox, "agent.entry").await; + assert_eq!(answered["entry"]["message"]["params"]["update"]["answer"]["outcome"], "allowed"); + let ended = next_event(&mut second_inbox, "agent.entry").await; + assert_eq!(ended["entry"]["message"]["method"], "_termy/turn_end"); + assert_eq!(ended["entry"]["message"]["params"]["stopReason"], "end_turn"); + + let listed = dispatch(&daemon, 2, "agent.list", Value::Null).await.unwrap(); + assert!(listed[0]["turn"].is_null()); + assert_eq!(listed[0]["lastSeq"], 4); + + daemon.kill_agent("thread-1"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_shell_keeps_its_output_for_the_next_window() { + use base64::Engine; + let decode = |data: &Value| { + String::from_utf8_lossy(&base64::engine::general_purpose::STANDARD.decode(data.as_str().unwrap()).unwrap()) + .into_owned() + }; + let daemon = Daemon::new(); + let (first, mut first_inbox) = mpsc::unbounded_channel(); + daemon.hub.add(1, first); + let open = json!({ "key": "tab-1", "cwd": std::env::temp_dir(), "cols": 80, "rows": 24 }); + let opened = dispatch(&daemon, 1, "shell.open", open.clone()).await.unwrap(); + assert_eq!(opened["created"], true); + dispatch(&daemon, 1, "shell.write", json!({ "key": "tab-1", "data": "echo multiplexed-$((6*7))\n" })) + .await + .unwrap(); + let mut seen = String::new(); + while !seen.contains("multiplexed-42") { + seen += &decode(&next_event(&mut first_inbox, "shell.output").await["data"]); + } + + daemon.hub.remove(1); + daemon.shells.detach_all(1); + let (second, _second_inbox) = mpsc::unbounded_channel(); + daemon.hub.add(2, second); + let reopened = dispatch(&daemon, 2, "shell.open", open).await.unwrap(); + assert_eq!(reopened["created"], false, "the same shell, not a new one"); + assert!(decode(&reopened["scrollback"]).contains("multiplexed-42")); + assert_eq!(daemon.shells.alive_count(), 1); + + dispatch(&daemon, 2, "shell.close", json!({ "key": "tab-1" })).await.unwrap(); + assert_eq!(daemon.shells.alive_count(), 0); + } +} diff --git a/src-tauri/src/daemon/shells.rs b/src-tauri/src/daemon/shells.rs new file mode 100644 index 0000000..1699082 --- /dev/null +++ b/src-tauri/src/daemon/shells.rs @@ -0,0 +1,204 @@ +//! Interactive shells for terminal tabs and panels, kept running by the daemon. +//! +//! A window opens a shell by a key it chooses (its tab id). Opening a key that +//! already exists attaches to it instead: the window gets the recent output to +//! replay into xterm.js, then live output. Shells end only when a window closes +//! them or the program exits. + +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::{Arc, Mutex, Weak}, +}; + +use base64::Engine; +use serde_json::{json, Value}; +use crate::pty::{Pty, PtyCommand, PtySize}; + +use super::{number, string, ConnId, Hub}; + +/// Recent output kept per shell, replayed to a window that attaches. +const SCROLLBACK_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Default)] +pub struct Shells { + map: Mutex>>, +} + +struct Shell { + cwd: String, + pty: Mutex>, + inner: Mutex, +} + +#[derive(Default)] +struct ShellInner { + scrollback: VecDeque, + attached: HashSet, + exited: bool, +} + +fn encode(bytes: impl IntoIterator) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes.into_iter().collect::>()) +} + +impl Shells { + fn get(&self, key: &str) -> Result, String> { + self.map + .lock() + .map_err(|_| "Shell registry poisoned".to_owned())? + .get(key) + .cloned() + .ok_or_else(|| "That shell is closed.".to_owned()) + } + + pub fn alive_count(&self) -> usize { + self.map + .lock() + .map(|map| { + map.values() + .filter(|shell| shell.inner.lock().is_ok_and(|inner| !inner.exited)) + .count() + }) + .unwrap_or(0) + } + + pub fn list(&self) -> Value { + let Ok(map) = self.map.lock() else { return Value::Array(Vec::new()) }; + Value::Array( + map.iter() + .map(|(key, shell)| { + let exited = shell.inner.lock().map(|inner| inner.exited).unwrap_or(true); + json!({ "key": key, "cwd": shell.cwd, "exited": exited }) + }) + .collect(), + ) + } + + /// Attaches to the shell under `key`, or starts one in `cwd`. + pub fn open(&self, hub: &Arc, conn: ConnId, params: &Value) -> Result { + let key = string(params, "key")?; + let cols = number(params, "cols")? as u16; + let rows = number(params, "rows")? as u16; + + if let Ok(shell) = self.get(&key) { + let (scrollback, exited) = { + let mut inner = shell.inner.lock().map_err(|_| "Shell poisoned")?; + inner.attached.insert(conn); + (encode(inner.scrollback.iter().copied()), inner.exited) + }; + if !exited { + if let Ok(pty) = shell.pty.lock() { + if let Some(pty) = pty.as_ref() { + // Full-screen programs redraw on a size change; nudge one even at the same size. + let _ = pty.resize(PtySize::new(cols, rows.saturating_sub(1).max(1))); + let _ = pty.resize(PtySize::new(cols, rows)); + } + } + } + return Ok(json!({ "created": false, "scrollback": scrollback, "exited": exited })); + } + + let cwd = string(params, "cwd")?; + let shell = Arc::new(Shell { + cwd: cwd.clone(), + pty: Mutex::new(None), + inner: Mutex::new(ShellInner { + attached: HashSet::from([conn]), + ..ShellInner::default() + }), + }); + + let output_shell: Weak = Arc::downgrade(&shell); + let output_hub = hub.clone(); + let output_key = key.clone(); + let exit_shell: Weak = Arc::downgrade(&shell); + let exit_hub = hub.clone(); + let exit_key = key.clone(); + + let pty = Pty::spawn( + PtyCommand { + working_directory: Some(&cwd), + ..PtyCommand::default() + }, + PtySize::new(cols, rows), + move |bytes| { + let Some(shell) = output_shell.upgrade() else { return }; + let Ok(mut inner) = shell.inner.lock() else { return }; + inner.scrollback.extend(bytes); + let excess = inner.scrollback.len().saturating_sub(SCROLLBACK_BYTES); + inner.scrollback.drain(..excess); + // Sent under the lock, so an attaching window can't miss or repeat a chunk. + output_hub.event( + inner.attached.iter().copied(), + "shell.output", + json!({ "key": output_key, "data": encode(bytes.iter().copied()) }), + ); + }, + move || { + let Some(shell) = exit_shell.upgrade() else { return }; + let Ok(mut inner) = shell.inner.lock() else { return }; + inner.exited = true; + exit_hub.event(inner.attached.iter().copied(), "shell.exit", json!({ "key": exit_key })); + }, + ) + .map_err(|error| format!("Couldn't start a shell: {error}"))?; + + *shell.pty.lock().map_err(|_| "Shell poisoned")? = Some(pty); + self.map.lock().map_err(|_| "Shell registry poisoned")?.insert(key, shell); + Ok(json!({ "created": true, "scrollback": "", "exited": false })) + } + + pub fn write(&self, key: &str, data: &str) -> Result { + let shell = self.get(key)?; + let pty = shell.pty.lock().map_err(|_| "Shell poisoned")?; + let pty = pty.as_ref().ok_or("That shell is closed.")?; + pty.write_owned(data.as_bytes().to_vec()).map_err(|error| error.to_string())?; + Ok(Value::Null) + } + + pub fn resize(&self, key: &str, cols: u16, rows: u16) -> Result { + let shell = self.get(key)?; + let pty = shell.pty.lock().map_err(|_| "Shell poisoned")?; + let pty = pty.as_ref().ok_or("That shell is closed.")?; + pty.resize(PtySize::new(cols, rows)).map_err(|error| error.to_string())?; + Ok(Value::Null) + } + + pub fn detach(&self, conn: ConnId, key: &str) { + if let Ok(shell) = self.get(key) { + if let Ok(mut inner) = shell.inner.lock() { + inner.attached.remove(&conn); + } + } + } + + pub fn detach_all(&self, conn: ConnId) { + let Ok(map) = self.map.lock() else { return }; + for shell in map.values() { + if let Ok(mut inner) = shell.inner.lock() { + inner.attached.remove(&conn); + } + } + } + + /// Dropping the PTY hangs up the shell. + pub fn close(&self, key: &str) { + let removed = self.map.lock().ok().and_then(|mut map| map.remove(key)); + if let Some(shell) = removed { + if let Ok(mut pty) = shell.pty.lock() { + pty.take(); + } + } + } + + pub fn close_all(&self) { + let keys: Vec = self + .map + .lock() + .map(|map| map.keys().cloned().collect()) + .unwrap_or_default(); + for key in keys { + self.close(&key); + } + } +} diff --git a/src-tauri/src/daemon_client.rs b/src-tauri/src/daemon_client.rs new file mode 100644 index 0000000..327dfce --- /dev/null +++ b/src-tauri/src/daemon_client.rs @@ -0,0 +1,261 @@ +//! The app's connection to the background daemon (see `daemon`). +//! +//! The webview calls `daemon_request` with a method and params; daemon events +//! arrive as `daemon://event`. If the daemon isn't running it is started, and +//! if it's from another build it is restarted when nothing is running in it. + +use std::{ + collections::HashMap, + process::Stdio, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; + +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter, State}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::{unix::OwnedWriteHalf, UnixStream}, + sync::oneshot, +}; + +use crate::{ + daemon::{exe_mtime, PROTOCOL}, + paths, +}; + +type Reply = oneshot::Sender>; + +struct Connection { + writer: tokio::sync::Mutex, + waiting: Mutex>, + next_id: AtomicU64, + open: AtomicBool, +} + +impl Connection { + async fn request(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (reply, answer) = oneshot::channel(); + self.waiting + .lock() + .map_err(|_| "Daemon connection poisoned")? + .insert(id, reply); + let mut frame = json!({ "id": id, "method": method, "params": params }).to_string(); + frame.push('\n'); + let written = self.writer.lock().await.write_all(frame.as_bytes()).await; + if let Err(error) = written { + self.waiting.lock().ok().and_then(|mut waiting| waiting.remove(&id)); + return Err(format!("Lost the background service: {error}")); + } + answer + .await + .map_err(|_| "Lost the background service.".to_owned())? + } +} + +/// What the app knows about the daemon it's attached to. +#[derive(Default)] +struct Info { + pid: u64, + /// Started from an older build that was busy, so it was kept rather than restarted. + stale: bool, +} + +pub struct DaemonClient { + app: AppHandle, + connection: tokio::sync::Mutex>>, + info: Mutex, + /// Set after the first connection, so later ones are announced as reconnects. + connected_before: AtomicBool, +} + +impl DaemonClient { + pub fn new(app: AppHandle) -> Self { + Self { + app, + connection: tokio::sync::Mutex::new(None), + info: Mutex::new(Info::default()), + connected_before: AtomicBool::new(false), + } + } + + pub async fn request(&self, method: &str, params: Value) -> Result { + self.connection().await?.request(method, params).await + } + + async fn connection(&self) -> Result, String> { + let mut current = self.connection.lock().await; + if let Some(connection) = current.as_ref().filter(|connection| connection.open.load(Ordering::Relaxed)) { + return Ok(connection.clone()); + } + let connection = self.connect().await?; + *current = Some(connection.clone()); + if self.connected_before.swap(true, Ordering::Relaxed) { + let _ = self.app.emit("daemon://reconnected", ()); + } + Ok(connection) + } + + async fn connect(&self) -> Result, String> { + let connection = self.open_or_start().await?; + let hello = connection + .request("hello", json!({ "protocol": PROTOCOL })) + .await?; + let protocol = hello.get("protocol").and_then(Value::as_u64).unwrap_or(0); + let same_build = hello.get("exeMtime").and_then(Value::as_u64) == Some(exe_mtime()); + let busy = hello.get("busy").and_then(Value::as_bool).unwrap_or(false); + let pid = hello.get("pid").and_then(Value::as_u64).unwrap_or(0); + + if protocol == PROTOCOL && same_build { + self.set_info(pid, false); + return Ok(connection); + } + if protocol == PROTOCOL && busy { + // A rebuilt app with work still running: keep the old daemon rather than kill that work. + self.set_info(pid, true); + return Ok(connection); + } + // Idle, or speaking another protocol: replace it with this build. + let _ = connection.request("daemon.shutdown", Value::Null).await; + for _ in 0..40 { + if !connection.open.load(Ordering::Relaxed) { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + let connection = self.open_or_start().await?; + let hello = connection.request("hello", json!({ "protocol": PROTOCOL })).await?; + if hello.get("protocol").and_then(Value::as_u64) != Some(PROTOCOL) { + return Err("The background service is from an incompatible version of Termy Code.".to_owned()); + } + self.set_info(hello.get("pid").and_then(Value::as_u64).unwrap_or(0), false); + Ok(connection) + } + + fn set_info(&self, pid: u64, stale: bool) { + if let Ok(mut info) = self.info.lock() { + info.pid = pid; + info.stale = stale; + } + } + + async fn open_or_start(&self) -> Result, String> { + let socket = paths::socket_path(); + if let Ok(stream) = UnixStream::connect(&socket).await { + return Ok(self.attach(stream)); + } + start_daemon()?; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(50)).await; + if let Ok(stream) = UnixStream::connect(&socket).await { + return Ok(self.attach(stream)); + } + } + Err(format!( + "Couldn't start the background service. See {}.", + paths::daemon_log_file().display() + )) + } + + fn attach(&self, stream: UnixStream) -> Arc { + let (read, write) = stream.into_split(); + let connection = Arc::new(Connection { + writer: tokio::sync::Mutex::new(write), + waiting: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + open: AtomicBool::new(true), + }); + let reader = connection.clone(); + let app = self.app.clone(); + tauri::async_runtime::spawn(async move { + let mut lines = BufReader::new(read).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(frame) = serde_json::from_str::(&line) else { continue }; + if let Some(id) = frame.get("id").and_then(Value::as_u64) { + let reply = reader.waiting.lock().ok().and_then(|mut waiting| waiting.remove(&id)); + if let Some(reply) = reply { + let result = match frame.get("error") { + Some(error) => Err(error.as_str().unwrap_or("Background service error").to_owned()), + None => Ok(frame.get("result").cloned().unwrap_or(Value::Null)), + }; + let _ = reply.send(result); + } + } else if frame.get("event").is_some() { + let _ = app.emit("daemon://event", frame); + } + } + reader.open.store(false, Ordering::Relaxed); + if let Ok(mut waiting) = reader.waiting.lock() { + waiting.clear(); + } + let _ = app.emit("daemon://disconnected", ()); + }); + connection + } +} + +/// Starts ` --daemon` in its own process group, so it outlives the app +/// and doesn't get the terminal's Ctrl+C when the app runs under `tauri dev`. +fn start_daemon() -> Result<(), String> { + use std::os::unix::process::CommandExt; + + let exe = std::env::current_exe().map_err(|error| error.to_string())?; + let log = paths::daemon_log_file(); + if let Some(parent) = log.parent() { + let _ = std::fs::create_dir_all(parent); + } + let stderr = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log) + .map(Stdio::from) + .unwrap_or_else(|_| Stdio::null()); + std::process::Command::new(exe) + .arg("--daemon") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr) + .process_group(0) + .spawn() + .map(drop) + .map_err(|error| format!("Couldn't start the background service: {error}")) +} + +#[tauri::command] +pub async fn daemon_request(client: State<'_, DaemonClient>, method: String, params: Value) -> Result { + client.request(&method, params).await +} + +/// `{ pid, stale }` for the Settings page, after making sure the daemon is reachable. +#[tauri::command] +pub async fn daemon_info(client: State<'_, DaemonClient>) -> Result { + let status = client.request("status", Value::Null).await?; + let stale = client.info.lock().map(|info| info.stale).unwrap_or(false); + Ok(json!({ + "pid": status.get("pid"), + "agents": status.get("agents"), + "shells": status.get("shells"), + "stale": stale, + })) +} + +/// Stops every background agent and shell, then starts a fresh daemon from this build. +#[tauri::command] +pub async fn daemon_restart(client: State<'_, DaemonClient>) -> Result<(), String> { + let _ = client.request("daemon.shutdown", Value::Null).await; + let mut current = client.connection.lock().await; + if let Some(connection) = current.take() { + for _ in 0..40 { + if !connection.open.load(Ordering::Relaxed) { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + drop(current); + client.request("hello", json!({ "protocol": PROTOCOL })).await.map(drop) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 1159c0d..35022c6 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -28,7 +28,10 @@ const MIGRATIONS: &[&str] = &[r#" updated_at INTEGER NOT NULL ); CREATE INDEX threads_project_updated ON threads(project_id, updated_at DESC); -"#]; +"#, + // How far into the daemon's log for this thread's agent the saved items go. + r#"ALTER TABLE threads ADD COLUMN log_seq INTEGER NOT NULL DEFAULT 0;"#, +]; impl Db { pub fn open(path: &Path) -> rusqlite::Result { @@ -88,6 +91,8 @@ pub struct Thread { items: serde_json::Value, created_at: i64, updated_at: i64, + #[serde(default)] + log_seq: i64, } #[tauri::command] @@ -262,7 +267,7 @@ pub fn db_threads_search(db: State<'_, Db>, query: String) -> Result, id: String) -> Result, String> { db.with(|conn| { conn.query_row( - "SELECT id, project_id, agent_id, acp_session_id, title, items, created_at, updated_at + "SELECT id, project_id, agent_id, acp_session_id, title, items, created_at, updated_at, log_seq FROM threads WHERE id = ?1", [id], |row| { @@ -276,6 +281,7 @@ pub fn db_thread_get(db: State<'_, Db>, id: String) -> Result, St items: serde_json::from_str(&items).unwrap_or(serde_json::Value::Array(vec![])), created_at: row.get(6)?, updated_at: row.get(7)?, + log_seq: row.get(8)?, }) }, ) @@ -287,13 +293,14 @@ pub fn db_thread_get(db: State<'_, Db>, id: String) -> Result, St pub fn db_thread_save(db: State<'_, Db>, thread: Thread) -> Result<(), String> { db.with(|conn| { conn.execute( - "INSERT INTO threads (id, project_id, agent_id, acp_session_id, title, items, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + "INSERT INTO threads (id, project_id, agent_id, acp_session_id, title, items, created_at, updated_at, log_seq) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(id) DO UPDATE SET acp_session_id = excluded.acp_session_id, title = excluded.title, items = excluded.items, - updated_at = excluded.updated_at", + updated_at = excluded.updated_at, + log_seq = excluded.log_seq", params![ thread.id, thread.project_id, @@ -302,7 +309,8 @@ pub fn db_thread_save(db: State<'_, Db>, thread: Thread) -> Result<(), String> { thread.title, thread.items.to_string(), thread.created_at, - thread.updated_at + thread.updated_at, + thread.log_seq ], ) .map(drop) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1db2c81..93a6b23 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,11 @@ -mod agent; mod agent_terminal; +pub mod daemon; +mod daemon_client; mod db; mod mentions; +mod paths; +mod pty; mod shell_env; -mod terminal; mod workspace; use tauri::Manager; @@ -16,34 +18,24 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) - .manage(agent::Agents::default()) - .manage(terminal::Terminals::default()) - .manage(agent_terminal::AgentTerminals::default()) .setup(|app| { let dir = app.path().app_data_dir()?; std::fs::create_dir_all(&dir)?; app.manage(db::Db::open(&dir.join("termy.db"))?); + // Agents and shells live in the background daemon, so they outlive this window. + app.manage(daemon_client::DaemonClient::new(app.handle().clone())); Ok(()) }) .invoke_handler(tauri::generate_handler![ - agent::agent_spawn, - agent::agent_write, - agent::agent_kill, + daemon_client::daemon_request, + daemon_client::daemon_info, + daemon_client::daemon_restart, workspace::fs_read_text, workspace::fs_write_text, workspace::git_branch, workspace::fs_read_image, mentions::project_files, mentions::skills_list, - terminal::term_open, - terminal::term_write, - terminal::term_resize, - terminal::term_close, - agent_terminal::acp_terminal_create, - agent_terminal::acp_terminal_output, - agent_terminal::acp_terminal_wait, - agent_terminal::acp_terminal_kill, - agent_terminal::acp_terminal_release, db::db_projects_list, db::db_project_save, db::db_project_delete, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4ff9868..86a0dfa 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - termy_code_lib::run() + // The same binary is also the background daemon that keeps agents and shells running. + if std::env::args().any(|arg| arg == "--daemon") { + termy_code_lib::daemon::run(); + } else { + termy_code_lib::run(); + } } diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs new file mode 100644 index 0000000..91b59d2 --- /dev/null +++ b/src-tauri/src/paths.rs @@ -0,0 +1,45 @@ +//! Where the app and its background daemon keep files. Both processes must +//! agree without Tauri's path resolver, since the daemon runs without Tauri. + +use std::path::PathBuf; + +/// Matches `identifier` in tauri.conf.json, which is where Tauri puts app data. +const IDENTIFIER: &str = "dev.termy.code"; + +/// macOS limits a Unix socket path to 104 bytes. +const MAX_SOCKET_PATH: usize = 100; + +pub fn data_dir() -> PathBuf { + // Lets tests (and a second dev copy) run without touching the real app's data. + if let Some(dir) = std::env::var_os("TERMY_DATA_DIR") { + return PathBuf::from(dir); + } + let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + if cfg!(target_os = "macos") { + home.join("Library/Application Support").join(IDENTIFIER) + } else { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local/share")) + .join(IDENTIFIER) + } +} + +/// The daemon's socket. Falls back to the temp dir when the data dir path is too long. +pub fn socket_path() -> PathBuf { + let preferred = data_dir().join("daemon.sock"); + if preferred.as_os_str().len() <= MAX_SOCKET_PATH { + return preferred; + } + let user = std::env::var("USER").unwrap_or_else(|_| "user".to_owned()); + std::env::temp_dir().join(format!("termy-code-{user}.sock")) +} + +/// Agent logs: what each agent said while no window was watching. +pub fn agent_log_dir() -> PathBuf { + data_dir().join("daemon").join("agents") +} + +pub fn daemon_log_file() -> PathBuf { + data_dir().join("daemon").join("daemon.log") +} diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs new file mode 100644 index 0000000..245725d --- /dev/null +++ b/src-tauri/src/pty.rs @@ -0,0 +1,344 @@ +//! Alacritty's native PTY transport, with raw output for xterm.js and ACP. +//! A single worker owns the child, drains output before reporting its status, +//! and keeps writes bounded while allowing resize and shutdown under backpressure. + +use std::{ + collections::VecDeque, + io::{self, Read, Write}, + os::unix::process::ExitStatusExt, + path::PathBuf, + sync::{Arc, Mutex}, + thread, +}; + +use alacritty_terminal::{ + event::{OnResize, WindowSize}, + tty::{self, ChildEvent, EventedPty, EventedReadWrite, Options, Shell}, +}; +use polling::{Event, Events, PollMode, Poller}; + +const MAX_INPUT_BYTES: usize = 2 * 1024 * 1024; +const IO_BUDGET: usize = 256 * 1024; + +pub enum TerminalLaunch { + ShellCommand(String), + Program { program: String, args: Vec }, +} + +#[derive(Default)] +pub struct PtyCommand<'a> { + pub launch: Option<&'a TerminalLaunch>, + pub working_directory: Option<&'a str>, + pub environment: &'a [(String, String)], +} + +#[derive(Clone, Copy)] +pub struct PtySize { + cols: u16, + rows: u16, +} + +impl PtySize { + pub fn new(cols: u16, rows: u16) -> Self { + Self { + cols: cols.max(1), + rows: rows.max(1), + } + } + + fn window_size(self) -> WindowSize { + WindowSize { + num_cols: self.cols, + num_lines: self.rows, + cell_width: 0, + cell_height: 0, + } + } +} + +#[derive(Clone, Copy)] +pub struct PtyExitStatus { + pub code: Option, + pub signal: Option, +} + +#[derive(Default)] +struct State { + input: VecDeque>, + input_bytes: usize, + resize: Option, + kill: bool, + exited: bool, + status: Option, +} + +pub struct Pty { + state: Arc>, + poll: Arc, + child_pid: i32, +} + +impl Pty { + pub fn spawn( + command: PtyCommand<'_>, + size: PtySize, + on_output: impl FnMut(&[u8]) + Send + 'static, + on_exit: impl FnOnce() + Send + 'static, + ) -> io::Result { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let shell = match command.launch { + Some(TerminalLaunch::Program { program, args }) => { + Shell::new(program.clone(), args.clone()) + } + Some(TerminalLaunch::ShellCommand(script)) => { + Shell::new(shell, vec!["-lc".into(), script.clone()]) + } + None => Shell::new(shell, vec!["-l".into()]), + }; + let working_directory = command.working_directory.map(PathBuf::from); + if let Some(directory) = &working_directory { + // Alacritty ignores a failed chdir; do not silently run agent commands elsewhere. + if !directory.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Terminal working directory does not exist", + )); + } + } + let mut env = std::collections::HashMap::from([ + ("TERM".into(), "xterm-256color".into()), + ("COLORTERM".into(), "truecolor".into()), + ("TERM_PROGRAM".into(), "termy-code".into()), + ( + "TERM_PROGRAM_VERSION".into(), + env!("CARGO_PKG_VERSION").into(), + ), + ("PATH".into(), crate::shell_env::login_path().into()), + ]); + env.extend(command.environment.iter().cloned()); + let options = Options { + shell: Some(shell), + working_directory, + env, + drain_on_exit: true, + }; + let poll = Arc::new(Poller::new()?); + let mut native = tty::new(&options, size.window_size(), 0)?; + let child_pid = native.child().id() as i32; + // SAFETY: the worker retains both the PTY and poller and deregisters + // before dropping the PTY. Failed startup also deregisters below. + if let Err(error) = unsafe { native.register(&poll, Event::readable(0), PollMode::Level) } { + let _ = native.deregister(&poll); + signal(&native, libc::SIGKILL); + return Err(error); + } + let state = Arc::new(Mutex::new(State::default())); + let worker_state = state.clone(); + let worker_poll = poll.clone(); + // Keep ownership outside the closure until thread creation succeeds. + let slot = Arc::new(Mutex::new(Some(native))); + let worker_slot = slot.clone(); + thread::Builder::new() + .name("termy-code-pty".into()) + .spawn(move || { + let mut native = worker_slot.lock().unwrap().take().unwrap(); + run(&mut native, &worker_poll, &worker_state, on_output); + let _ = native.deregister(&worker_poll); + drop(native); + on_exit(); + }) + .map_err(|error| { + if let Some(mut native) = slot.lock().unwrap().take() { + let _ = native.deregister(&poll); + signal(&native, libc::SIGKILL); + } + error + })?; + Ok(Self { + state, + poll, + child_pid, + }) + } + + pub fn write_owned(&self, bytes: Vec) -> io::Result<()> { + let mut state = self.state.lock().unwrap(); + if state.exited || state.kill { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Terminal is closed", + )); + } + if bytes.len() > MAX_INPUT_BYTES.saturating_sub(state.input_bytes) { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "Terminal input queue is full", + )); + } + if !bytes.is_empty() { + state.input_bytes += bytes.len(); + state.input.push_back(bytes); + } + self.poll.notify() + } + + pub fn resize(&self, size: PtySize) -> io::Result<()> { + self.state.lock().unwrap().resize = Some(size); + self.poll.notify() + } + + pub fn exit_status(&self) -> Option { + self.state.lock().unwrap().status + } + + pub fn kill(&self) -> io::Result<()> { + let mut state = self.state.lock().unwrap(); + if !state.exited && !state.kill { + // Reaping uses the same lock, so this cannot signal a reused PID. + signal_pid(self.child_pid, libc::SIGKILL); + state.kill = true; + } + self.poll.notify() + } +} + +impl Drop for Pty { + fn drop(&mut self) { + // Daemon shutdown may exit before the worker wakes, so signal now. + // The worker remains responsible for reaping and final output. + let _ = self.kill(); + } +} + +fn signal(pty: &tty::Pty, signal: i32) { + signal_pid(pty.child().id() as i32, signal); +} + +fn signal_pid(pid: i32, signal: i32) { + // SAFETY: this worker owns the unreaped child. Alacritty creates a new + // session/process group with this PID. Signal descendants as well as the shell. + unsafe { + libc::kill(-pid, signal); + libc::kill(pid, signal); + } +} + +fn read_output(pty: &mut tty::Pty, on_output: &mut impl FnMut(&[u8])) -> io::Result { + let mut buffer = [0; 32 * 1024]; + let mut total = 0; + while total < IO_BUDGET { + match pty.reader().read(&mut buffer) { + Ok(0) => return Ok(false), + Ok(n) => { + on_output(&buffer[..n]); + total += n; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) if error.raw_os_error() == Some(libc::EIO) => return Ok(false), + Err(error) => return Err(error), + } + } + Ok(true) +} + +fn run( + pty: &mut tty::Pty, + poll: &Arc, + state: &Mutex, + mut on_output: impl FnMut(&[u8]), +) { + let mut events = Events::new(); + let mut pending = Vec::new(); + let mut offset = 0; + let mut readable = true; + let mut killed = false; + loop { + let exited = { + let mut state = state.lock().unwrap(); + if let Some(ChildEvent::Exited(status)) = pty.next_child_event() { + state.status = status.map(|status| PtyExitStatus { + code: status.code(), + signal: status.signal(), + }); + state.exited = true; + state.input.clear(); + state.input_bytes = 0; + } + state.exited + }; + if exited { + // Final output precedes the callback, including quick commands. + let _ = read_output(pty, &mut on_output); + return; + } + let (resize, kill) = { + let mut state = state.lock().unwrap(); + if offset == pending.len() { + pending = state.input.pop_front().unwrap_or_default(); + offset = 0; + } + (state.resize.take(), state.kill) + }; + if !killed && kill { + signal(pty, libc::SIGKILL); + killed = true; + } + if let Some(size) = resize { + pty.on_resize(size.window_size()); + } + if readable { + match read_output(pty, &mut on_output) { + Ok(open) => readable = open, + Err(_) => { + readable = false; + state.lock().unwrap().kill = true; + continue; + } + } + } + if offset < pending.len() { + let end = pending.len().min(offset + IO_BUDGET); + match pty.writer().write(&pending[offset..end]) { + Ok(0) => { + state.lock().unwrap().kill = true; + continue; + } + Ok(n) => { + offset += n; + state.lock().unwrap().input_bytes -= n; + if offset == pending.len() { + continue; + } + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(_) => { + state.lock().unwrap().kill = true; + pending.clear(); + offset = 0; + continue; + } + } + } + let mut interest = Event::none(0); + interest.readable = readable; + interest.writable = offset < pending.len(); + if pty.reregister(poll, interest, PollMode::Level).is_err() { + signal(pty, libc::SIGKILL); + break; + } + events.clear(); + if let Err(error) = poll.wait(&mut events, None) { + if error.kind() != io::ErrorKind::Interrupted { + signal(pty, libc::SIGKILL); + break; + } + } + } + // Dropping Alacritty's PTY reaps the child even on an I/O failure. + state.lock().unwrap().exited = true; +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/pty/tests.rs b/src-tauri/src/pty/tests.rs new file mode 100644 index 0000000..d217e12 --- /dev/null +++ b/src-tauri/src/pty/tests.rs @@ -0,0 +1,158 @@ +use super::{Pty, PtyCommand, PtySize, TerminalLaunch}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; + +#[test] +fn raw_output_environment_and_exit_status_are_ready_when_exit_fires() { + let launch = TerminalLaunch::Program { + program: "/bin/sh".into(), + args: vec![ + "-c".into(), + "printf '\\033[32m%s\\033[0m' \"$TERMY_PTY_TEST\"; exit 7".into(), + ], + }; + let environment = [("TERMY_PTY_TEST".into(), "raw-output".into())]; + let output = Arc::new(Mutex::new(Vec::new())); + let sink = output.clone(); + let (tx, rx) = mpsc::channel(); + let pty = Pty::spawn( + PtyCommand { + launch: Some(&launch), + environment: &environment, + ..Default::default() + }, + PtySize::new(80, 24), + move |bytes| sink.lock().unwrap().extend_from_slice(bytes), + move || { + let _ = tx.send(()); + }, + ) + .unwrap(); + rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(*output.lock().unwrap(), b"\x1b[32mraw-output\x1b[0m"); + let status = pty.exit_status().unwrap(); + assert_eq!(status.code, Some(7)); + assert_eq!(status.signal, None); + pty.kill().expect("killing an exited child is a no-op"); +} + +#[test] +fn resize_and_input_reach_the_child() { + let launch = TerminalLaunch::Program { + program: "/bin/sh".into(), + args: vec![ + "-c".into(), + "stty -echo; printf ''; read line; stty size; printf '<%s>' \"$line\"".into(), + ], + }; + let (tx, rx) = mpsc::channel(); + let (exit_tx, exit_rx) = mpsc::channel(); + let pty = Pty::spawn( + PtyCommand { + launch: Some(&launch), + ..Default::default() + }, + PtySize::new(80, 24), + move |bytes| { + let _ = tx.send(bytes.to_vec()); + }, + move || { + let _ = exit_tx.send(()); + }, + ) + .unwrap(); + let mut output = Vec::new(); + while !String::from_utf8_lossy(&output).contains("") { + output.extend(rx.recv_timeout(Duration::from_secs(5)).unwrap()); + } + pty.resize(PtySize::new(100, 40)).unwrap(); + pty.write_owned(b"hello\n".to_vec()).unwrap(); + exit_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + for bytes in rx.try_iter() { + output.extend(bytes); + } + let text = String::from_utf8_lossy(&output); + assert!(text.contains("40 100"), "{text}"); + assert!(text.contains(""), "{text}"); +} + +#[test] +fn kill_reports_a_signal_and_keeps_output_available() { + let launch = TerminalLaunch::Program { + program: "/bin/sh".into(), + args: vec!["-c".into(), "printf ''; exec sleep 30".into()], + }; + let (tx, rx) = mpsc::channel(); + let (exit_tx, exit_rx) = mpsc::channel(); + let pty = Pty::spawn( + PtyCommand { + launch: Some(&launch), + ..Default::default() + }, + PtySize::new(80, 24), + move |bytes| { + let _ = tx.send(bytes.to_vec()); + }, + move || { + let _ = exit_tx.send(()); + }, + ) + .unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), b""); + pty.kill().unwrap(); + exit_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let status = pty.exit_status().unwrap(); + assert_eq!(status.signal, Some(9)); + assert_eq!(status.code, None); +} + +#[test] +fn backpressure_does_not_block_drop_or_leave_an_ignoring_child_alive() { + let launch = TerminalLaunch::Program { + program: "/bin/sh".into(), + args: vec![ + "-c".into(), + "trap '' HUP; stty -echo; printf ''; exec sleep 30".into(), + ], + }; + let (tx, rx) = mpsc::channel(); + let (exit_tx, exit_rx) = mpsc::channel(); + let pty = Pty::spawn( + PtyCommand { + launch: Some(&launch), + ..Default::default() + }, + PtySize::new(80, 24), + move |bytes| { + let _ = tx.send(bytes.to_vec()); + }, + move || { + let _ = exit_tx.send(()); + }, + ) + .unwrap(); + assert_eq!(rx.recv_timeout(Duration::from_secs(5)).unwrap(), b""); + let error = pty + .write_owned(vec![b'x'; super::MAX_INPUT_BYTES + 1]) + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + pty.write_owned(vec![b'x'; super::MAX_INPUT_BYTES]).unwrap(); + drop(pty); + exit_rx + .recv_timeout(Duration::from_secs(5)) + .expect("drop must reap even a child ignoring SIGHUP"); +} + +#[test] +fn invalid_working_directory_fails_instead_of_running_elsewhere() { + let result = Pty::spawn( + PtyCommand { + working_directory: Some("/dev/null/not-a-directory"), + ..Default::default() + }, + PtySize::new(80, 24), + |_| {}, + || {}, + ); + assert!(matches!(result, Err(error) if error.kind() == std::io::ErrorKind::NotFound)); +} diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs deleted file mode 100644 index c671751..0000000 --- a/src-tauri/src/terminal.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Interactive shells for the terminal panel. Termy owns the PTY and shell -//! launch; the raw byte stream goes to xterm.js over an IPC channel. - -use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicU32, Ordering}, - Mutex, - }, -}; - -use serde::Serialize; -use tauri::{ - ipc::{Channel, InvokeResponseBody}, - AppHandle, Emitter, State, -}; -use termy_core::pty::{Pty, PtyCommand, PtySize}; - -#[derive(Default)] -pub struct Terminals { - next_id: AtomicU32, - open: Mutex>, -} - -#[derive(Clone, Serialize)] -struct Exit { - id: u32, -} - -fn runtime_config() -> termy_core::TerminalRuntimeConfig { - termy_core::load_config_from_default_path() - .map(|loaded| loaded.runtime_config) - .unwrap_or_default() -} - -#[tauri::command] -pub fn term_open( - app: AppHandle, - terminals: State<'_, Terminals>, - cwd: String, - cols: u16, - rows: u16, - output: Channel, -) -> Result { - let id = terminals.next_id.fetch_add(1, Ordering::Relaxed) + 1; - let pty = Pty::spawn( - &runtime_config(), - PtyCommand { - working_directory: Some(&cwd), - ..PtyCommand::default() - }, - PtySize::new(cols, rows), - move |bytes| { - let _ = output.send(InvokeResponseBody::Raw(bytes.to_vec())); - }, - move || { - let _ = app.emit("term://exit", Exit { id }); - }, - ) - .map_err(|error| format!("Couldn't start a shell: {error}"))?; - terminals - .open - .lock() - .map_err(|_| "Terminal registry poisoned")? - .insert(id, pty); - Ok(id) -} - -fn with_pty(terminals: &Terminals, id: u32, f: impl FnOnce(&Pty) -> std::io::Result) -> Result { - let open = terminals.open.lock().map_err(|_| "Terminal registry poisoned")?; - let pty = open.get(&id).ok_or("Terminal is closed")?; - f(pty).map_err(|error| error.to_string()) -} - -#[tauri::command] -pub fn term_write(terminals: State<'_, Terminals>, id: u32, data: String) -> Result<(), String> { - with_pty(&terminals, id, |pty| pty.write_owned(data.into_bytes())) -} - -#[tauri::command] -pub fn term_resize(terminals: State<'_, Terminals>, id: u32, cols: u16, rows: u16) -> Result<(), String> { - with_pty(&terminals, id, |pty| pty.resize(PtySize::new(cols, rows))) -} - -/// Dropping the PTY hangs up the shell. -#[tauri::command] -pub fn term_close(terminals: State<'_, Terminals>, id: u32) -> Result<(), String> { - terminals - .open - .lock() - .map_err(|_| "Terminal registry poisoned")? - .remove(&id); - Ok(()) -} diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 54acd0b..275a3c5 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -12,7 +12,17 @@ pub async fn fs_read_text( line: Option, limit: Option, ) -> Result { - let content = tokio::fs::read_to_string(&path) + read_text(&path, line, limit).await +} + +#[tauri::command] +pub async fn fs_write_text(path: String, content: String) -> Result<(), String> { + write_text(&path, &content).await +} + +/// ACP `fs/read_text_file`: the whole file, or `limit` lines from 1-based `line`. +pub async fn read_text(path: &str, line: Option, limit: Option) -> Result { + let content = tokio::fs::read_to_string(path) .await .map_err(|error| format!("Couldn't read {path}: {error}"))?; @@ -29,14 +39,14 @@ pub async fn fs_read_text( Ok(selected.join("\n")) } -#[tauri::command] -pub async fn fs_write_text(path: String, content: String) -> Result<(), String> { - if let Some(parent) = Path::new(&path).parent() { +/// ACP `fs/write_text_file`, creating missing parent folders. +pub async fn write_text(path: &str, content: &str) -> Result<(), String> { + if let Some(parent) = Path::new(path).parent() { tokio::fs::create_dir_all(parent) .await .map_err(|error| format!("Couldn't create {}: {error}", parent.display()))?; } - tokio::fs::write(&path, content) + tokio::fs::write(path, content) .await .map_err(|error| format!("Couldn't write {path}: {error}")) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8d3bf7a..99e1d54 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,15 +18,21 @@ "height": 900, "minWidth": 960, "minHeight": 600, - "backgroundColor": "#131315", + "backgroundColor": "#00000000", "titleBarStyle": "Overlay", "hiddenTitle": true, - "trafficLightPosition": { "x": 18, "y": 22 } + "trafficLightPosition": { + "x": 18, + "y": 22 + }, + "transparent": true, + "theme": "Dark" } ], "security": { "csp": null - } + }, + "macOSPrivateApi": true }, "bundle": { "active": true, diff --git a/src/components/app/agent-icon.tsx b/src/components/app/agent-icon.tsx index ab72545..ed8be3d 100644 --- a/src/components/app/agent-icon.tsx +++ b/src/components/app/agent-icon.tsx @@ -2,12 +2,13 @@ import { useId } from "react" import type { AgentId } from "@/domain/agents" import { cn } from "@/lib/utils" -type IconProps = { className?: string } +/** `mono` draws brand-colored marks in `currentColor`, for places where color would read as status. */ +type IconProps = { className?: string; mono?: boolean } -const ClaudeIcon = ({ className }: IconProps) => ( +const ClaudeIcon = ({ className, mono }: IconProps) => ( @@ -108,9 +109,16 @@ const ANTIGRAVITY_BLOBS = [ const ANTIGRAVITY_ARCH = "M14.0777 13.984C14.945 14.6345 16.2458 14.2008 15.0533 13.0084C11.476 9.53949 12.2349 0 7.79033 0C3.34579 0 4.10461 9.53949 0.527295 13.0084C-0.773543 14.3092 0.635692 14.6345 1.50293 13.984C4.86344 11.7076 4.64663 7.69664 7.79033 7.69664C10.934 7.69664 10.7172 11.7076 14.0777 13.984Z" -const AntigravityIcon = ({ className }: IconProps) => { +const AntigravityIcon = ({ className, mono }: IconProps) => { // Every icon on the page needs its own mask and filter ids. const id = useId() + if (mono) { + return ( + + + + ) + } return ( @@ -166,7 +174,7 @@ const icons: Record React.ReactNode> = { opencode: OpenCodeIcon, } -export const AgentIcon = ({ agent, className }: { agent: AgentId; className?: string }) => { +export const AgentIcon = ({ agent, className, mono }: { agent: AgentId; className?: string; mono?: boolean }) => { const Icon = icons[agent] - return + return } diff --git a/src/components/app/command-palette.tsx b/src/components/app/command-palette.tsx index 42b53ea..e6b1afe 100644 --- a/src/components/app/command-palette.tsx +++ b/src/components/app/command-palette.tsx @@ -1,5 +1,5 @@ import { useNavigate } from "@tanstack/react-router" -import { FolderPlusIcon, SearchIcon, SquarePenIcon } from "lucide-react" +import { FolderPlusIcon, MagnifyingGlassIcon, PencilSquareIcon } from "@heroicons/react/24/outline" import { type ReactNode, useEffect, useRef, useState } from "react" import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" import type { AgentId } from "@/domain/agents" @@ -104,7 +104,7 @@ export const CommandPalette = ({ open, onOpenChange }: { open: boolean; onOpenCh entries: [ { key: "new-thread", - icon: , + icon: , title: "New thread", meta: "⌘N", run: () => { @@ -139,11 +139,11 @@ export const CommandPalette = ({ open, onOpenChange }: { open: boolean; onOpenCh Search
- + {sections.map((section) => (
- {section.label} + {section.label} {section.entries.map((entry) => { index += 1 const position = index @@ -186,7 +186,7 @@ export const CommandPalette = ({ open, onOpenChange }: { open: boolean; onOpenCh > {entry.icon} - + {entry.detail && ( @@ -195,7 +195,7 @@ export const CommandPalette = ({ open, onOpenChange }: { open: boolean; onOpenCh )} - {entry.meta && {entry.meta}} + {entry.meta && {entry.meta}} ) })} diff --git a/src/components/app/composer.tsx b/src/components/app/composer.tsx index 1ec7c42..ff29a30 100644 --- a/src/components/app/composer.tsx +++ b/src/components/app/composer.tsx @@ -1,18 +1,6 @@ import { getCurrentWebview } from "@tauri-apps/api/webview" -import { - ArrowUpIcon, - AtSignIcon, - CheckIcon, - ChevronDownIcon, - ImageIcon, - type LucideIcon, - PlusIcon, - RotateCwIcon, - ScrollTextIcon, - SquareIcon, - SquareSlashIcon, - XIcon, -} from "lucide-react" +import { ArrowPathIcon, ArrowUpIcon, AtSymbolIcon, BookOpenIcon, CheckIcon, ChevronDownIcon, PhotoIcon, PlusIcon, SlashIcon, XMarkIcon } from "@heroicons/react/24/outline" +import { StopIcon as SolidStopIcon } from "@heroicons/react/16/solid" import { type RefObject, useEffect, useMemo, useRef, useState } from "react" import { Shimmer } from "@/components/shimmer/components/shimmer" import { @@ -31,10 +19,11 @@ import { agents } from "@/domain/agents" import { activeQuery, type Mention, type Skill } from "@/domain/mentions" import { configOption, type ImageAttachment, type Session } from "@/domain/session" import { useRun, useWorkspace } from "@/lib/runtime" +import { useSettings } from "@/lib/settings" import { cn } from "@/lib/utils" import { emptyText, MentionPicker, type PickerRow, pickerRows } from "./mention-picker" import { choicesOf, effortChoicesOf, ModelEffortSelector } from "./model-effort-selector" -import { formatElapsed, Led } from "./primitives" +import { formatElapsed, type HeroIcon, Led } from "./primitives" import { RequestPrompt } from "./request-prompt" const useNow = (active: boolean) => { @@ -47,6 +36,16 @@ const useNow = (active: boolean) => { return now } +/** + * Escape stops the turn only when nothing else wants it: not when a field already + * handled it, a menu, dialog or picker is open (its own Escape closes it first), + * or a terminal has focus (vim and friends need it). + */ +const escapeBelongsElsewhere = (event: KeyboardEvent) => + event.defaultPrevented || + (event.target instanceof Element && event.target.closest(".xterm") !== null) || + document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]') !== null + const StatusStrip = ({ session }: { session: Session }) => { const workspace = useWorkspace() const run = useRun() @@ -59,7 +58,7 @@ const StatusStrip = ({ session }: { session: Session }) => { Working {session.turnStartedAt && ( - {formatElapsed(now - session.turnStartedAt)} + {formatElapsed(now - session.turnStartedAt)} )} Esc to stop @@ -84,7 +83,7 @@ const StatusStrip = ({ session }: { session: Session }) => { onClick={() => void run(workspace.retry(session.id))} className="text-text-2 hover:text-text hover:bg-hover flex h-6 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs" > - + Retry
@@ -129,10 +128,10 @@ const ModeMenu = ({ session }: { session: Session }) => { /* Notra's Claude plus menu, with the items Termy can actually do. */ const MENU_SURFACE_CLASS = - "rounded-2xl bg-[#1e1e21]/97 p-1.5 shadow-[inset_0_1px_0_rgb(255_255_255/0.06),0_0_0_1px_rgb(255_255_255/0.07),0_16px_40px_rgb(0_0_0/0.5)] ring-0 backdrop-blur-xl" + "rounded-2xl bg-popover/97 p-1.5 shadow-popover ring-0 backdrop-blur-xl" const MENU_ITEM_CLASS = - "text-text focus:bg-white/6 focus:text-text not-data-[variant=destructive]:focus:**:text-text data-highlighted:bg-white/6 h-8 cursor-pointer gap-2.5 rounded-lg px-2.5 py-0 text-[13px] leading-5 [&_svg:not([class*='size-'])]:size-4 [&_svg]:text-text-2" + "text-text focus:bg-white/6 focus:text-text not-data-[variant=destructive]:focus:**:text-text data-highlighted:bg-white/6 h-8 cursor-pointer gap-2.5 rounded-lg px-2.5 py-0 text-ui leading-5 [&_svg:not([class*='size-'])]:size-4 [&_svg]:text-text-2" const SUB_TRIGGER_CLASS = cn( MENU_ITEM_CLASS, @@ -150,7 +149,7 @@ const PlusSubmenu = ({ empty, onSelect, }: { - icon: LucideIcon + icon: HeroIcon label: string rows: ReadonlyArray empty: string @@ -220,26 +219,26 @@ const PlusMenu = ({ > {acceptsImages && ( - + Add images ⌘U )} onInsert("@")}> - + Mention a file @ onInsert(`${row.insert} `, row.mention)} /> }) => { side="top" align="end" sideOffset={8} - className="w-[220px] gap-0 rounded-[14px] bg-[#1e1e21]/97 p-1 shadow-[inset_0_1px_0_rgb(255_255_255/0.06),0_0_0_1px_rgb(255_255_255/0.07),0_16px_40px_rgb(0_0_0/0.5)] ring-0 backdrop-blur-xl" + className="w-[220px] gap-0 rounded-[14px] bg-popover/97 p-1 shadow-popover ring-0 backdrop-blur-xl" >
@@ -355,7 +354,7 @@ const Attachments = ({ src={`data:${image.mimeType};base64,${image.data}`} alt={image.name} title={image.name} - className="size-14 rounded-lg object-cover shadow-[0_0_0_1px_rgb(255_255_255/0.08)]" + className="size-14 rounded-lg object-cover shadow-outline" />
))} @@ -392,6 +391,7 @@ export const Composer = ({ session, onSend, placeholder, autoFocus, className }: const [files, setFiles] = useState | null>(null) const [skills, setSkills] = useState | null>(null) const input = useRef(null) + const { sendKey } = useSettings() const working = session?.status === "working" const acceptsImages = session?.supportsImages ?? false const canSend = @@ -442,7 +442,7 @@ export const Composer = ({ session, onSend, placeholder, autoFocus, className }: useEffect(() => { if (!working || !session) return const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") void run(workspace.cancel(session.id)) + if (event.key === "Escape" && !escapeBelongsElsewhere(event)) void run(workspace.cancel(session.id)) } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) @@ -525,9 +525,9 @@ export const Composer = ({ session, onSend, placeholder, autoFocus, className }: return (
@@ -590,7 +590,9 @@ export const Composer = ({ session, onSend, placeholder, autoFocus, className }: return } } - if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + // With ⌘Enter to send, a plain Enter is a newline. + const sends = sendKey === "enter" ? !event.shiftKey : event.metaKey + if (event.key === "Enter" && sends && !event.nativeEvent.isComposing) { event.preventDefault() submit() } @@ -630,7 +632,7 @@ export const Composer = ({ session, onSend, placeholder, autoFocus, className }: onClick={() => session && void run(workspace.cancel(session.id))} className="bg-text flex size-[30px] items-center justify-center rounded-full shadow-[0_1px_2px_rgb(0_0_0/0.4)] transition-transform active:scale-95" > - + ) : ( )}
{content === undefined ? null : !file ? ( -

This file can't be previewed.

+

This file can't be previewed.

) : (
- + { @@ -311,7 +301,7 @@ export const FileTree = ({ aria-label="Clear filter" className="text-text-3 hover:text-text hover:bg-hover flex size-6 shrink-0 items-center justify-center rounded-md" > - + )}
{query.trim() ? ( results.length === 0 ? ( -

No matching files

+

No matching files

) : ( results.map((file, index) => { const slash = file.lastIndexOf("/") @@ -335,7 +325,7 @@ export const FileTree = ({ return ( setPreview(file)}> - + {name} {/* Takes only the room the name leaves. */} diff --git a/src/components/app/fork-menu.tsx b/src/components/app/fork-menu.tsx index 775bc40..f1b7f24 100644 --- a/src/components/app/fork-menu.tsx +++ b/src/components/app/fork-menu.tsx @@ -1,5 +1,5 @@ import { useNavigate } from "@tanstack/react-router" -import { GitForkIcon } from "lucide-react" +import { Square2StackIcon } from "@heroicons/react/24/outline" import { DropdownMenu, DropdownMenuContent, @@ -51,7 +51,7 @@ export const ForkMenu = ({ : "h-[26px] gap-1.5 rounded-[7px] px-2.5 text-xs hover:bg-white/5 aria-expanded:bg-white/5", )} > - + {!compact && "Fork"} diff --git a/src/components/app/markdown.tsx b/src/components/app/markdown.tsx index 6bae562..82e6ce7 100644 --- a/src/components/app/markdown.tsx +++ b/src/components/app/markdown.tsx @@ -19,8 +19,8 @@ const components: Components = { ), ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, strong: ({ children }) => {children}, blockquote: ({ children }) => ( @@ -30,16 +30,16 @@ const components: Components = { className?.startsWith("language-") ? ( {children} ) : ( - {children} + {children} ), pre: ({ children }) => ( -
+    
       {children}
     
), table: ({ children }) => (
- {children}
+ {children}
), th: ({ children }) => {children}, diff --git a/src/components/app/mention-picker.tsx b/src/components/app/mention-picker.tsx index c89b15c..9c318ce 100644 --- a/src/components/app/mention-picker.tsx +++ b/src/components/app/mention-picker.tsx @@ -1,38 +1,28 @@ import type * as acp from "@agentclientprotocol/sdk" -import { - BoxIcon, - FileCodeIcon, - FileIcon, - FileJsonIcon, - FileTerminalIcon, - FileTextIcon, - ImageIcon, - type LucideIcon, - SettingsIcon, - SquareSlashIcon, -} from "lucide-react" +import { CodeBracketIcon, CodeBracketSquareIcon, Cog6ToothIcon, CommandLineIcon, CubeIcon, DocumentIcon, DocumentTextIcon, PhotoIcon, SlashIcon } from "@heroicons/react/24/outline" import { useEffect, useRef } from "react" +import type { HeroIcon } from "./primitives" import type { AgentId } from "@/domain/agents" import { type Mention, type QueryKind, rankByName, rankFiles, rankSkills, type Skill, skillTitle } from "@/domain/mentions" import { cn } from "@/lib/utils" const MAX_ROWS = 50 -const extensionIcons: ReadonlyArray = [ - [/\.(toml|ya?ml|ini|env|lock|config)$|^\.|rc$/, SettingsIcon], - [/\.json5?$/, FileJsonIcon], - [/\.(md|mdx|txt|rst)$/, FileTextIcon], - [/\.(png|jpe?g|gif|webp|svg|ico|icns)$/, ImageIcon], - [/\.(sh|bash|zsh|fish)$/, FileTerminalIcon], - [/\.(tsx?|jsx?|mjs|cjs|rs|go|py|rb|swift|kt|java|c|cc|cpp|h|hpp|cs|css|scss|html|vue|svelte|sql|lua|zig)$/, FileCodeIcon], +const extensionIcons: ReadonlyArray = [ + [/\.(toml|ya?ml|ini|env|lock|config)$|^\.|rc$/, Cog6ToothIcon], + [/\.json5?$/, CodeBracketSquareIcon], + [/\.(md|mdx|txt|rst)$/, DocumentTextIcon], + [/\.(png|jpe?g|gif|webp|svg|ico|icns)$/, PhotoIcon], + [/\.(sh|bash|zsh|fish)$/, CommandLineIcon], + [/\.(tsx?|jsx?|mjs|cjs|rs|go|py|rb|swift|kt|java|c|cc|cpp|h|hpp|cs|css|scss|html|vue|svelte|sql|lua|zig)$/, CodeBracketIcon], ] export const iconFor = (fileName: string) => - extensionIcons.find(([pattern]) => pattern.test(fileName.toLowerCase()))?.[1] ?? FileIcon + extensionIcons.find(([pattern]) => pattern.test(fileName.toLowerCase()))?.[1] ?? DocumentIcon export interface PickerRow { readonly key: string - readonly icon: LucideIcon + readonly icon: HeroIcon readonly title: string readonly detail: string readonly trailing?: string @@ -54,7 +44,7 @@ export const pickerRows = ( if (kind === "command") { return rankByName(commands, query, MAX_ROWS).map((command) => ({ key: command.name, - icon: SquareSlashIcon, + icon: SlashIcon, title: `/${command.name}`, detail: command.description, trailing: command.input?.hint, @@ -77,7 +67,7 @@ export const pickerRows = ( } return rankSkills(skills, query, MAX_ROWS).map((skill) => ({ key: skill.path, - icon: BoxIcon, + icon: CubeIcon, title: skillTitle(skill.name), detail: skill.description, trailing: skill.scope === "project" ? "Project" : "Global", @@ -114,9 +104,9 @@ export const MentionPicker = ({ }, [active]) return ( -
+
{rows.length === 0 ? ( -

{empty}

+

{empty}

) : (
{rows.map((row, index) => { @@ -137,10 +127,10 @@ export const MentionPicker = ({ )} > - + {row.title} - {row.detail} + {row.detail} {row.trailing && {row.trailing}} ) diff --git a/src/components/app/message-actions.tsx b/src/components/app/message-actions.tsx index c83cefb..c51e110 100644 --- a/src/components/app/message-actions.tsx +++ b/src/components/app/message-actions.tsx @@ -1,4 +1,4 @@ -import { CheckIcon, CopyIcon, RefreshCwIcon } from "lucide-react" +import { ArrowPathIcon, CheckIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline" import { type ReactNode, useEffect, useRef, useState } from "react" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" @@ -14,7 +14,7 @@ const ActionButton = ({ label, onClick, children }: { label: string; onClick?: ( > {children} - + {label} @@ -57,11 +57,11 @@ export const MessageActions = ({ return (
void copy()}> - {copied ? : } + {copied ? : } {onRetry && ( - + )} {children} diff --git a/src/components/app/model-effort-selector.tsx b/src/components/app/model-effort-selector.tsx index d5e057b..8308510 100644 --- a/src/components/app/model-effort-selector.tsx +++ b/src/components/app/model-effort-selector.tsx @@ -1,5 +1,5 @@ import type * as acp from "@agentclientprotocol/sdk" -import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react" +import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline" import { type CSSProperties, useEffect, useLayoutEffect, useState } from "react" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Slider } from "@/components/ui/slider" @@ -216,7 +216,7 @@ export const ModelEffortSelector = ({
setView("models")} - className="text-text mx-auto flex h-7 items-center gap-1 rounded-full px-3 text-[13px] transition-colors hover:bg-white/6 focus-visible:ring-2 focus-visible:ring-amber/35" + className="text-text mx-auto flex h-7 items-center gap-1 rounded-full px-3 text-ui transition-colors hover:bg-white/6 focus-visible:ring-2 focus-visible:ring-amber/35" > {selectedModel?.name ?? "Choose model"} ) : ( - + {selectedEffort && } )} @@ -352,7 +352,7 @@ export const ModelEffortSelector = ({ if (hasEfforts) setView("effort") else setOpen(false) }} - className="text-text flex h-9 shrink-0 items-center justify-between gap-3 rounded-xl px-3 text-start text-[13px] transition-colors hover:bg-white/6 focus-visible:ring-2 focus-visible:ring-amber/35" + className="text-text flex h-9 shrink-0 items-center justify-between gap-3 rounded-xl px-3 text-start text-ui transition-colors hover:bg-white/6 focus-visible:ring-2 focus-visible:ring-amber/35" > {choice.name} {choice.value === model && } diff --git a/src/components/app/panel-terminals.tsx b/src/components/app/panel-terminals.tsx index 289b8d8..ac8dc6d 100644 --- a/src/components/app/panel-terminals.tsx +++ b/src/components/app/panel-terminals.tsx @@ -1,22 +1,58 @@ import { useNavigate } from "@tanstack/react-router" -import { PlusIcon, SquareArrowOutUpRightIcon, XIcon } from "lucide-react" -import { useState } from "react" +import { ArrowTopRightOnSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline" +import { Schema } from "effect" +import { useEffect, useState } from "react" import { useRun, useWorkspace } from "@/lib/runtime" import { cn } from "@/lib/utils" import { TerminalView } from "./terminal-view" -interface Shell { - readonly id: number - readonly title: string | null +const Shell = Schema.Struct({ id: Schema.Number, title: Schema.NullOr(Schema.String) }) +type Shell = typeof Shell.Type +const Saved = Schema.Struct({ shells: Schema.NonEmptyArray(Shell), current: Schema.Number }) +const decodeSaved = Schema.decodeUnknownOption(Schema.fromJsonString(Saved)) + +const storageKey = (threadId: string) => `termy.panelShells.${threadId}` + +/** The thread's panel shells from last time; they're still running in the daemon. */ +const restore = (threadId: string): typeof Saved.Type => { + try { + const saved = decodeSaved(localStorage.getItem(storageKey(threadId))) + if (saved._tag === "Some") return saved.value + } catch { + // Storage blocked: start with one shell. + } + return { shells: [{ id: 1, title: null }], current: 1 } } -/** Several shells in the side panel, each kept alive while another one shows. */ -export const PanelTerminals = ({ cwd, name, active }: { cwd: string; name: string; active: boolean }) => { +/** Panel shells live in the daemon under `:panel:`. */ +const shellKey = (threadId: string, id: number) => `${threadId}:panel:${id}` + +/** Several shells in the side panel, each kept alive while another one shows, and after the app quits. */ +export const PanelTerminals = ({ + threadId, + cwd, + name, + active, +}: { + threadId: string + cwd: string + name: string + active: boolean +}) => { const workspace = useWorkspace() const run = useRun() const navigate = useNavigate() - const [shells, setShells] = useState>([{ id: 1, title: null }]) - const [current, setCurrent] = useState(1) + const [initial] = useState(() => restore(threadId)) + const [shells, setShells] = useState>(initial.shells) + const [current, setCurrent] = useState(initial.current) + + useEffect(() => { + try { + localStorage.setItem(storageKey(threadId), JSON.stringify({ shells, current })) + } catch { + // The shells keep running; they just won't be listed after a restart. + } + }, [threadId, shells, current]) const add = () => { const id = Math.max(0, ...shells.map((shell) => shell.id)) + 1 @@ -25,6 +61,7 @@ export const PanelTerminals = ({ cwd, name, active }: { cwd: string; name: strin } const remove = (id: number) => { + void run(workspace.closeShell(shellKey(threadId, id))) const index = shells.findIndex((shell) => shell.id === id) const rest = shells.filter((shell) => shell.id !== id) if (rest.length === 0) { @@ -66,7 +103,7 @@ export const PanelTerminals = ({ cwd, name, active }: { cwd: string; name: strin shell.id !== current && "opacity-0 group-hover/shell:opacity-100", )} > - +
))} @@ -86,12 +123,13 @@ export const PanelTerminals = ({ cwd, name, active }: { cwd: string; name: strin onClick={() => void openInTab()} className="text-text-3 hover:text-text hover:bg-hover flex size-6 shrink-0 items-center justify-center rounded-md" > - +
{shells.map((shell) => (
diff --git a/src/components/app/primitives.tsx b/src/components/app/primitives.tsx index 867f10e..e8f4d82 100644 --- a/src/components/app/primitives.tsx +++ b/src/components/app/primitives.tsx @@ -1,5 +1,9 @@ +import type { PlusIcon } from "@heroicons/react/24/outline" import { cn } from "@/lib/utils" +/** Any Heroicon; they all share one component type. */ +export type HeroIcon = typeof PlusIcon + /** The amber LED. The one place the accent means "live". */ export const Led = ({ className }: { className?: string }) => ( @@ -26,11 +30,11 @@ export const EffortMeter = ({ level, of, size = "sm" }: { level: number; of: num } export const Kbd = ({ children }: { children: React.ReactNode }) => ( - {children} + {children} ) export const Diffstat = ({ additions, deletions }: { additions: number; deletions: number }) => ( - + {additions > 0 && +{additions}} {deletions > 0 && -{deletions}} diff --git a/src/components/app/request-prompt.tsx b/src/components/app/request-prompt.tsx index 95b1f0c..651ec7e 100644 --- a/src/components/app/request-prompt.tsx +++ b/src/components/app/request-prompt.tsx @@ -1,5 +1,5 @@ import type * as acp from "@agentclientprotocol/sdk" -import { CheckIcon, ListChecksIcon, MessageCircleQuestionIcon, ShieldIcon } from "lucide-react" +import { CheckIcon, ClipboardDocumentListIcon, QuestionMarkCircleIcon, ShieldCheckIcon } from "@heroicons/react/24/outline" import { type ReactNode, useState } from "react" import type { PendingRequest, Session } from "@/domain/session" import { useRun, useWorkspace } from "@/lib/runtime" @@ -16,7 +16,7 @@ const Frame = ({ icon, title, detail, children }: { icon: ReactNode; title: stri
{icon}
- {title} + {title} {detail}
@@ -63,10 +63,10 @@ const Permission = ({ session, request }: { session: Session; request: Extract

permissionOrder[a.kind] - permissionOrder[b.kind]) return ( } + icon={} title="Allow this action?" detail={ - + {request.toolCall.title ?? "The agent wants to run a tool"} } @@ -104,11 +104,11 @@ const Question = ({ session, request }: { session: Session; request: Extract} title={request.title ?? "The agent has a question"}> + } title={request.title ?? "The agent has a question"}>

{request.questions.map((question) => (
- {question.prompt} + {question.prompt}
{question.options.map((option) => { const active = selected[question.id]?.includes(option.id) ?? false @@ -171,11 +171,11 @@ const PlanApproval = ({ const run = useRun() return ( } + icon={} title={request.name ? `Approve plan: ${request.name}` : "Approve this plan?"} detail={request.overview && {request.overview}} > -
+
diff --git a/src/components/app/review-pane.tsx b/src/components/app/review-pane.tsx index 3d9f0ab..2b159df 100644 --- a/src/components/app/review-pane.tsx +++ b/src/components/app/review-pane.tsx @@ -1,22 +1,24 @@ import { MultiFileDiff } from "@pierre/diffs/react" -import { XIcon } from "lucide-react" +import { XMarkIcon } from "@heroicons/react/24/outline" import type { CSSProperties, ReactNode } from "react" import { type FileChange, totals } from "@/domain/changes" import { relativePath } from "@/domain/timeline" +import { useSettings } from "@/lib/settings" import { Diffstat } from "./primitives" import { ResizeHandle, usePanelWidth } from "./resize-handle" /** Pierre's dark theme, seated on the window's instrument black. */ export const diffStyle = { "--diffs-bg": "var(--bg)", - "--diffs-font-family": "'Geist Mono Variable', ui-monospace, monospace", - "--diffs-header-font-family": "'Geist Variable', system-ui, sans-serif", - "--diffs-font-size": "11.5px", - "--diffs-line-height": "20px", + "--diffs-font-family": "var(--code-font)", + "--diffs-header-font-family": "var(--ui-font)", + "--diffs-font-size": "var(--code-font-size)", + "--diffs-line-height": "calc(var(--code-font-size) * 1.7)", } as CSSProperties const FileDiff = ({ change, cwd }: { change: FileChange; cwd: string }) => { const name = relativePath(change.path, cwd) + const { diffLayout } = useSettings() return ( { options={{ theme: "pierre-dark", themeType: "dark", - diffStyle: "unified", + diffStyle: diffLayout, overflow: "wrap", lineDiffType: "word", stickyHeader: true, @@ -38,7 +40,7 @@ const FileDiff = ({ change, cwd }: { change: FileChange; cwd: string }) => { const ChangeList = ({ changes, cwd }: { changes: ReadonlyArray; cwd: string }) => (
{changes.length === 0 ? ( -

Files the agent edits in this thread show up here.

+

Files the agent edits in this thread show up here.

) : ( changes.map((change) => ) )} @@ -67,7 +69,7 @@ export const ReviewPane = ({