A safe, simple, extensible, and fast agent harness
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

App install process (#22)

* Configure `cargo binstall` for TUI and server

Setup `cargo binstall` support for installing the TUI and the server.
Initially users will need to use the `--git` flag to install since the
required packages are not yet published to crates.io.

To make the install process as ergonomic as possible both `ein-tui` and
`ein-server` will be installed with the metapackage `ein`. The `ein`
package contains two `bin` configuration options that are just thin
wrappers over the existing TUI program and the server program.

* Add distribution instructions to the README

authored by

Mason Stallmo and committed by
GitHub
6926aa29 f49c74aa

+868 -395
+300
.github/workflows/release.yml
··· 1 + # This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist 2 + # 3 + # Copyright 2022-2024, axodotdev 4 + # SPDX-License-Identifier: MIT or Apache-2.0 5 + # 6 + # CI that: 7 + # 8 + # * checks for a Git Tag that looks like a release 9 + # * builds artifacts with dist (archives, installers, hashes) 10 + # * uploads those artifacts to temporary workflow zip 11 + # * on success, uploads the artifacts to a GitHub Release 12 + # 13 + # Note that the GitHub Release will be created with a generated 14 + # title/body based on your changelogs. 15 + 16 + name: Release 17 + permissions: 18 + "contents": "write" 19 + 20 + # This task will run whenever you push a git tag that looks like a version 21 + # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 22 + # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 23 + # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 24 + # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 25 + # 26 + # If PACKAGE_NAME is specified, then the announcement will be for that 27 + # package (erroring out if it doesn't have the given version or isn't dist-able). 28 + # 29 + # If PACKAGE_NAME isn't specified, then the announcement will be for all 30 + # (dist-able) packages in the workspace with that version (this mode is 31 + # intended for workspaces with only one dist-able package, or with all dist-able 32 + # packages versioned/released in lockstep). 33 + # 34 + # If you push multiple tags at once, separate instances of this workflow will 35 + # spin up, creating an independent announcement for each one. However, GitHub 36 + # will hard limit this to 3 tags per commit, as it will assume more tags is a 37 + # mistake. 38 + # 39 + # If there's a prerelease-style suffix to the version, then the release(s) 40 + # will be marked as a prerelease. 41 + on: 42 + pull_request: 43 + push: 44 + tags: 45 + - '**[0-9]+.[0-9]+.[0-9]+*' 46 + 47 + jobs: 48 + # Run 'dist plan' (or host) to determine what tasks we need to do 49 + plan: 50 + runs-on: "ubuntu-22.04" 51 + outputs: 52 + val: ${{ steps.plan.outputs.manifest }} 53 + tag: ${{ !github.event.pull_request && github.ref_name || '' }} 54 + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 55 + publishing: ${{ !github.event.pull_request }} 56 + env: 57 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 + steps: 59 + - uses: actions/checkout@v6 60 + with: 61 + persist-credentials: false 62 + submodules: recursive 63 + - name: Install dist 64 + # we specify bash to get pipefail; it guards against the `curl` command 65 + # failing. otherwise `sh` won't catch that `curl` returned non-0 66 + shell: bash 67 + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" 68 + - name: Cache dist 69 + uses: actions/upload-artifact@v6 70 + with: 71 + name: cargo-dist-cache 72 + path: ~/.cargo/bin/dist 73 + # sure would be cool if github gave us proper conditionals... 74 + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 75 + # functionality based on whether this is a pull_request, and whether it's from a fork. 76 + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 77 + # but also really annoying to build CI around when it needs secrets to work right.) 78 + - id: plan 79 + run: | 80 + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 81 + echo "dist ran successfully" 82 + cat plan-dist-manifest.json 83 + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 84 + - name: "Upload dist-manifest.json" 85 + uses: actions/upload-artifact@v6 86 + with: 87 + name: artifacts-plan-dist-manifest 88 + path: plan-dist-manifest.json 89 + 90 + # Build and packages all the platform-specific things 91 + build-local-artifacts: 92 + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 93 + # Let the initial task tell us to not run (currently very blunt) 94 + needs: 95 + - plan 96 + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 97 + strategy: 98 + fail-fast: false 99 + # Target platforms/runners are computed by dist in create-release. 100 + # Each member of the matrix has the following arguments: 101 + # 102 + # - runner: the github runner 103 + # - dist-args: cli flags to pass to dist 104 + # - install-dist: expression to run to install dist on the runner 105 + # 106 + # Typically there will be: 107 + # - 1 "global" task that builds universal installers 108 + # - N "local" tasks that build each platform's binaries and platform-specific installers 109 + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 110 + runs-on: ${{ matrix.runner }} 111 + container: ${{ matrix.container && matrix.container.image || null }} 112 + env: 113 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 114 + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 115 + steps: 116 + - name: enable windows longpaths 117 + run: | 118 + git config --global core.longpaths true 119 + - uses: actions/checkout@v6 120 + with: 121 + persist-credentials: false 122 + submodules: recursive 123 + - name: Install Rust non-interactively if not already installed 124 + if: ${{ matrix.container }} 125 + run: | 126 + if ! command -v cargo > /dev/null 2>&1; then 127 + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 128 + echo "$HOME/.cargo/bin" >> $GITHUB_PATH 129 + fi 130 + - name: Install dist 131 + run: ${{ matrix.install_dist.run }} 132 + # Get the dist-manifest 133 + - name: Fetch local artifacts 134 + uses: actions/download-artifact@v7 135 + with: 136 + pattern: artifacts-* 137 + path: target/distrib/ 138 + merge-multiple: true 139 + - name: Install dependencies 140 + run: | 141 + ${{ matrix.packages_install }} 142 + - name: Install protoc 143 + uses: arduino/setup-protoc@v3 144 + with: 145 + repo-token: ${{ secrets.GITHUB_TOKEN }} 146 + - name: Build artifacts 147 + run: | 148 + # Actually do builds and make zips and whatnot 149 + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 150 + echo "dist ran successfully" 151 + - id: cargo-dist 152 + name: Post-build 153 + # We force bash here just because github makes it really hard to get values up 154 + # to "real" actions without writing to env-vars, and writing to env-vars has 155 + # inconsistent syntax between shell and powershell. 156 + shell: bash 157 + run: | 158 + # Parse out what we just built and upload it to scratch storage 159 + echo "paths<<EOF" >> "$GITHUB_OUTPUT" 160 + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" 161 + echo "EOF" >> "$GITHUB_OUTPUT" 162 + 163 + cp dist-manifest.json "$BUILD_MANIFEST_NAME" 164 + - name: "Upload artifacts" 165 + uses: actions/upload-artifact@v6 166 + with: 167 + name: artifacts-build-local-${{ join(matrix.targets, '_') }} 168 + path: | 169 + ${{ steps.cargo-dist.outputs.paths }} 170 + ${{ env.BUILD_MANIFEST_NAME }} 171 + 172 + # Build and package all the platform-agnostic(ish) things 173 + build-global-artifacts: 174 + needs: 175 + - plan 176 + - build-local-artifacts 177 + runs-on: "ubuntu-22.04" 178 + env: 179 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 180 + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 181 + steps: 182 + - uses: actions/checkout@v6 183 + with: 184 + persist-credentials: false 185 + submodules: recursive 186 + - name: Install cached dist 187 + uses: actions/download-artifact@v7 188 + with: 189 + name: cargo-dist-cache 190 + path: ~/.cargo/bin/ 191 + - run: chmod +x ~/.cargo/bin/dist 192 + # Get all the local artifacts for the global tasks to use (for e.g. checksums) 193 + - name: Fetch local artifacts 194 + uses: actions/download-artifact@v7 195 + with: 196 + pattern: artifacts-* 197 + path: target/distrib/ 198 + merge-multiple: true 199 + - id: cargo-dist 200 + shell: bash 201 + run: | 202 + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 203 + echo "dist ran successfully" 204 + 205 + # Parse out what we just built and upload it to scratch storage 206 + echo "paths<<EOF" >> "$GITHUB_OUTPUT" 207 + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 208 + echo "EOF" >> "$GITHUB_OUTPUT" 209 + 210 + cp dist-manifest.json "$BUILD_MANIFEST_NAME" 211 + - name: "Upload artifacts" 212 + uses: actions/upload-artifact@v6 213 + with: 214 + name: artifacts-build-global 215 + path: | 216 + ${{ steps.cargo-dist.outputs.paths }} 217 + ${{ env.BUILD_MANIFEST_NAME }} 218 + # Determines if we should publish/announce 219 + host: 220 + needs: 221 + - plan 222 + - build-local-artifacts 223 + - build-global-artifacts 224 + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) 225 + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 226 + env: 227 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 228 + runs-on: "ubuntu-22.04" 229 + outputs: 230 + val: ${{ steps.host.outputs.manifest }} 231 + steps: 232 + - uses: actions/checkout@v6 233 + with: 234 + persist-credentials: false 235 + submodules: recursive 236 + - name: Install cached dist 237 + uses: actions/download-artifact@v7 238 + with: 239 + name: cargo-dist-cache 240 + path: ~/.cargo/bin/ 241 + - run: chmod +x ~/.cargo/bin/dist 242 + # Fetch artifacts from scratch-storage 243 + - name: Fetch artifacts 244 + uses: actions/download-artifact@v7 245 + with: 246 + pattern: artifacts-* 247 + path: target/distrib/ 248 + merge-multiple: true 249 + - id: host 250 + shell: bash 251 + run: | 252 + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 253 + echo "artifacts uploaded and released successfully" 254 + cat dist-manifest.json 255 + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 256 + - name: "Upload dist-manifest.json" 257 + uses: actions/upload-artifact@v6 258 + with: 259 + # Overwrite the previous copy 260 + name: artifacts-dist-manifest 261 + path: dist-manifest.json 262 + # Create a GitHub Release while uploading all files to it 263 + - name: "Download GitHub Artifacts" 264 + uses: actions/download-artifact@v7 265 + with: 266 + pattern: artifacts-* 267 + path: artifacts 268 + merge-multiple: true 269 + - name: Cleanup 270 + run: | 271 + # Remove the granular manifests 272 + rm -f artifacts/*-dist-manifest.json 273 + - name: Create GitHub Release 274 + env: 275 + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 276 + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 277 + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 278 + RELEASE_COMMIT: "${{ github.sha }}" 279 + run: | 280 + # Write and read notes from a file to avoid quoting breaking things 281 + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 282 + 283 + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 284 + 285 + announce: 286 + needs: 287 + - plan 288 + - host 289 + # use "always() && ..." to allow us to wait for all publish jobs while 290 + # still allowing individual publish jobs to skip themselves (for prereleases). 291 + # "host" however must run to completion, no skipping allowed! 292 + if: ${{ always() && needs.host.result == 'success' }} 293 + runs-on: "ubuntu-22.04" 294 + env: 295 + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 296 + steps: 297 + - uses: actions/checkout@v6 298 + with: 299 + persist-credentials: false 300 + submodules: recursive
+11
Cargo.lock
··· 902 902 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 903 903 904 904 [[package]] 905 + name = "ein" 906 + version = "0.1.0" 907 + dependencies = [ 908 + "anyhow", 909 + "clap", 910 + "ein-server", 911 + "ein-tui", 912 + "tokio", 913 + ] 914 + 915 + [[package]] 905 916 name = "ein-agent" 906 917 version = "0.1.0" 907 918 dependencies = [
+21
Cargo.toml
··· 1 1 [workspace] 2 2 members = [ 3 + "crates/ein", 3 4 "crates/ein-server", 4 5 "crates/ein-tui", 5 6 "crates/ein-proto", ··· 8 9 "packages/*", 9 10 ] 10 11 default-members = [ 12 + "crates/ein", 11 13 "crates/ein-server", 12 14 "crates/ein-tui", 13 15 "crates/ein-proto", ··· 21 23 edition = "2024" 22 24 authors = ["Mason Stallmo"] 23 25 license = "Apache-2.0" 26 + repository = "https://github.com/mstallmo/ein" 27 + homepage = "https://github.com/mstallmo/ein" 28 + 29 + [workspace.metadata.dist] 30 + cargo-dist-version = "0.31.0" 31 + ci = "github" 32 + installers = ["shell", "powershell"] 33 + targets = [ 34 + "aarch64-apple-darwin", 35 + "aarch64-unknown-linux-gnu", 36 + "x86_64-apple-darwin", 37 + "x86_64-unknown-linux-gnu", 38 + "x86_64-pc-windows-msvc", 39 + ] 40 + pr-run-mode = "plan" 41 + # Only distribute the meta-package; individual crates are excluded from releases. 42 + members = ["crates/ein"] 43 + # Allow manual edits to the generated workflow (we add a protoc install step). 44 + allow-dirty = ["ci"] 24 45 25 46 [workspace.dependencies] 26 47 anyhow = "1"
+50
README.md
··· 14 14 └─────────────────────────────┘ └──────────────────────────────┘ 15 15 ``` 16 16 17 + ## Installation 18 + 19 + Install pre-built binaries with [cargo binstall](https://github.com/cargo-bins/cargo-binstall): 20 + 21 + ```bash 22 + cargo install cargo-binstall 23 + cargo binstall --git https://github.com/mstallmo/ein ein 24 + ``` 25 + 26 + This installs both `ein-tui` (terminal UI) and `ein-server` (gRPC agent server). You can also install them individually: 27 + 28 + ```bash 29 + cargo binstall --git https://github.com/mstallmo/ein ein-tui 30 + cargo binstall --git https://github.com/mstallmo/ein ein-server 31 + ``` 32 + 33 + Or download archives directly from [GitHub Releases](https://github.com/mstallmo/ein/releases). 34 + 35 + > **Note:** WASM plugins are required for the server to function and are not included in 36 + > binary releases. After installing, build and install plugins from source: 37 + > ```bash 38 + > git clone https://github.com/mstallmo/ein && cd ein 39 + > rustup target add wasm32-wasip2 40 + > ./scripts/build_install_plugins.sh 41 + > ``` 42 + 17 43 ## Getting Started 18 44 19 45 ### Prerequisites ··· 300 326 | `persistence.rs` | `SessionStore` — SQLite-backed session storage; create, save, and load message history | 301 327 | `tools.rs` | `ToolRegistry` + `WasmTool` — loads and calls WASM plugins | 302 328 | `syscalls.rs` | Host functions exposed to WASM tool plugins (spawn, log, …) | 329 + 330 + ## Releasing 331 + 332 + Releases are fully automated via CI using [cargo-dist](https://axodotdev.github.io/cargo-dist/). Only the `crates/ein` meta-package is distributed — it includes both the `ein-tui` and `ein-server` binaries. 333 + 334 + **1. Bump the version** 335 + 336 + Edit `version` in `[workspace.package]` in `Cargo.toml`. All crates inherit this value. 337 + 338 + **2. Commit and tag** 339 + 340 + ```bash 341 + git commit -am "chore: bump version to vX.Y.Z" 342 + git tag vX.Y.Z 343 + git push origin main --tags 344 + ``` 345 + 346 + **3. CI publishes the release** 347 + 348 + Pushing a tag matching `vX.Y.Z` triggers `.github/workflows/release.yml`, which builds multi-platform binaries (macOS arm64/x86, Linux arm64/x86, Windows x86) and publishes a GitHub Release with archives and checksums. 349 + 350 + Once the release is live, `cargo binstall --git https://github.com/mstallmo/ein ein` will resolve the new binaries automatically — no crates.io publish required. 351 + 352 + > **Note:** `release.yml` contains a manually-added `protoc` install step. If you ever regenerate the workflow with `cargo dist generate`, preserve that step — it is required for the proto compilation during the build. 303 353 304 354 ## License 305 355
+6
crates/ein-server/Cargo.toml
··· 4 4 edition.workspace = true 5 5 authors.workspace = true 6 6 license.workspace = true 7 + repository.workspace = true 8 + homepage.workspace = true 9 + 10 + [lib] 11 + name = "ein_server" 12 + path = "src/lib.rs" 7 13 8 14 [[bin]] 9 15 name = "ein-server"
+70
crates/ein-server/src/lib.rs
··· 1 + // SPDX-License-Identifier: Apache-2.0 2 + // Copyright 2026 Mason Stallmo 3 + 4 + //! Ein server library. 5 + //! 6 + //! Exposes [`run`] so both the standalone `ein-server` binary and the `ein` 7 + //! meta-package binary can share the same entry-point without duplicating code. 8 + 9 + mod grpc; 10 + mod model_client; 11 + mod persistence; 12 + mod tools; 13 + 14 + use ein_proto::ein::agent_server::AgentServer as AgentServiceServer; 15 + use grpc::AgentServer; 16 + use std::path::PathBuf; 17 + use tonic::transport::Server; 18 + 19 + /// Top-level runtime configuration for the Ein server. 20 + #[derive(Debug, Clone)] 21 + pub struct EinConfig { 22 + /// Directory from which tool `.wasm` plugin files are loaded. 23 + pub plugin_dir: PathBuf, 24 + /// Directory from which model client `.wasm` plugin files are loaded. 25 + pub model_client_dir: PathBuf, 26 + /// Path to the SQLite session database. 27 + pub db_path: PathBuf, 28 + } 29 + 30 + impl Default for EinConfig { 31 + fn default() -> Self { 32 + let ein_dir = dirs::home_dir() 33 + .expect("Failed to load EinConfig, Missing home directory") 34 + .join(".ein"); 35 + 36 + // Use the local debug output directory during development so plugins 37 + // don't need to be installed after every rebuild. 38 + let (plugin_dir, model_client_dir) = if cfg!(debug_assertions) { 39 + let debug = PathBuf::from("./target/wasm32-wasip2/debug"); 40 + (debug.clone(), debug) 41 + } else { 42 + ( 43 + ein_dir.join("plugins").join("tools"), 44 + ein_dir.join("plugins").join("model_clients"), 45 + ) 46 + }; 47 + 48 + Self { 49 + plugin_dir, 50 + model_client_dir, 51 + db_path: ein_dir.join("sessions.db"), 52 + } 53 + } 54 + } 55 + 56 + /// Start the Ein gRPC server and block until it exits. 57 + pub async fn run(port: u16) -> anyhow::Result<()> { 58 + let addr = format!("0.0.0.0:{port}").parse()?; 59 + 60 + let server = AgentServer::new().await?; 61 + 62 + println!("ein-server listening on {addr}"); 63 + 64 + Server::builder() 65 + .add_service(AgentServiceServer::new(server)) 66 + .serve(addr) 67 + .await?; 68 + 69 + Ok(()) 70 + }
+1 -59
crates/ein-server/src/main.rs
··· 24 24 //! model client plugins from `~/.ein/plugins/model_clients/`. 25 25 //! Run `./scripts/build_install_plugins.sh` to compile and install them. 26 26 27 - mod grpc; 28 - mod model_client; 29 - mod persistence; 30 - mod tools; 31 - 32 27 use clap::Parser; 33 - use ein_proto::ein::agent_server::AgentServer as AgentServiceServer; 34 - use grpc::AgentServer; 35 - use std::path::PathBuf; 36 - use tonic::transport::Server; 37 - 38 - /// Top-level runtime configuration for the Ein server. 39 - #[derive(Debug, Clone)] 40 - pub struct EinConfig { 41 - /// Directory from which tool `.wasm` plugin files are loaded. 42 - pub plugin_dir: PathBuf, 43 - /// Directory from which model client `.wasm` plugin files are loaded. 44 - pub model_client_dir: PathBuf, 45 - /// Path to the SQLite session database. 46 - pub db_path: PathBuf, 47 - } 48 - 49 - impl Default for EinConfig { 50 - fn default() -> Self { 51 - let ein_dir = dirs::home_dir() 52 - .expect("Failed to load EinConfig, Missing home directory") 53 - .join(".ein"); 54 - 55 - // Use the local debug output directory during development so plugins 56 - // don't need to be installed after every rebuild. 57 - let (plugin_dir, model_client_dir) = if cfg!(debug_assertions) { 58 - let debug = PathBuf::from("./target/wasm32-wasip2/debug"); 59 - (debug.clone(), debug) 60 - } else { 61 - ( 62 - ein_dir.join("plugins").join("tools"), 63 - ein_dir.join("plugins").join("model_clients"), 64 - ) 65 - }; 66 - 67 - Self { 68 - plugin_dir, 69 - model_client_dir, 70 - db_path: ein_dir.join("sessions.db"), 71 - } 72 - } 73 - } 74 28 75 29 #[derive(Parser)] 76 30 #[command(author, version, about)] ··· 82 36 83 37 #[tokio::main] 84 38 async fn main() -> anyhow::Result<()> { 85 - let args = Args::parse(); 86 - let addr = format!("0.0.0.0:{}", args.port).parse()?; 87 - 88 - let server = AgentServer::new().await?; 89 - 90 - println!("ein-server listening on {addr}"); 91 - 92 - Server::builder() 93 - .add_service(AgentServiceServer::new(server)) 94 - .serve(addr) 95 - .await?; 96 - 97 - Ok(()) 39 + ein_server::run(Args::parse().port).await 98 40 }
+6
crates/ein-tui/Cargo.toml
··· 4 4 edition.workspace = true 5 5 authors.workspace = true 6 6 license.workspace = true 7 + repository.workspace = true 8 + homepage.workspace = true 9 + 10 + [lib] 11 + name = "ein_tui" 12 + path = "src/lib.rs" 7 13 8 14 [[bin]] 9 15 name = "ein-tui"
+349
crates/ein-tui/src/lib.rs
··· 1 + // SPDX-License-Identifier: Apache-2.0 2 + // Copyright 2026 Mason Stallmo 3 + 4 + //! Ein TUI library. 5 + //! 6 + //! Exposes [`run`] so both the standalone `ein-tui` binary and the `ein` 7 + //! meta-package binary can share the same entry-point without duplicating code. 8 + 9 + mod app; 10 + mod config; 11 + mod connection; 12 + mod input; 13 + mod render; 14 + 15 + use crate::app::{App, AppEvent, ConnectionStatus, CwdState, DisplayMessage, SessionPickerState}; 16 + use crate::config::load_or_create_config; 17 + use crate::connection::{ 18 + connection_manager, delete_session, spawn_config_watcher, to_proto_session_config, 19 + }; 20 + use crate::input::{KeyAction, handle_key_event, handle_server_event}; 21 + use crate::render::render; 22 + use crossterm::{ 23 + event::{Event, EventStream, KeyEventKind}, 24 + execute, 25 + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 26 + }; 27 + use ein_proto::ein::{SessionConfig, UserInput, user_input}; 28 + use ratatui::{Terminal, backend::CrosstermBackend}; 29 + use tokio::sync::mpsc; 30 + use tokio_stream::StreamExt; 31 + use tracing::info; 32 + 33 + // --------------------------------------------------------------------------- 34 + // CLI arguments 35 + // --------------------------------------------------------------------------- 36 + 37 + #[derive(clap::Parser)] 38 + #[command(about = "Ein terminal UI")] 39 + pub struct Args { 40 + /// gRPC server address 41 + #[arg(default_value = "http://localhost:50051")] 42 + server_addr: String, 43 + 44 + /// Write debug logs to ~/.ein/tui.log 45 + #[arg(long)] 46 + debug: bool, 47 + } 48 + 49 + // --------------------------------------------------------------------------- 50 + // Helpers 51 + // --------------------------------------------------------------------------- 52 + 53 + /// Derives a short model name for the status bar from the client config. 54 + /// 55 + /// Strips the vendor prefix (e.g. "anthropic/claude-haiku-4.5" → "claude-haiku-4.5"). 56 + /// Falls back to a placeholder when no model is configured. 57 + pub fn model_display_from_config(cfg: &config::ClientConfig) -> String { 58 + let model_full = cfg 59 + .plugin_configs 60 + .get(&cfg.model_client_name) 61 + .and_then(|pc| pc.params.get("model")) 62 + .and_then(|v| v.as_str()) 63 + .map(str::to_owned) 64 + .unwrap_or_else(|| "unknown".to_string()); 65 + model_full 66 + .split_once('/') 67 + .map(|(_, m)| m.to_string()) 68 + .unwrap_or(model_full) 69 + } 70 + 71 + // --------------------------------------------------------------------------- 72 + // Entry point 73 + // --------------------------------------------------------------------------- 74 + 75 + /// Run the Ein TUI. Parses CLI arguments and blocks until the user exits. 76 + pub async fn run(args: Args) -> anyhow::Result<()> { 77 + // Initialize the file-based tracing subscriber when --debug is passed. 78 + // Must happen before enable_raw_mode() takes over the terminal. 79 + // The guard is held for the lifetime of run() to flush the non-blocking writer. 80 + let _tracing_guard = if args.debug { 81 + let log_dir = dirs::home_dir().unwrap_or_default().join(".ein"); 82 + let file_appender = tracing_appender::rolling::never(&log_dir, "tui.log"); 83 + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); 84 + tracing_subscriber::fmt() 85 + .with_writer(non_blocking) 86 + .with_ansi(false) 87 + .with_target(false) 88 + .init(); 89 + Some(guard) 90 + } else { 91 + None 92 + }; 93 + 94 + info!(server_addr = %args.server_addr, "ein-tui starting"); 95 + 96 + // Load (or create) the client config before opening the gRPC session. 97 + let cfg = load_or_create_config()?; 98 + 99 + // Derive a short model name for the status bar by stripping the vendor 100 + // prefix (e.g. "anthropic/claude-haiku-4.5" → "claude-haiku-4.5"). 101 + let model_display = model_display_from_config(&cfg); 102 + 103 + // Capture the cwd for the "New Session" CWD modal and the welcome header. 104 + let cwd_str = std::env::current_dir() 105 + .ok() 106 + .map(|p| p.display().to_string()); 107 + let cwd_display = cwd_str.clone().unwrap_or_else(|| "unknown".to_string()); 108 + 109 + let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(64); 110 + 111 + // Watch ~/.ein/config.json for changes and send ConfigChanged events. 112 + spawn_config_watcher(event_tx.clone()); 113 + 114 + // Cache for the chosen SessionConfig; shared with the connection manager so 115 + // reconnects reuse the same config without reshowing the session picker. 116 + let session_config_cache: std::sync::Arc<tokio::sync::Mutex<Option<SessionConfig>>> = 117 + std::sync::Arc::new(tokio::sync::Mutex::new(None)); 118 + 119 + // Shared notify used by /new to skip the connection manager's 3 s retry delay. 120 + let reconnect_notify = std::sync::Arc::new(tokio::sync::Notify::new()); 121 + 122 + // Spawn the connection manager immediately — the session picker is shown 123 + // as part of the first connection handshake, not before it. 124 + tokio::spawn(connection_manager( 125 + args.server_addr.clone(), 126 + event_tx.clone(), 127 + session_config_cache.clone(), 128 + reconnect_notify.clone(), 129 + )); 130 + 131 + // Configure the terminal for raw / alternate-screen rendering. 132 + enable_raw_mode()?; 133 + let mut stdout = std::io::stdout(); 134 + execute!(stdout, EnterAlternateScreen)?; 135 + let backend = CrosstermBackend::new(stdout); 136 + let mut terminal = Terminal::new(backend)?; 137 + 138 + let mut app = App::new(model_display, cwd_str, cwd_display, cfg.clone()); 139 + let mut term_events = EventStream::new(); 140 + // Ticker drives the thinking spinner; only app.tick is incremented when 141 + // the agent is busy, so the timer is cheap when idle. 142 + let mut ticker = tokio::time::interval(std::time::Duration::from_millis(80)); 143 + 144 + loop { 145 + terminal.draw(|f| render(&app, f))?; 146 + 147 + tokio::select! { 148 + _ = ticker.tick() => { 149 + if app.agent_busy || matches!(app.connection_status, ConnectionStatus::Connecting) { 150 + app.tick = app.tick.wrapping_add(1); 151 + } 152 + } 153 + 154 + Some(Ok(event)) = term_events.next() => { 155 + let Event::Key(key) = event else { continue }; 156 + if key.kind != KeyEventKind::Press { continue; } 157 + 158 + match handle_key_event(&mut app, key).await { 159 + KeyAction::Quit => break, 160 + KeyAction::OpenConfig(path) => { 161 + let editor = 162 + std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); 163 + disable_raw_mode()?; 164 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 165 + let _ = std::process::Command::new(&editor).arg(&path).status(); 166 + enable_raw_mode()?; 167 + execute!(terminal.backend_mut(), EnterAlternateScreen)?; 168 + terminal.clear()?; 169 + } 170 + KeyAction::NewSession => { 171 + // Build a fresh config from the current ~/.ein/config.json — the 172 + // same source "New Session" in the picker uses — rather than 173 + // recycling the cached SessionConfig from the old session. 174 + let base = to_proto_session_config(&app.current_cfg, String::new()); 175 + 176 + // Drop the sender → closes the gRPC request stream → server 177 + // receives EOF and closes the response stream → try_connect returns. 178 + app.prompt_tx = None; 179 + app.connection_status = ConnectionStatus::Connecting; 180 + app.session_id = None; 181 + app.agent_busy = false; 182 + app.connection_error = None; 183 + 184 + // Clear the conversation display, keeping the welcome banner. 185 + app.messages.retain(|m| matches!(m, DisplayMessage::Header { .. })); 186 + app.scroll_offset = 0; 187 + app.auto_scroll = true; 188 + 189 + if let Some(cwd) = app.cwd.clone() { 190 + // Show the CWD modal — identical to the "New Session" picker 191 + // path. A bridge task receives the final config from the modal 192 + // and updates the cache before signalling the connection manager. 193 + let (tx, rx) = tokio::sync::oneshot::channel::<SessionConfig>(); 194 + app.pending_cwd_prompt = Some(CwdState { 195 + cwd, 196 + base_config: base, 197 + session_tx: tx, 198 + }); 199 + let cache = session_config_cache.clone(); 200 + let notify = reconnect_notify.clone(); 201 + tokio::spawn(async move { 202 + if let Ok(cfg) = rx.await { 203 + *cache.lock().await = Some(cfg); 204 + notify.notify_one(); 205 + } 206 + }); 207 + } else { 208 + // No CWD to ask about — update the cache and reconnect now. 209 + *session_config_cache.lock().await = Some(base); 210 + reconnect_notify.notify_one(); 211 + } 212 + } 213 + KeyAction::OpenSessionPicker => { 214 + app.prompt_tx = None; 215 + app.connection_status = ConnectionStatus::Connecting; 216 + app.session_id = None; 217 + app.agent_busy = false; 218 + app.connection_error = None; 219 + app.messages.retain(|m| matches!(m, DisplayMessage::Header { .. })); 220 + app.scroll_offset = 0; 221 + app.auto_scroll = true; 222 + // Clear the cache so try_connect shows the session picker on reconnect. 223 + *session_config_cache.lock().await = None; 224 + reconnect_notify.notify_one(); 225 + } 226 + KeyAction::DeleteSession(session_id) => { 227 + let addr = args.server_addr.clone(); 228 + let tx = event_tx.clone(); 229 + tokio::spawn(async move { 230 + if delete_session(&addr, session_id.clone()).await.is_ok() { 231 + let _ = tx.send(AppEvent::SessionDeleted(session_id)).await; 232 + } 233 + }); 234 + } 235 + KeyAction::Continue => {} 236 + } 237 + } 238 + 239 + Some(app_event) = event_rx.recv() => { 240 + match app_event { 241 + AppEvent::Server(event) => handle_server_event(&mut app, event), 242 + AppEvent::Connected(sender) => { 243 + info!("connected to server"); 244 + app.prompt_tx = Some(sender); 245 + app.connection_status = ConnectionStatus::Connected; 246 + app.cumulative_tokens = 0; 247 + app.connection_error = None; 248 + } 249 + AppEvent::Disconnected(msg) => { 250 + info!(error = ?msg, "disconnected from server"); 251 + app.connection_error = msg; 252 + app.prompt_tx = None; 253 + app.connection_status = ConnectionStatus::Connecting; 254 + app.agent_busy = false; 255 + app.auto_scroll = true; 256 + app.session_id = None; 257 + } 258 + AppEvent::ConfigChanged(new_cfg) => { 259 + app.current_cfg = new_cfg.clone(); 260 + app.model_display = model_display_from_config(&new_cfg); 261 + info!(model = %app.model_display, "config reloaded"); 262 + if let Some(tx) = &app.prompt_tx { 263 + let _ = tx 264 + .send(UserInput { 265 + input: Some(user_input::Input::ConfigUpdate( 266 + // session_id is ignored by the server on ConfigUpdate 267 + to_proto_session_config(&new_cfg, String::new()), 268 + )), 269 + }) 270 + .await; 271 + } 272 + } 273 + AppEvent::SessionsLoaded(sessions, session_tx) => { 274 + app.pending_session_picker = Some(SessionPickerState { 275 + sessions, 276 + selected: 0, 277 + session_tx, 278 + }); 279 + } 280 + AppEvent::SessionDeleted(id) => { 281 + if let Some(picker) = &mut app.pending_session_picker { 282 + picker.sessions.retain(|s| s.id != id); 283 + // Clamp selection: index 0 is always "New Session". 284 + let max_idx = picker.sessions.len(); 285 + if picker.selected > max_idx { 286 + picker.selected = max_idx; 287 + } 288 + } 289 + } 290 + } 291 + } 292 + } 293 + } 294 + 295 + // Restore the terminal to its original state before exiting. 296 + disable_raw_mode()?; 297 + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 298 + terminal.show_cursor()?; 299 + 300 + Ok(()) 301 + } 302 + 303 + // --------------------------------------------------------------------------- 304 + // Tests 305 + // --------------------------------------------------------------------------- 306 + 307 + #[cfg(test)] 308 + mod tests { 309 + use super::*; 310 + use crate::config::{ClientConfig, PluginConfig}; 311 + use std::collections::HashMap; 312 + 313 + fn config_with_model(model: &str) -> ClientConfig { 314 + let mut params = HashMap::new(); 315 + params.insert("model".to_string(), serde_json::json!(model)); 316 + let mut plugin_configs = HashMap::new(); 317 + plugin_configs.insert( 318 + "ein_openrouter".to_string(), 319 + PluginConfig { 320 + allowed_paths: vec![], 321 + allowed_hosts: vec![], 322 + params, 323 + }, 324 + ); 325 + ClientConfig { 326 + model_client_name: "ein_openrouter".to_string(), 327 + plugin_configs, 328 + ..Default::default() 329 + } 330 + } 331 + 332 + #[test] 333 + fn model_display_strips_vendor_prefix() { 334 + let cfg = config_with_model("anthropic/claude-sonnet-4-5"); 335 + assert_eq!(model_display_from_config(&cfg), "claude-sonnet-4-5"); 336 + } 337 + 338 + #[test] 339 + fn model_display_no_model_returns_unknown() { 340 + let cfg = ClientConfig::default(); 341 + assert_eq!(model_display_from_config(&cfg), "unknown"); 342 + } 343 + 344 + #[test] 345 + fn model_display_no_prefix_passthrough() { 346 + let cfg = config_with_model("llama3"); 347 + assert_eq!(model_display_from_config(&cfg), "llama3"); 348 + } 349 + }
+3 -336
crates/ein-tui/src/main.rs
··· 1 1 // SPDX-License-Identifier: Apache-2.0 2 2 // Copyright 2026 Mason Stallmo 3 3 4 - mod app; 5 - mod config; 6 - mod connection; 7 - mod input; 8 - mod render; 9 - 10 - use crate::app::{App, AppEvent, ConnectionStatus, CwdState, DisplayMessage, SessionPickerState}; 11 - use crate::config::load_or_create_config; 12 - use crate::connection::{ 13 - connection_manager, delete_session, spawn_config_watcher, to_proto_session_config, 14 - }; 15 - use crate::input::{KeyAction, handle_key_event, handle_server_event}; 16 - use crate::render::render; 17 - use crossterm::{ 18 - event::{Event, EventStream, KeyEventKind}, 19 - execute, 20 - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 21 - }; 22 - use ein_proto::ein::{SessionConfig, UserInput, user_input}; 23 - use ratatui::{Terminal, backend::CrosstermBackend}; 24 - use tokio::sync::mpsc; 25 - use tokio_stream::StreamExt; 26 - use tracing::info; 27 - 28 - // --------------------------------------------------------------------------- 29 - // CLI arguments 30 - // --------------------------------------------------------------------------- 31 - 32 - #[derive(clap::Parser)] 33 - #[command(about = "Ein terminal UI")] 34 - struct Args { 35 - /// gRPC server address 36 - #[arg(default_value = "http://localhost:50051")] 37 - server_addr: String, 38 - 39 - /// Write debug logs to ~/.ein/tui.log 40 - #[arg(long)] 41 - debug: bool, 42 - } 43 - 44 - // --------------------------------------------------------------------------- 45 - // Entry point 46 - // --------------------------------------------------------------------------- 47 - 48 - /// Derives a short model name for the status bar from the client config. 49 - /// 50 - /// Strips the vendor prefix (e.g. "anthropic/claude-haiku-4.5" → "claude-haiku-4.5"). 51 - /// Falls back to a placeholder when no model is configured. 52 - fn model_display_from_config(cfg: &config::ClientConfig) -> String { 53 - let model_full = cfg 54 - .plugin_configs 55 - .get(&cfg.model_client_name) 56 - .and_then(|pc| pc.params.get("model")) 57 - .and_then(|v| v.as_str()) 58 - .map(str::to_owned) 59 - .unwrap_or_else(|| "unknown".to_string()); 60 - model_full 61 - .split_once('/') 62 - .map(|(_, m)| m.to_string()) 63 - .unwrap_or(model_full) 64 - } 4 + use clap::Parser; 5 + use ein_tui::Args; 65 6 66 7 #[tokio::main] 67 8 async fn main() -> anyhow::Result<()> { 68 - use clap::Parser; 69 - let args = Args::parse(); 70 - 71 - // Initialize the file-based tracing subscriber when --debug is passed. 72 - // Must happen before enable_raw_mode() takes over the terminal. 73 - // The guard is held for the lifetime of main() to flush the non-blocking writer. 74 - let _tracing_guard = if args.debug { 75 - let log_dir = dirs::home_dir().unwrap_or_default().join(".ein"); 76 - let file_appender = tracing_appender::rolling::never(&log_dir, "tui.log"); 77 - let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); 78 - tracing_subscriber::fmt() 79 - .with_writer(non_blocking) 80 - .with_ansi(false) 81 - .with_target(false) 82 - .init(); 83 - Some(guard) 84 - } else { 85 - None 86 - }; 87 - 88 - info!(server_addr = %args.server_addr, "ein-tui starting"); 89 - 90 - // Load (or create) the client config before opening the gRPC session. 91 - let cfg = load_or_create_config()?; 92 - 93 - // Derive a short model name for the status bar by stripping the vendor 94 - // prefix (e.g. "anthropic/claude-haiku-4.5" → "claude-haiku-4.5"). 95 - let model_display = model_display_from_config(&cfg); 96 - 97 - // Capture the cwd for the "New Session" CWD modal and the welcome header. 98 - let cwd_str = std::env::current_dir() 99 - .ok() 100 - .map(|p| p.display().to_string()); 101 - let cwd_display = cwd_str.clone().unwrap_or_else(|| "unknown".to_string()); 102 - 103 - let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(64); 104 - 105 - // Watch ~/.ein/config.json for changes and send ConfigChanged events. 106 - spawn_config_watcher(event_tx.clone()); 107 - 108 - // Cache for the chosen SessionConfig; shared with the connection manager so 109 - // reconnects reuse the same config without reshowing the session picker. 110 - let session_config_cache: std::sync::Arc<tokio::sync::Mutex<Option<SessionConfig>>> = 111 - std::sync::Arc::new(tokio::sync::Mutex::new(None)); 112 - 113 - // Shared notify used by /new to skip the connection manager's 3 s retry delay. 114 - let reconnect_notify = std::sync::Arc::new(tokio::sync::Notify::new()); 115 - 116 - // Spawn the connection manager immediately — the session picker is shown 117 - // as part of the first connection handshake, not before it. 118 - tokio::spawn(connection_manager( 119 - args.server_addr.clone(), 120 - event_tx.clone(), 121 - session_config_cache.clone(), 122 - reconnect_notify.clone(), 123 - )); 124 - 125 - // Configure the terminal for raw / alternate-screen rendering. 126 - enable_raw_mode()?; 127 - let mut stdout = std::io::stdout(); 128 - execute!(stdout, EnterAlternateScreen)?; 129 - let backend = CrosstermBackend::new(stdout); 130 - let mut terminal = Terminal::new(backend)?; 131 - 132 - let mut app = App::new(model_display, cwd_str, cwd_display, cfg.clone()); 133 - let mut term_events = EventStream::new(); 134 - // Ticker drives the thinking spinner; only app.tick is incremented when 135 - // the agent is busy, so the timer is cheap when idle. 136 - let mut ticker = tokio::time::interval(std::time::Duration::from_millis(80)); 137 - 138 - loop { 139 - terminal.draw(|f| render(&app, f))?; 140 - 141 - tokio::select! { 142 - _ = ticker.tick() => { 143 - if app.agent_busy || matches!(app.connection_status, ConnectionStatus::Connecting) { 144 - app.tick = app.tick.wrapping_add(1); 145 - } 146 - } 147 - 148 - Some(Ok(event)) = term_events.next() => { 149 - let Event::Key(key) = event else { continue }; 150 - if key.kind != KeyEventKind::Press { continue; } 151 - 152 - match handle_key_event(&mut app, key).await { 153 - KeyAction::Quit => break, 154 - KeyAction::OpenConfig(path) => { 155 - let editor = 156 - std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); 157 - disable_raw_mode()?; 158 - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 159 - let _ = std::process::Command::new(&editor).arg(&path).status(); 160 - enable_raw_mode()?; 161 - execute!(terminal.backend_mut(), EnterAlternateScreen)?; 162 - terminal.clear()?; 163 - } 164 - KeyAction::NewSession => { 165 - // Build a fresh config from the current ~/.ein/config.json — the 166 - // same source "New Session" in the picker uses — rather than 167 - // recycling the cached SessionConfig from the old session. 168 - let base = to_proto_session_config(&app.current_cfg, String::new()); 169 - 170 - // Drop the sender → closes the gRPC request stream → server 171 - // receives EOF and closes the response stream → try_connect returns. 172 - app.prompt_tx = None; 173 - app.connection_status = ConnectionStatus::Connecting; 174 - app.session_id = None; 175 - app.agent_busy = false; 176 - app.connection_error = None; 177 - 178 - // Clear the conversation display, keeping the welcome banner. 179 - app.messages.retain(|m| matches!(m, DisplayMessage::Header { .. })); 180 - app.scroll_offset = 0; 181 - app.auto_scroll = true; 182 - 183 - if let Some(cwd) = app.cwd.clone() { 184 - // Show the CWD modal — identical to the "New Session" picker 185 - // path. A bridge task receives the final config from the modal 186 - // and updates the cache before signalling the connection manager. 187 - let (tx, rx) = tokio::sync::oneshot::channel::<SessionConfig>(); 188 - app.pending_cwd_prompt = Some(CwdState { 189 - cwd, 190 - base_config: base, 191 - session_tx: tx, 192 - }); 193 - let cache = session_config_cache.clone(); 194 - let notify = reconnect_notify.clone(); 195 - tokio::spawn(async move { 196 - if let Ok(cfg) = rx.await { 197 - *cache.lock().await = Some(cfg); 198 - notify.notify_one(); 199 - } 200 - }); 201 - } else { 202 - // No CWD to ask about — update the cache and reconnect now. 203 - *session_config_cache.lock().await = Some(base); 204 - reconnect_notify.notify_one(); 205 - } 206 - } 207 - KeyAction::OpenSessionPicker => { 208 - app.prompt_tx = None; 209 - app.connection_status = ConnectionStatus::Connecting; 210 - app.session_id = None; 211 - app.agent_busy = false; 212 - app.connection_error = None; 213 - app.messages.retain(|m| matches!(m, DisplayMessage::Header { .. })); 214 - app.scroll_offset = 0; 215 - app.auto_scroll = true; 216 - // Clear the cache so try_connect shows the session picker on reconnect. 217 - *session_config_cache.lock().await = None; 218 - reconnect_notify.notify_one(); 219 - } 220 - KeyAction::DeleteSession(session_id) => { 221 - let addr = args.server_addr.clone(); 222 - let tx = event_tx.clone(); 223 - tokio::spawn(async move { 224 - if delete_session(&addr, session_id.clone()).await.is_ok() { 225 - let _ = tx.send(AppEvent::SessionDeleted(session_id)).await; 226 - } 227 - }); 228 - } 229 - KeyAction::Continue => {} 230 - } 231 - } 232 - 233 - Some(app_event) = event_rx.recv() => { 234 - match app_event { 235 - AppEvent::Server(event) => handle_server_event(&mut app, event), 236 - AppEvent::Connected(sender) => { 237 - info!("connected to server"); 238 - app.prompt_tx = Some(sender); 239 - app.connection_status = ConnectionStatus::Connected; 240 - app.cumulative_tokens = 0; 241 - app.connection_error = None; 242 - } 243 - AppEvent::Disconnected(msg) => { 244 - info!(error = ?msg, "disconnected from server"); 245 - app.connection_error = msg; 246 - app.prompt_tx = None; 247 - app.connection_status = ConnectionStatus::Connecting; 248 - app.agent_busy = false; 249 - app.auto_scroll = true; 250 - app.session_id = None; 251 - } 252 - AppEvent::ConfigChanged(new_cfg) => { 253 - app.current_cfg = new_cfg.clone(); 254 - app.model_display = model_display_from_config(&new_cfg); 255 - info!(model = %app.model_display, "config reloaded"); 256 - if let Some(tx) = &app.prompt_tx { 257 - let _ = tx 258 - .send(UserInput { 259 - input: Some(user_input::Input::ConfigUpdate( 260 - // session_id is ignored by the server on ConfigUpdate 261 - to_proto_session_config(&new_cfg, String::new()), 262 - )), 263 - }) 264 - .await; 265 - } 266 - } 267 - AppEvent::SessionsLoaded(sessions, session_tx) => { 268 - app.pending_session_picker = Some(SessionPickerState { 269 - sessions, 270 - selected: 0, 271 - session_tx, 272 - }); 273 - } 274 - AppEvent::SessionDeleted(id) => { 275 - if let Some(picker) = &mut app.pending_session_picker { 276 - picker.sessions.retain(|s| s.id != id); 277 - // Clamp selection: index 0 is always "New Session". 278 - let max_idx = picker.sessions.len(); 279 - if picker.selected > max_idx { 280 - picker.selected = max_idx; 281 - } 282 - } 283 - } 284 - } 285 - } 286 - } 287 - } 288 - 289 - // Restore the terminal to its original state before exiting. 290 - disable_raw_mode()?; 291 - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; 292 - terminal.show_cursor()?; 293 - 294 - Ok(()) 295 - } 296 - 297 - // --------------------------------------------------------------------------- 298 - // Tests 299 - // --------------------------------------------------------------------------- 300 - 301 - #[cfg(test)] 302 - mod tests { 303 - use super::*; 304 - use crate::config::{ClientConfig, PluginConfig}; 305 - use std::collections::HashMap; 306 - 307 - fn config_with_model(model: &str) -> ClientConfig { 308 - let mut params = HashMap::new(); 309 - params.insert("model".to_string(), serde_json::json!(model)); 310 - let mut plugin_configs = HashMap::new(); 311 - plugin_configs.insert( 312 - "ein_openrouter".to_string(), 313 - PluginConfig { 314 - allowed_paths: vec![], 315 - allowed_hosts: vec![], 316 - params, 317 - }, 318 - ); 319 - ClientConfig { 320 - model_client_name: "ein_openrouter".to_string(), 321 - plugin_configs, 322 - ..Default::default() 323 - } 324 - } 325 - 326 - #[test] 327 - fn model_display_strips_vendor_prefix() { 328 - let cfg = config_with_model("anthropic/claude-sonnet-4-5"); 329 - assert_eq!(model_display_from_config(&cfg), "claude-sonnet-4-5"); 330 - } 331 - 332 - #[test] 333 - fn model_display_no_model_returns_unknown() { 334 - let cfg = ClientConfig::default(); 335 - assert_eq!(model_display_from_config(&cfg), "unknown"); 336 - } 337 - 338 - #[test] 339 - fn model_display_no_prefix_passthrough() { 340 - let cfg = config_with_model("llama3"); 341 - assert_eq!(model_display_from_config(&cfg), "llama3"); 342 - } 9 + ein_tui::run(Args::parse()).await 343 10 }
+24
crates/ein/Cargo.toml
··· 1 + [package] 2 + name = "ein" 3 + version.workspace = true 4 + edition.workspace = true 5 + authors.workspace = true 6 + license.workspace = true 7 + description = "AI agent framework — installs both ein-tui and ein-server" 8 + repository.workspace = true 9 + homepage.workspace = true 10 + 11 + [dependencies] 12 + anyhow = { workspace = true } 13 + clap = { version = "4", features = ["derive"] } 14 + ein-server = { path = "../ein-server" } 15 + ein-tui = { path = "../ein-tui" } 16 + tokio = { workspace = true } 17 + 18 + [[bin]] 19 + name = "ein-server" 20 + path = "src/bin/ein_server.rs" 21 + 22 + [[bin]] 23 + name = "ein-tui" 24 + path = "src/bin/ein_tui.rs"
+17
crates/ein/src/bin/ein_server.rs
··· 1 + // SPDX-License-Identifier: Apache-2.0 2 + // Copyright 2026 Mason Stallmo 3 + 4 + use clap::Parser; 5 + 6 + #[derive(Parser)] 7 + #[command(author, version, about)] 8 + struct Args { 9 + /// TCP port for the gRPC server to listen on. 10 + #[arg(long, default_value = "50051")] 11 + port: u16, 12 + } 13 + 14 + #[tokio::main] 15 + async fn main() -> anyhow::Result<()> { 16 + ein_server::run(Args::parse().port).await 17 + }
+10
crates/ein/src/bin/ein_tui.rs
··· 1 + // SPDX-License-Identifier: Apache-2.0 2 + // Copyright 2026 Mason Stallmo 3 + 4 + use clap::Parser; 5 + use ein_tui::Args; 6 + 7 + #[tokio::main] 8 + async fn main() -> anyhow::Result<()> { 9 + ein_tui::run(Args::parse()).await 10 + }