Empryo threads run `empryo --headless --chat --events`: a message per line on stdin, Empryo's JSONL events on stdout. A thread names its session (--session-id) and resumes it (--session) after a stop, a restart or a model or effort change, which Empryo takes as flags. Streamed text, tool calls and results, cost, context and model speed map onto the transcript; Stop sends Ctrl+C, which ends Empryo's turn and its chat. It runs with full access (--mode auto) or plans (--mode plan), offers Low/Medium/High effort, lists models with `empryo --list-models`, writes thread titles with a bare one-off run, and updates by running its installer. For Empryo threads the sidebar's corner gets a Genome button (and the right panel a "Genome map" surface, G): Empryo's ranked map of the checkout, read from .empryo/genome.db. A force-directed graph of the top files, sized by rank and colored by folder, with their imports; below it the ranked files with their dependents, opening to their symbols. It follows the thread's checkout and reloads as Empryo updates the map. The Android app knows Empryo's name, mark and plan mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 30 changed files with 1674 additions and 56 deletions.
| @@ -1733,6 +1733,18 @@ dependencies = [ | ||
| 1733 | 1733 | "zune-inflate", |
| 1734 | 1734 | ] |
| 1735 | 1735 | |
| 1736 | + [[package]] | |
| 1737 | + name = "fallible-iterator" | |
| 1738 | + version = "0.3.0" | |
| 1739 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1740 | + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | |
| 1741 | + | |
| 1742 | + [[package]] | |
| 1743 | + name = "fallible-streaming-iterator" | |
| 1744 | + version = "0.1.9" | |
| 1745 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1746 | + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" | |
| 1747 | + | |
| 1736 | 1748 | [[package]] |
| 1737 | 1749 | name = "fastrand" |
| 1738 | 1750 | version = "2.5.0" |
| @@ -2985,6 +2997,18 @@ name = "hashbrown" | ||
| 2985 | 2997 | version = "0.17.1" |
| 2986 | 2998 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2987 | 2999 | checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" |
| 3000 | + dependencies = [ | |
| 3001 | + "foldhash 0.2.0", | |
| 3002 | + ] | |
| 3003 | + | |
| 3004 | + [[package]] | |
| 3005 | + name = "hashlink" | |
| 3006 | + version = "0.12.2" | |
| 3007 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3008 | + checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" | |
| 3009 | + dependencies = [ | |
| 3010 | + "hashbrown 0.17.1", | |
| 3011 | + ] | |
| 2988 | 3012 | |
| 2989 | 3013 | [[package]] |
| 2990 | 3014 | name = "heapless" |
| @@ -3705,6 +3729,17 @@ dependencies = [ | ||
| 3705 | 3729 | "libc", |
| 3706 | 3730 | ] |
| 3707 | 3731 | |
| 3732 | + [[package]] | |
| 3733 | + name = "libsqlite3-sys" | |
| 3734 | + version = "0.38.2" | |
| 3735 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3736 | + checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" | |
| 3737 | + dependencies = [ | |
| 3738 | + "cc", | |
| 3739 | + "pkg-config", | |
| 3740 | + "vcpkg", | |
| 3741 | + ] | |
| 3742 | + | |
| 3708 | 3743 | [[package]] |
| 3709 | 3744 | name = "linebender_resource_handle" |
| 3710 | 3745 | version = "0.1.1" |
| @@ -5735,6 +5770,31 @@ dependencies = [ | ||
| 5735 | 5770 | "memchr", |
| 5736 | 5771 | ] |
| 5737 | 5772 | |
| 5773 | + [[package]] | |
| 5774 | + name = "rsqlite-vfs" | |
| 5775 | + version = "0.1.1" | |
| 5776 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5777 | + checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" | |
| 5778 | + dependencies = [ | |
| 5779 | + "hashbrown 0.16.1", | |
| 5780 | + "thiserror 2.0.21", | |
| 5781 | + ] | |
| 5782 | + | |
| 5783 | + [[package]] | |
| 5784 | + name = "rusqlite" | |
| 5785 | + version = "0.40.2" | |
| 5786 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5787 | + checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" | |
| 5788 | + dependencies = [ | |
| 5789 | + "bitflags 2.13.2", | |
| 5790 | + "fallible-iterator", | |
| 5791 | + "fallible-streaming-iterator", | |
| 5792 | + "hashlink", | |
| 5793 | + "libsqlite3-sys", | |
| 5794 | + "smallvec", | |
| 5795 | + "sqlite-wasm-rs", | |
| 5796 | + ] | |
| 5797 | + | |
| 5738 | 5798 | [[package]] |
| 5739 | 5799 | name = "rust-embed" |
| 5740 | 5800 | version = "8.13.0" |
| @@ -6452,6 +6512,18 @@ dependencies = [ | ||
| 6452 | 6512 | "bitflags 2.13.2", |
| 6453 | 6513 | ] |
| 6454 | 6514 | |
| 6515 | + [[package]] | |
| 6516 | + name = "sqlite-wasm-rs" | |
| 6517 | + version = "0.5.5" | |
| 6518 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6519 | + checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" | |
| 6520 | + dependencies = [ | |
| 6521 | + "cc", | |
| 6522 | + "js-sys", | |
| 6523 | + "rsqlite-vfs", | |
| 6524 | + "wasm-bindgen", | |
| 6525 | + ] | |
| 6526 | + | |
| 6455 | 6527 | [[package]] |
| 6456 | 6528 | name = "stable_deref_trait" |
| 6457 | 6529 | version = "1.2.1" |
| @@ -7475,6 +7547,12 @@ dependencies = [ | ||
| 7475 | 7547 | "sval_serde", |
| 7476 | 7548 | ] |
| 7477 | 7549 | |
| 7550 | + [[package]] | |
| 7551 | + name = "vcpkg" | |
| 7552 | + version = "0.2.15" | |
| 7553 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7554 | + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 7555 | + | |
| 7478 | 7556 | [[package]] |
| 7479 | 7557 | name = "vectrum-code" |
| 7480 | 7558 | version = "0.2.5" |
| @@ -7490,6 +7568,7 @@ dependencies = [ | ||
| 7490 | 7568 | "openh264", |
| 7491 | 7569 | "qrcode", |
| 7492 | 7570 | "ring", |
| 7571 | + "rusqlite", | |
| 7493 | 7572 | "serde", |
| 7494 | 7573 | "serde_json", |
| 7495 | 7574 | "smol", |
| @@ -21,6 +21,7 @@ openh264 = "0.9" | ||
| 21 | 21 | mermaid-rs-renderer = { version = "0.3.1", default-features = false } |
| 22 | 22 | qrcode = { version = "0.14.1", default-features = false } |
| 23 | 23 | ring = "0.17" |
| 24 | + rusqlite = { version = "0.40", features = ["bundled"] } | |
| 24 | 25 | |
| 25 | 26 | [profile.dev] |
| 26 | 27 | debug = "line-tables-only" |
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | A native [T3 Code](https://github.com/pingdotgg/t3code)-style desktop client for coding |
| 4 | 4 | agents, built with [GPUI](https://gpui.rs) and [GPUI Kit](https://github.com/longbridge/gpui-kit). |
| 5 | 5 | It drives [Claude Code](https://docs.claude.com/en/docs/claude-code), |
| 6 | - [Pi](https://github.com/earendil-works/pi) and [jcode](https://github.com/1jehuang/jcode). | |
| 6 | + [Pi](https://github.com/earendil-works/pi), [jcode](https://github.com/1jehuang/jcode) and | |
| 7 | + [Empryo](https://empryo.com/). | |
| 7 | 8 | |
| 8 | 9 | Every thread runs its own agent process in the background. Claude Code threads talk to the |
| 9 | 10 | `claude` CLI over the stream-json protocol the Claude Agent SDKs use |
| @@ -11,6 +12,9 @@ Every thread runs its own agent process in the background. Claude Code threads t | ||
| 11 | 12 | talk to `pi --mode rpc` over its JSONL RPC protocol. jcode threads talk to |
| 12 | 13 | `jcode api --stdio`, a bridge to jcode's versioned harness API that starts jcode's shared |
| 13 | 14 | daemon if it isn't running, so jcode threads also show up in jcode's own session list. |
| 15 | + Empryo threads talk to `empryo --headless --chat --events`, Empryo's headless chat: a message | |
| 16 | + per line on stdin, its events as JSONL on stdout, and a session the thread names | |
| 17 | + (`--session-id`) and resumes (`--session`). | |
| 14 | 18 | |
| 15 | 19 | The layout and workflow follow T3 Code. The look follows [MetalUI](https://metalui.dev/), the |
| 16 | 20 | "soft hardware" design system [vectrum.cc](https://vectrum.cc/) is built on: the |
| @@ -64,19 +68,22 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 64 | 68 | it there once the thread has started. |
| 65 | 69 | - A model picker across providers (Ctrl+Shift+M): a rail with **Favorites** and each provider, |
| 66 | 70 | Claude's models (current ones, plus legacy models folded away), every model Pi's configured |
| 67 | - providers offer, and every model route jcode's logins and provider profiles make available. | |
| 71 | + providers offer, every model route jcode's logins and provider profiles make available, and | |
| 72 | + every model of the providers Empryo has a key or sign-in for (`empryo --list-models`). | |
| 68 | 73 | Star models to keep them under Favorites, search across all of them, pick the first nine |
| 69 | 74 | with Ctrl+1–9, and switch providers with Ctrl+Shift+↑/↓. On a new thread, picking another |
| 70 | 75 | provider's model starts a thread of that kind in its place; once a thread has started, |
| 71 | 76 | other providers' models are greyed out, as in T3 Code. `/model` opens the picker too. |
| 72 | 77 | - A reasoning menu (Ctrl+Shift+E): Low to Max (as the model supports, its default marked), |
| 73 | 78 | Claude's Ultracode and Ultrathink, and Fast Mode on models that have it. Pi threads list Pi's thinking levels; |
| 74 | - jcode threads offer effort levels on its Anthropic, OpenAI and OpenRouter routes. | |
| 75 | - All of it switches live. | |
| 76 | - - An access menu (Ctrl+Shift+A): Supervised, Auto-accept edits, Auto or Full access. Pi and | |
| 77 | - jcode have no approval prompts, so their threads always run with full access. With plan | |
| 78 | - mode turned on (Settings → General → Legacy features), a Build/Plan toggle and Shift+Tab | |
| 79 | - switch Claude into plan mode and back. | |
| 79 | + jcode threads offer effort levels on its Anthropic, OpenAI and OpenRouter routes; Empryo | |
| 80 | + threads offer Low, Medium and High (`--effort`). All of it switches live; an Empryo thread | |
| 81 | + restarts Empryo with the new flags once its turn is done, resuming its session. | |
| 82 | + - An access menu (Ctrl+Shift+A): Supervised, Auto-accept edits, Auto or Full access. Pi, | |
| 83 | + jcode and Empryo have no approval prompts, so their threads always run with full access | |
| 84 | + (Empryo's `--mode auto`). With plan mode turned on (Settings → General → Legacy features), a | |
| 85 | + Build/Plan toggle and Shift+Tab switch Claude, or Empryo (`--mode plan`), into plan mode and | |
| 86 | + back. | |
| 80 | 87 | - Finished turns fold their tool calls and interim messages into one **Worked for 12s** row |
| 81 | 88 | above the answer. Click it to unfold. |
| 82 | 89 | - Answers can carry live diagrams and UI, written as plain fenced code blocks. ` ```mermaid ` |
| @@ -138,6 +145,14 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 138 | 145 | before every run, that extension also switches off Pi's other subagent tools. |
| 139 | 146 | jcode gets them as session tools over its API, with its `swarm` and `schedule` |
| 140 | 147 | tools turned off. Subagents can't delegate tasks of their own. |
| 148 | + - The Genome map, for Empryo threads (the DNA button in the sidebar's corner, or G in the | |
| 149 | + right panel): [Empryo's Genome](https://empryo.com/docs/concepts/genome), the ranked map of | |
| 150 | + files, symbols and imports Empryo keeps of a checkout in `.empryo/genome.db`. A graph of the | |
| 151 | + 48 highest ranked files, sized by rank and colored by folder, with a line for each import, | |
| 152 | + and the files ranked below it with how many files import each (`→12`) and what changed in | |
| 153 | + the last two days. A file opens to its symbols, exported ones first with their signatures, | |
| 154 | + and lights up with its imports in the graph. It follows the thread to its worktree and | |
| 155 | + reloads as Empryo updates the map. | |
| 141 | 156 | - Background tasks: when a command, monitor or subagent Claude left running finishes, the |
| 142 | 157 | transcript says so and Claude picks the work back up and reports back on its own. A |
| 143 | 158 | long-running command can be sent to the background from its tool card, like Ctrl+B in |
| @@ -292,6 +307,9 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 292 | 307 | (`npm install -g @earendil-works/pi-coding-agent`, then log in to a provider with `pi`) |
| 293 | 308 | - Optional: [jcode](https://github.com/1jehuang/jcode) for jcode threads |
| 294 | 309 | (`curl -fsSL https://jcode.sh/install | bash`, then `jcode login`) |
| 310 | + - Optional: [Empryo](https://empryo.com/) for Empryo threads | |
| 311 | + (`curl -fsSL https://empryo.com/install.sh | bash`, then `empryo --login claude` or another | |
| 312 | + account, or `empryo --set-key <provider> <key>`). Add it in Settings → Providers → Add provider. | |
| 295 | 313 | - Optional: agent-browser for the browser panel |
| 296 | 314 | (`npm install -g agent-browser && agent-browser install`) |
| 297 | 315 | - Optional, for the Device panel: Node.js 22 or newer with npm, and the Android SDK |
| @@ -352,6 +370,8 @@ The shortcuts follow T3 Code's default keymap. | ||
| 352 | 370 | `~/.local/bin`, `~/.npm-global/bin`, `~/.bun/bin`, `~/.volta/bin` and nvm's Node versions. |
| 353 | 371 | - `VECTRUM_CODE_JCODE_BIN`: path to the `jcode` binary. Otherwise it's looked up on `PATH`, |
| 354 | 372 | then in `~/.local/bin` and `~/.cargo/bin`. |
| 373 | + - `VECTRUM_CODE_EMPRYO_BIN`: path to the `empryo` binary. Otherwise it's looked up on `PATH`, | |
| 374 | + then in `~/.empryo/bin` and `~/.local/bin`. | |
| 355 | 375 | - `VECTRUM_UPDATE_URL`: where to look for updates instead of `https://vectrum.cc`, such as a |
| 356 | 376 | test server. Releases from there still need the release key's signature. |
| 357 | 377 | |
| @@ -392,12 +412,14 @@ after changing them, `cargo test export_android_themes -- --ignored` rewrites | ||
| 392 | 412 | |
| 393 | 413 | ## Layout |
| 394 | 414 | |
| 395 | - - `src/claude.rs`: spawns and owns the agent process, `claude`, `pi` or `jcode api --stdio` | |
| 415 | + - `src/claude.rs`: spawns and owns the agent process, `claude`, `pi`, `jcode api --stdio` or | |
| 416 | + `empryo --headless --chat` | |
| 396 | 417 | (stdin writer and stdout/stderr reader threads). |
| 397 | - - `src/session.rs`: per-thread state. Folds Claude's stream-json, Pi's RPC and jcode's | |
| 398 | - harness API events into chat items, handles permission requests, control requests and | |
| 418 | + - `src/session.rs`: per-thread state. Folds Claude's stream-json, Pi's RPC, jcode's | |
| 419 | + harness API and Empryo's headless events into chat items, handles permission requests, control requests and | |
| 399 | 420 | extension dialogs, and snapshots each turn's changes. |
| 400 | 421 | - `src/models.rs`: the model catalog across providers, and favorites. |
| 422 | + - `src/genome.rs`: reads Empryo's Genome (`.empryo/genome.db`) and lays out its graph. | |
| 401 | 423 | - `src/git.rs`: branch and change stats, working-tree snapshots, and diffs. |
| 402 | 424 | - `src/settings.rs`: the saved settings file, cached in memory. |
| 403 | 425 | - `src/update.rs`: the app's updater: release checks, signature and checksum checks, the |
| @@ -437,4 +459,6 @@ highlighting, and T3 Code's project | ||
| 437 | 459 | scripts aren't here yet. The terminal drawer doesn't report mouse clicks to full-screen |
| 438 | 460 | programs (only the wheel), open links, or show T3's popup after a selection. jcode models can't write thread titles (every |
| 439 | 461 | jcode run is saved as a session), and jcode's side panel, applets and background-task |
| 440 | - progress aren't shown. | |
| 462 | + progress aren't shown. Empryo threads don't get the app's subagent and device tools (Empryo | |
| 463 | + loads MCP servers from its config files only), take images by path rather than inline, and | |
| 464 | + can't be steered mid-turn: a message sent while Empryo works becomes its next turn. | |
| @@ -59,6 +59,7 @@ enum class Lucide(val res: Int) { | ||
| 59 | 59 | Ellipsis(R.drawable.lucide_ellipsis), |
| 60 | 60 | EllipsisVertical(R.drawable.lucide_ellipsis_vertical), |
| 61 | 61 | ExternalLink(R.drawable.lucide_external_link), |
| 62 | + Dna(R.drawable.lucide_dna), | |
| 62 | 63 | Eye(R.drawable.lucide_eye), |
| 63 | 64 | EyeOff(R.drawable.lucide_eye_off), |
| 64 | 65 | Feather(R.drawable.lucide_feather), |
| @@ -270,7 +270,7 @@ fun ComposerFrame( | ||
| 270 | 270 | val p = palette |
| 271 | 271 | var focused by remember { mutableStateOf(false) } |
| 272 | 272 | var sheet by remember { mutableStateOf<PickerSheet?>(null) } |
| 273 | - val planToggle = app.prefs.bool("plan_mode") && asksPermission(target.provider) | |
| 273 | + val planToggle = app.prefs.bool("plan_mode") && hasPlanMode(target.provider) | |
| 274 | 274 | val attach = rememberAttach(app, state) |
| 275 | 275 | LaunchedEffect(state.modelPickerRequested) { |
| 276 | 276 | if (state.modelPickerRequested) { |
| @@ -32,12 +32,14 @@ val ClaudeOrange = Color(0xffd97757) | ||
| 32 | 32 | fun providerIcon(provider: String): Lucide = when (provider) { |
| 33 | 33 | "pi" -> Lucide.Pi |
| 34 | 34 | "jcode" -> Lucide.Feather |
| 35 | + "empryo" -> Lucide.Dna | |
| 35 | 36 | else -> Lucide.Asterisk |
| 36 | 37 | } |
| 37 | 38 | |
| 38 | 39 | fun providerLabel(provider: String): String = when (provider) { |
| 39 | 40 | "pi" -> "Pi" |
| 40 | 41 | "jcode" -> "jcode" |
| 42 | + "empryo" -> "Empryo" | |
| 41 | 43 | else -> "Claude" |
| 42 | 44 | } |
| 43 | 45 | |
| @@ -45,12 +47,16 @@ fun providerLabel(provider: String): String = when (provider) { | ||
| 45 | 47 | fun agentName(provider: String): String = when (provider) { |
| 46 | 48 | "pi" -> "Pi" |
| 47 | 49 | "jcode" -> "jcode" |
| 50 | + "empryo" -> "Empryo" | |
| 48 | 51 | else -> "Claude Code" |
| 49 | 52 | } |
| 50 | 53 | |
| 51 | - /** Whether the agent asks before running tools. Pi and jcode just run them. */ | |
| 54 | + /** Whether the agent asks before running tools. Pi, jcode and Empryo just run them. */ | |
| 52 | 55 | fun asksPermission(provider: String) = provider == "claude" |
| 53 | 56 | |
| 57 | + /** Whether the agent has a plan mode the composer can switch to. */ | |
| 58 | + fun hasPlanMode(provider: String) = provider == "claude" || provider == "empryo" | |
| 59 | + | |
| 54 | 60 | // ---- reasoning ----------------------------------------------------------------- |
| 55 | 61 | |
| 56 | 62 | /** Claude's `--effort` levels, offered when the model doesn't list its own. */ |
| @@ -82,6 +88,9 @@ fun reasoningLevels(provider: String, model: RemoteModel?): List<String> = when | ||
| 82 | 88 | else -> model?.efforts?.takeIf { it.isNotEmpty() } ?: DefaultEfforts |
| 83 | 89 | } |
| 84 | 90 | |
| 91 | + /** Whether the reasoning menu offers `Default`: the model's own level. */ | |
| 92 | + fun offersDefaultLevel(provider: String) = provider == "claude" || provider == "empryo" | |
| 93 | + | |
| 85 | 94 | /** The level `Default` stands for on this model, when known. */ |
| 86 | 95 | fun defaultLevel(provider: String, model: RemoteModel?): String? = |
| 87 | 96 | if (provider == "claude") model?.defaultEffort else null |
| @@ -234,7 +234,7 @@ fun ColumnScope.TraitsItems(target: PickTarget, close: () -> Unit) { | ||
| 234 | 234 | if (levels.isNotEmpty() || provider == "claude") { |
| 235 | 235 | any = true |
| 236 | 236 | MenuLabel(if (provider == "pi") "Thinking" else "Reasoning") |
| 237 | - if (default == null && provider == "claude") item("default", "Default", null, true) | |
| 237 | + if (default == null && offersDefaultLevel(provider)) item("default", "Default", null, true) | |
| 238 | 238 | for (level in levels) item(level, effortLabel(level), null, default == level) |
| 239 | 239 | if (provider == "claude") { |
| 240 | 240 | if (model == null || model.ultracode) { |
| @@ -531,6 +531,7 @@ private fun setupText(instance: RemoteInstance?): String? { | ||
| 531 | 531 | "missing" -> when (instance.provider) { |
| 532 | 532 | "pi" -> "Open provider setup to install Pi on this environment: npm install -g @earendil-works/pi-coding-agent" |
| 533 | 533 | "jcode" -> "Open provider setup to install jcode on this environment." |
| 534 | + "empryo" -> "Open provider setup to install Empryo on this environment: curl -fsSL https://empryo.com/install.sh | bash" | |
| 534 | 535 | else -> "Open provider setup to install Claude on this environment." |
| 535 | 536 | } |
| 536 | 537 | else -> "$label provider is unavailable." |
| @@ -155,7 +155,7 @@ fun newThreadDefaults(host: HostState, project: String, git: Boolean, from: Thre | ||
| 155 | 155 | // A host that doesn't list its agents: its instance ids start with the provider. |
| 156 | 156 | instances.isEmpty() -> { |
| 157 | 157 | instance = id |
| 158 | - provider = listOf("pi", "jcode").firstOrNull { id.startsWith(it) } ?: "claude" | |
| 158 | + provider = listOf("pi", "jcode", "empryo").firstOrNull { id.startsWith(it) } ?: "claude" | |
| 159 | 159 | model = key.substring(split + 1) |
| 160 | 160 | } |
| 161 | 161 | } |
| @@ -589,13 +589,14 @@ fun Code(text: String, modifier: Modifier = Modifier) { | ||
| 589 | 589 | /** Claude's asterisk orange, as the desktop's pickers draw it. */ |
| 590 | 590 | val ClaudeOrange = Color(0xffd97757) |
| 591 | 591 | |
| 592 | - /** An agent's mark: Claude's asterisk, Pi's π or jcode's feather. */ | |
| 592 | + /** An agent's mark: Claude's asterisk, Pi's π, jcode's feather or Empryo's DNA strand. */ | |
| 593 | 593 | @Composable |
| 594 | 594 | fun ProviderIcon(provider: String, size: Dp = 13.dp, modifier: Modifier = Modifier) { |
| 595 | 595 | val p = palette |
| 596 | 596 | when (provider) { |
| 597 | 597 | "pi" -> LucideIcon(Lucide.Pi, modifier, size = size, tint = p.ink) |
| 598 | 598 | "jcode" -> LucideIcon(Lucide.Feather, modifier, size = size, tint = p.ink) |
| 599 | + "empryo" -> LucideIcon(Lucide.Dna, modifier, size = size, tint = p.ink) | |
| 599 | 600 | else -> LucideIcon(Lucide.Asterisk, modifier, size = size, tint = ClaudeOrange) |
| 600 | 601 | } |
| 601 | 602 | } |
| @@ -630,7 +631,7 @@ fun ModelControl( | ||
| 630 | 631 | model != null -> model.name |
| 631 | 632 | else -> modelId ?: current |
| 632 | 633 | } |
| 633 | - val provider = instance?.provider ?: instanceId?.takeIf { it in setOf("claude", "pi", "jcode") } | |
| 634 | + val provider = instance?.provider ?: instanceId?.takeIf { it in setOf("claude", "pi", "jcode", "empryo") } | |
| 634 | 635 | val leading: (@Composable androidx.compose.foundation.layout.RowScope.() -> Unit)? = |
| 635 | 636 | if (provider != null) { { ProviderIcon(provider) } } else null |
| 636 | 637 | Cap( |
| @@ -0,0 +1,16 @@ | ||
| 1 | + <?xml version="1.0" encoding="utf-8"?> | |
| 2 | + <vector xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + android:width="24dp" android:height="24dp" | |
| 4 | + android:viewportWidth="24" android:viewportHeight="24"> | |
| 5 | + <path android:pathData="m10 16 1.5 1.5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 6 | + <path android:pathData="m14 8-1.5-1.5" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 7 | + <path android:pathData="M15 2c-1.798 1.998-2.518 3.995-2.807 5.993" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 8 | + <path android:pathData="m16.5 10.5 1 1" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 9 | + <path android:pathData="m17 6-2.891-2.891" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 10 | + <path android:pathData="M2 15c6.667-6 13.333 0 20-6" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 11 | + <path android:pathData="m20 9 .891.891" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 12 | + <path android:pathData="M3.109 14.109 4 15" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 13 | + <path android:pathData="m6.5 12.5 1 1" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 14 | + <path android:pathData="m7 18 2.891 2.891" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 15 | + <path android:pathData="M9 22c1.798-1.998 2.518-3.995 2.807-5.993" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" /> | |
| 16 | + </vector> | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | //! Spawns the long-running agent process behind a thread: the `claude` CLI |
| 2 | 2 | //! speaking stream-json (the transport the Claude Agent SDKs use), Pi in its |
| 3 | - //! JSONL RPC mode, or jcode's harness API bridge, which connects to jcode's | |
| 4 | - //! shared daemon. All read commands on stdin and stream events on stdout. | |
| 3 | + //! JSONL RPC mode, jcode's harness API bridge, which connects to jcode's | |
| 4 | + //! shared daemon, or Empryo's headless chat, which takes a message per line. | |
| 5 | + //! All read commands on stdin and stream events on stdout. | |
| 5 | 6 | |
| 6 | 7 | use std::{ |
| 7 | 8 | io::{BufRead, BufReader, Write}, |
| @@ -142,15 +143,18 @@ pub struct LaunchConfig { | ||
| 142 | 143 | /// Which agent, with its binary, home folder, arguments and environment. |
| 143 | 144 | pub instance: ProviderInstance, |
| 144 | 145 | pub cwd: PathBuf, |
| 145 | - /// A Claude model alias, `provider/model` for Pi, or a jcode model spec. | |
| 146 | - /// `None` is the default. | |
| 146 | + /// A Claude model alias, `provider/model` for Pi and Empryo, or a jcode | |
| 147 | + /// model spec. `None` is the default. | |
| 147 | 148 | pub model: Option<String>, |
| 148 | 149 | pub permission_mode: PermissionMode, |
| 149 | 150 | /// `None` leaves effort at the model's default. |
| 150 | 151 | pub effort: Option<Effort>, |
| 151 | - /// Session to resume (a Claude or jcode session id, or a Pi session | |
| 152 | - /// file), used to restart a thread whose process stopped. | |
| 152 | + /// Session to resume (a Claude, jcode or Empryo session id, or a Pi | |
| 153 | + /// session file), used to restart a thread whose process stopped. | |
| 153 | 154 | pub resume: Option<String>, |
| 155 | + /// The id a new Empryo session starts under, so the thread knows what | |
| 156 | + /// to resume before Empryo has said anything. | |
| 157 | + pub new_session: Option<String>, | |
| 154 | 158 | /// The thread's agent-browser session, shared with the browser panel. |
| 155 | 159 | pub browser_session: Option<String>, |
| 156 | 160 | /// The thread the agent runs for. |
| @@ -181,6 +185,7 @@ impl AgentProcess { | ||
| 181 | 185 | // jcode's tools run in its daemon, out of reach of this process's |
| 182 | 186 | // environment; the thread tells it about the browser in a message. |
| 183 | 187 | Provider::Jcode => jcode_command(config)?, |
| 188 | + Provider::Empryo => empryo_command(config)?, | |
| 184 | 189 | }; |
| 185 | 190 | // The app's tools; jcode's are registered over its API. |
| 186 | 191 | orchestrator::set_caps(&config.thread, config.tools); |
| @@ -192,20 +197,22 @@ impl AgentProcess { | ||
| 192 | 197 | } |
| 193 | 198 | } |
| 194 | 199 | Provider::Pi => orchestrator::prepare_pi(&mut command, &config.thread, config.tools), |
| 195 | - Provider::Jcode => {} | |
| 200 | + // Empryo takes MCP servers from its config files only. | |
| 201 | + Provider::Jcode | Provider::Empryo => {} | |
| 196 | 202 | } |
| 197 | 203 | // The agent-device launcher, on PATH for agents that may drive devices. |
| 198 | 204 | if config.tools.devices { |
| 199 | 205 | crate::device::agent::prepare_command(&mut command); |
| 200 | 206 | } |
| 201 | - // Claude and Pi take extra system prompt with this flag, once: the | |
| 202 | - // browser's, if the thread has one, and how to write inline UI. | |
| 207 | + // Claude, Pi and Empryo take extra system prompt with a flag, once: | |
| 208 | + // the browser's, if the thread has one, and how to write inline UI. | |
| 203 | 209 | if config.instance.kind != Provider::Jcode { |
| 204 | 210 | let browser = config.browser_session.as_ref().and_then(|session| browser::prepare_agent(&mut command, session)); |
| 205 | 211 | let inline = crate::settings::get(|settings| settings.inline_ui).then(|| crate::inline_ui::INSTRUCTIONS.to_string()); |
| 206 | 212 | let prompts: Vec<String> = browser.into_iter().chain(inline).collect(); |
| 207 | 213 | if !prompts.is_empty() { |
| 208 | - command.args(["--append-system-prompt", &prompts.join("\n\n")]); | |
| 214 | + let flag = if config.instance.kind == Provider::Empryo { "--system" } else { "--append-system-prompt" }; | |
| 215 | + command.args([flag, &prompts.join("\n\n")]); | |
| 209 | 216 | } |
| 210 | 217 | } |
| 211 | 218 | Self::spawn_command(&binary, command, events) |
| @@ -292,6 +299,11 @@ impl AgentProcess { | ||
| 292 | 299 | let _ = self.stdin_tx.send(message.to_string()); |
| 293 | 300 | } |
| 294 | 301 | |
| 302 | + /// Sends a line of text as it is, for agents that read plain lines. | |
| 303 | + pub fn send_line(&self, line: String) { | |
| 304 | + let _ = self.stdin_tx.send(line); | |
| 305 | + } | |
| 306 | + | |
| 295 | 307 | /// Sends a Claude user turn made of API content blocks (text, images). |
| 296 | 308 | pub fn send_user_content(&self, content: Vec<Value>) { |
| 297 | 309 | self.send(json!({ |
| @@ -312,6 +324,16 @@ impl AgentProcess { | ||
| 312 | 324 | })); |
| 313 | 325 | } |
| 314 | 326 | |
| 327 | + /// Sends the agent Ctrl+C. Empryo stops its turn, saves the session and | |
| 328 | + /// exits on it. | |
| 329 | + pub fn interrupt(&self) { | |
| 330 | + if let Ok(child) = self.child.lock() { | |
| 331 | + unsafe { | |
| 332 | + libc::kill(child.id() as libc::pid_t, libc::SIGINT); | |
| 333 | + } | |
| 334 | + } | |
| 335 | + } | |
| 336 | + | |
| 315 | 337 | pub fn kill(&self) { |
| 316 | 338 | if let Ok(mut child) = self.child.lock() { |
| 317 | 339 | let _ = child.kill(); |
| @@ -393,12 +415,43 @@ fn jcode_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 393 | 415 | Ok((binary, command)) |
| 394 | 416 | } |
| 395 | 417 | |
| 396 | - /// The environment variable that moves an agent's home folder. | |
| 418 | + /// Empryo's headless chat: a message per line on stdin, its events as | |
| 419 | + /// JSONL on stdout. A thread's session is new under the id the thread picked, | |
| 420 | + /// or resumed. It runs tools without asking (`auto`), or plans (`plan`). | |
| 421 | + fn empryo_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | |
| 422 | + let (binary, mut command) = base_command(&config.instance)?; | |
| 423 | + command | |
| 424 | + .args(["--headless", "--chat", "--events", "--cwd"]) | |
| 425 | + .arg(&config.cwd) | |
| 426 | + .current_dir(&config.cwd) | |
| 427 | + // Its first run would otherwise wait on a question about add-ons. | |
| 428 | + .env("EMPRYO_NO_PROMPT", "1"); | |
| 429 | + let mode = if config.permission_mode == PermissionMode::Plan { "plan" } else { "auto" }; | |
| 430 | + command.args(["--mode", mode]); | |
| 431 | + if let Some(model) = &config.model { | |
| 432 | + command.args(["--model", model]); | |
| 433 | + } | |
| 434 | + if let Some(effort) = config.effort { | |
| 435 | + command.args(["--effort", effort.as_cli()]); | |
| 436 | + } | |
| 437 | + if let Some(session) = &config.resume { | |
| 438 | + command.args(["--session", session]); | |
| 439 | + } else if let Some(session) = &config.new_session { | |
| 440 | + command.args(["--session-id", session]); | |
| 441 | + } | |
| 442 | + command.args(split_args(&config.instance.args)); | |
| 443 | + Ok((binary, command)) | |
| 444 | + } | |
| 445 | + | |
| 446 | + /// The environment variable that moves an agent's home folder. Empryo | |
| 447 | + /// keeps its keys, settings and sessions list in `~/.empryo`, so its home is | |
| 448 | + /// the folder that stands in for `~`. | |
| 397 | 449 | pub fn home_var(kind: Provider) -> &'static str { |
| 398 | 450 | match kind { |
| 399 | 451 | Provider::Claude => "CLAUDE_CONFIG_DIR", |
| 400 | 452 | Provider::Pi => "PI_CODING_AGENT_DIR", |
| 401 | 453 | Provider::Jcode => "JCODE_HOME", |
| 454 | + Provider::Empryo => "HOME", | |
| 402 | 455 | } |
| 403 | 456 | } |
| 404 | 457 | |
| @@ -408,6 +461,7 @@ pub fn binary_var(kind: Provider) -> &'static str { | ||
| 408 | 461 | Provider::Claude => "VECTRUM_CODE_CLAUDE_BIN", |
| 409 | 462 | Provider::Pi => "VECTRUM_CODE_PI_BIN", |
| 410 | 463 | Provider::Jcode => "VECTRUM_CODE_JCODE_BIN", |
| 464 | + Provider::Empryo => "VECTRUM_CODE_EMPRYO_BIN", | |
| 411 | 465 | } |
| 412 | 466 | } |
| 413 | 467 | |
| @@ -482,6 +536,8 @@ pub fn find_binary(instance: &ProviderInstance) -> Option<PathBuf> { | ||
| 482 | 536 | home.join(".volta/bin/pi"), |
| 483 | 537 | ], |
| 484 | 538 | Provider::Jcode => vec![home.join(".local/bin/jcode"), home.join(".cargo/bin/jcode")], |
| 539 | + // Its installer puts it here. | |
| 540 | + Provider::Empryo => vec![home.join(".empryo/bin/empryo"), home.join(".local/bin/empryo")], | |
| 485 | 541 | }; |
| 486 | 542 | fallbacks.extend([PathBuf::from("/usr/local/bin").join(name), PathBuf::from("/usr/bin").join(name)]); |
| 487 | 543 | // nvm keeps one bin directory per Node version. |
| @@ -0,0 +1,345 @@ | ||
| 1 | + //! Empryo's Genome: the ranked map of a repository Empryo keeps in | |
| 2 | + //! `.empryo/genome.db`, a SQLite file it updates as code changes. The Genome | |
| 3 | + //! map panel reads its most important files, what they export and which of | |
| 4 | + //! them import which, and lays them out as a graph. | |
| 5 | + | |
| 6 | + use std::{ | |
| 7 | + collections::HashMap, | |
| 8 | + path::{Path, PathBuf}, | |
| 9 | + time::{Duration, SystemTime}, | |
| 10 | + }; | |
| 11 | + | |
| 12 | + use anyhow::{Context as _, Result}; | |
| 13 | + use rusqlite::{Connection, OpenFlags, params_from_iter}; | |
| 14 | + | |
| 15 | + /// How many files the map shows, by rank. | |
| 16 | + pub const MAP_FILES: usize = 48; | |
| 17 | + /// How many symbols each file lists. | |
| 18 | + const FILE_SYMBOLS: usize = 12; | |
| 19 | + /// A file changed this recently is marked new, as Empryo does in its map. | |
| 20 | + const RECENT: Duration = Duration::from_secs(48 * 60 * 60); | |
| 21 | + | |
| 22 | + pub struct Genome { | |
| 23 | + pub total_files: u64, | |
| 24 | + pub total_symbols: u64, | |
| 25 | + pub total_edges: u64, | |
| 26 | + /// The highest ranked files, highest first. | |
| 27 | + pub files: Vec<GenomeFile>, | |
| 28 | + /// Imports between those files: `from` depends on `to`. | |
| 29 | + pub edges: Vec<GenomeEdge>, | |
| 30 | + /// Where each file sits in the map, from 0 to 1 on both axes. | |
| 31 | + pub layout: Vec<(f32, f32)>, | |
| 32 | + } | |
| 33 | + | |
| 34 | + pub struct GenomeFile { | |
| 35 | + pub path: String, | |
| 36 | + pub language: String, | |
| 37 | + pub lines: u64, | |
| 38 | + pub symbol_count: u64, | |
| 39 | + /// Its PageRank in the import graph; the files' ranks add up to 1. | |
| 40 | + pub rank: f64, | |
| 41 | + /// How many files import it, from anywhere in the repository. | |
| 42 | + pub dependents: u64, | |
| 43 | + /// How many files it imports. | |
| 44 | + pub dependencies: u64, | |
| 45 | + /// Changed in the last 48 hours. | |
| 46 | + pub recent: bool, | |
| 47 | + /// Its symbols, exported ones first. | |
| 48 | + pub symbols: Vec<GenomeSymbol>, | |
| 49 | + } | |
| 50 | + | |
| 51 | + pub struct GenomeSymbol { | |
| 52 | + pub name: String, | |
| 53 | + pub kind: String, | |
| 54 | + pub line: u64, | |
| 55 | + pub exported: bool, | |
| 56 | + pub signature: Option<String>, | |
| 57 | + } | |
| 58 | + | |
| 59 | + pub struct GenomeEdge { | |
| 60 | + pub from: usize, | |
| 61 | + pub to: usize, | |
| 62 | + pub weight: f64, | |
| 63 | + } | |
| 64 | + | |
| 65 | + /// The Genome database of the checkout `cwd` is in: Empryo keeps it at the | |
| 66 | + /// checkout's root, which may be a folder or two up. | |
| 67 | + pub fn db_path(cwd: &Path) -> Option<PathBuf> { | |
| 68 | + cwd.ancestors().map(|dir| dir.join(".empryo").join("genome.db")).find(|path| path.is_file()) | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Reads the map of the checkout `cwd` is in. | |
| 72 | + pub fn load(cwd: &Path) -> Result<Option<Genome>> { | |
| 73 | + let Some(path) = db_path(cwd) else { return Ok(None) }; | |
| 74 | + // Opened for writing so SQLite can share Empryo's write-ahead log, but | |
| 75 | + // only ever queried. | |
| 76 | + let db = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX) | |
| 77 | + .with_context(|| format!("couldn't open {}", path.display()))?; | |
| 78 | + db.busy_timeout(Duration::from_secs(5))?; | |
| 79 | + db.pragma_update(None, "query_only", true)?; | |
| 80 | + | |
| 81 | + let count = |table: &str| -> Result<u64> { | |
| 82 | + Ok(db.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| row.get::<_, i64>(0))? as u64) | |
| 83 | + }; | |
| 84 | + let (total_files, total_symbols, total_edges) = (count("files")?, count("symbols")?, count("edges")?); | |
| 85 | + | |
| 86 | + let now_ms = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis() as f64; | |
| 87 | + let mut ids = Vec::new(); | |
| 88 | + let mut files = Vec::new(); | |
| 89 | + let mut statement = db.prepare( | |
| 90 | + "SELECT id, path, language, line_count, symbol_count, pagerank, mtime_ms FROM files | |
| 91 | + ORDER BY pagerank DESC, symbol_count DESC LIMIT ?1", | |
| 92 | + )?; | |
| 93 | + let rows = statement.query_map([MAP_FILES as i64], |row| { | |
| 94 | + Ok(( | |
| 95 | + row.get::<_, i64>(0)?, | |
| 96 | + GenomeFile { | |
| 97 | + path: row.get(1)?, | |
| 98 | + language: row.get(2)?, | |
| 99 | + lines: row.get::<_, i64>(3)?.max(0) as u64, | |
| 100 | + symbol_count: row.get::<_, i64>(4)?.max(0) as u64, | |
| 101 | + rank: row.get(5)?, | |
| 102 | + dependents: 0, | |
| 103 | + dependencies: 0, | |
| 104 | + recent: now_ms - row.get::<_, f64>(6)? < RECENT.as_millis() as f64, | |
| 105 | + symbols: Vec::new(), | |
| 106 | + }, | |
| 107 | + )) | |
| 108 | + })?; | |
| 109 | + for row in rows { | |
| 110 | + let (id, file) = row?; | |
| 111 | + ids.push(id); | |
| 112 | + files.push(file); | |
| 113 | + } | |
| 114 | + drop(statement); | |
| 115 | + let index: HashMap<i64, usize> = ids.iter().enumerate().map(|(ix, id)| (*id, ix)).collect(); | |
| 116 | + let placeholders = vec!["?"; ids.len()].join(","); | |
| 117 | + | |
| 118 | + let mut edges = Vec::new(); | |
| 119 | + if !ids.is_empty() { | |
| 120 | + let mut statement = db.prepare(&format!( | |
| 121 | + "SELECT target_file_id, count(*) FROM edges WHERE target_file_id IN ({placeholders}) GROUP BY target_file_id" | |
| 122 | + ))?; | |
| 123 | + let rows = statement.query_map(params_from_iter(&ids), |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?; | |
| 124 | + for row in rows { | |
| 125 | + let (id, count) = row?; | |
| 126 | + if let Some(&ix) = index.get(&id) { | |
| 127 | + files[ix].dependents = count as u64; | |
| 128 | + } | |
| 129 | + } | |
| 130 | + drop(statement); | |
| 131 | + let mut statement = db.prepare(&format!( | |
| 132 | + "SELECT source_file_id, count(*) FROM edges WHERE source_file_id IN ({placeholders}) GROUP BY source_file_id" | |
| 133 | + ))?; | |
| 134 | + let rows = statement.query_map(params_from_iter(&ids), |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?; | |
| 135 | + for row in rows { | |
| 136 | + let (id, count) = row?; | |
| 137 | + if let Some(&ix) = index.get(&id) { | |
| 138 | + files[ix].dependencies = count as u64; | |
| 139 | + } | |
| 140 | + } | |
| 141 | + drop(statement); | |
| 142 | + | |
| 143 | + let mut statement = db.prepare(&format!( | |
| 144 | + "SELECT source_file_id, target_file_id, weight FROM edges | |
| 145 | + WHERE source_file_id IN ({placeholders}) AND target_file_id IN ({placeholders})" | |
| 146 | + ))?; | |
| 147 | + let both: Vec<i64> = ids.iter().chain(ids.iter()).copied().collect(); | |
| 148 | + let rows = statement.query_map(params_from_iter(&both), |row| { | |
| 149 | + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, f64>(2)?)) | |
| 150 | + })?; | |
| 151 | + for row in rows { | |
| 152 | + let (from, to, weight) = row?; | |
| 153 | + if let (Some(&from), Some(&to)) = (index.get(&from), index.get(&to)) | |
| 154 | + && from != to | |
| 155 | + { | |
| 156 | + edges.push(GenomeEdge { from, to, weight }); | |
| 157 | + } | |
| 158 | + } | |
| 159 | + drop(statement); | |
| 160 | + | |
| 161 | + let mut statement = db.prepare(&format!( | |
| 162 | + "SELECT file_id, name, kind, line, is_exported, signature FROM symbols | |
| 163 | + WHERE file_id IN ({placeholders}) ORDER BY is_exported DESC, line" | |
| 164 | + ))?; | |
| 165 | + let rows = statement.query_map(params_from_iter(&ids), |row| { | |
| 166 | + Ok(( | |
| 167 | + row.get::<_, i64>(0)?, | |
| 168 | + GenomeSymbol { | |
| 169 | + name: row.get(1)?, | |
| 170 | + kind: row.get(2)?, | |
| 171 | + line: row.get::<_, i64>(3)?.max(0) as u64, | |
| 172 | + exported: row.get::<_, i64>(4)? != 0, | |
| 173 | + signature: row.get(5)?, | |
| 174 | + }, | |
| 175 | + )) | |
| 176 | + })?; | |
| 177 | + for row in rows { | |
| 178 | + let (id, symbol) = row?; | |
| 179 | + if let Some(&ix) = index.get(&id) | |
| 180 | + && files[ix].symbols.len() < FILE_SYMBOLS | |
| 181 | + { | |
| 182 | + files[ix].symbols.push(symbol); | |
| 183 | + } | |
| 184 | + } | |
| 185 | + } | |
| 186 | + | |
| 187 | + let layout = layout(&files, &edges); | |
| 188 | + Ok(Some(Genome { total_files, total_symbols, total_edges, files, edges, layout })) | |
| 189 | + } | |
| 190 | + | |
| 191 | + /// Places the files with a force-directed layout: imports pull files | |
| 192 | + /// together, every pair pushes apart, and higher ranked files start nearer | |
| 193 | + /// the middle. Deterministic, so the map holds still between reloads. | |
| 194 | + pub fn layout(files: &[GenomeFile], edges: &[GenomeEdge]) -> Vec<(f32, f32)> { | |
| 195 | + let n = files.len(); | |
| 196 | + if n == 0 { | |
| 197 | + return Vec::new(); | |
| 198 | + } | |
| 199 | + if n == 1 { | |
| 200 | + return vec![(0.5, 0.5)]; | |
| 201 | + } | |
| 202 | + // A spiral by rank: the most important file in the middle. | |
| 203 | + let golden = std::f32::consts::PI * (3. - 5f32.sqrt()); | |
| 204 | + let mut pos: Vec<(f32, f32)> = (0..n) | |
| 205 | + .map(|ix| { | |
| 206 | + let radius = ((ix as f32 + 0.5) / n as f32).sqrt() * 0.45; | |
| 207 | + let angle = ix as f32 * golden; | |
| 208 | + (radius * angle.cos(), radius * angle.sin()) | |
| 209 | + }) | |
| 210 | + .collect(); | |
| 211 | + let mut edges_of = vec![0usize; n]; | |
| 212 | + for edge in edges { | |
| 213 | + edges_of[edge.from] += 1; | |
| 214 | + edges_of[edge.to] += 1; | |
| 215 | + } | |
| 216 | + let k = (1. / n as f32).sqrt() * 1.1; | |
| 217 | + let mut temperature = 0.1; | |
| 218 | + for _ in 0..400 { | |
| 219 | + let mut shift = vec![(0f32, 0f32); n]; | |
| 220 | + for a in 0..n { | |
| 221 | + for b in a + 1..n { | |
| 222 | + let (dx, dy) = (pos[a].0 - pos[b].0, pos[a].1 - pos[b].1); | |
| 223 | + let distance = (dx * dx + dy * dy).sqrt().max(0.001); | |
| 224 | + let force = k * k / distance; | |
| 225 | + let (fx, fy) = (dx / distance * force, dy / distance * force); | |
| 226 | + shift[a].0 += fx; | |
| 227 | + shift[a].1 += fy; | |
| 228 | + shift[b].0 -= fx; | |
| 229 | + shift[b].1 -= fy; | |
| 230 | + } | |
| 231 | + } | |
| 232 | + for edge in edges { | |
| 233 | + let (a, b) = (edge.from, edge.to); | |
| 234 | + let (dx, dy) = (pos[a].0 - pos[b].0, pos[a].1 - pos[b].1); | |
| 235 | + let distance = (dx * dx + dy * dy).sqrt().max(0.001); | |
| 236 | + // Hubs have many lines; each pulls less, so they don't swallow the map. | |
| 237 | + let degree = (edges_of[a] + edges_of[b]) as f32; | |
| 238 | + let force = distance * distance / k * (1. + (edge.weight as f32).ln_1p() * 0.2) / degree.sqrt().max(1.); | |
| 239 | + let (fx, fy) = (dx / distance * force, dy / distance * force); | |
| 240 | + shift[a].0 -= fx; | |
| 241 | + shift[a].1 -= fy; | |
| 242 | + shift[b].0 += fx; | |
| 243 | + shift[b].1 += fy; | |
| 244 | + } | |
| 245 | + for (ix, (dx, dy)) in shift.into_iter().enumerate() { | |
| 246 | + // A pull to the middle keeps files nothing imports from drifting off. | |
| 247 | + let gravity = 2.5 * k; | |
| 248 | + let (dx, dy) = (dx - pos[ix].0 * gravity, dy - pos[ix].1 * gravity); | |
| 249 | + let length = (dx * dx + dy * dy).sqrt().max(0.0001); | |
| 250 | + let step = length.min(temperature); | |
| 251 | + pos[ix].0 += dx / length * step; | |
| 252 | + pos[ix].1 += dy / length * step; | |
| 253 | + } | |
| 254 | + temperature *= 0.988; | |
| 255 | + } | |
| 256 | + // A few files far out would shrink everything else: bring the farthest | |
| 257 | + // in to just outside the rest. | |
| 258 | + let mut distances: Vec<f32> = pos.iter().map(|(x, y)| (x * x + y * y).sqrt()).collect(); | |
| 259 | + distances.sort_by(f32::total_cmp); | |
| 260 | + let edge = distances[(n * 9 / 10).min(n - 1)] * 1.15; | |
| 261 | + for (x, y) in &mut pos { | |
| 262 | + let distance = (*x * *x + *y * *y).sqrt(); | |
| 263 | + if distance > edge { | |
| 264 | + let squeeze = (edge + (distance - edge) * 0.2) / distance; | |
| 265 | + *x *= squeeze; | |
| 266 | + *y *= squeeze; | |
| 267 | + } | |
| 268 | + } | |
| 269 | + // Fit into the unit square. | |
| 270 | + let (min_x, max_x) = pos.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (x, _)| (lo.min(*x), hi.max(*x))); | |
| 271 | + let (min_y, max_y) = pos.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (_, y)| (lo.min(*y), hi.max(*y))); | |
| 272 | + let span = (max_x - min_x).max(max_y - min_y).max(0.0001); | |
| 273 | + let (off_x, off_y) = ((1. - (max_x - min_x) / span) / 2., (1. - (max_y - min_y) / span) / 2.); | |
| 274 | + pos.into_iter().map(|(x, y)| ((x - min_x) / span + off_x, (y - min_y) / span + off_y)).collect() | |
| 275 | + } | |
| 276 | + | |
| 277 | + #[cfg(test)] | |
| 278 | + mod tests { | |
| 279 | + use super::{GenomeEdge, GenomeFile, layout, load}; | |
| 280 | + | |
| 281 | + fn file(path: &str, rank: f64) -> GenomeFile { | |
| 282 | + GenomeFile { | |
| 283 | + path: path.into(), | |
| 284 | + language: "rust".into(), | |
| 285 | + lines: 10, | |
| 286 | + symbol_count: 1, | |
| 287 | + rank, | |
| 288 | + dependents: 0, | |
| 289 | + dependencies: 0, | |
| 290 | + recent: false, | |
| 291 | + symbols: Vec::new(), | |
| 292 | + } | |
| 293 | + } | |
| 294 | + | |
| 295 | + #[test] | |
| 296 | + fn layout_fits_the_square() { | |
| 297 | + let files: Vec<GenomeFile> = (0..20).map(|ix| file(&format!("f{ix}.rs"), 1. / (ix as f64 + 1.))).collect(); | |
| 298 | + let edges: Vec<GenomeEdge> = (1..20).map(|ix| GenomeEdge { from: ix, to: 0, weight: 1. }).collect(); | |
| 299 | + let pos = layout(&files, &edges); | |
| 300 | + assert_eq!(pos.len(), 20); | |
| 301 | + assert!(pos.iter().all(|(x, y)| (0. ..=1.).contains(x) && (0. ..=1.).contains(y))); | |
| 302 | + // No two files on top of each other. | |
| 303 | + for a in 0..pos.len() { | |
| 304 | + for b in a + 1..pos.len() { | |
| 305 | + let distance = ((pos[a].0 - pos[b].0).powi(2) + (pos[a].1 - pos[b].1).powi(2)).sqrt(); | |
| 306 | + assert!(distance > 0.01, "{a} and {b} overlap"); | |
| 307 | + } | |
| 308 | + } | |
| 309 | + assert_eq!(layout(&files[..1], &[]), vec![(0.5, 0.5)]); | |
| 310 | + } | |
| 311 | + | |
| 312 | + #[test] | |
| 313 | + fn reads_a_genome() { | |
| 314 | + let dir = std::env::temp_dir().join(format!("vectrum-genome-test-{}", std::process::id())); | |
| 315 | + let sub = dir.join("src"); | |
| 316 | + std::fs::create_dir_all(dir.join(".empryo")).unwrap(); | |
| 317 | + std::fs::create_dir_all(&sub).unwrap(); | |
| 318 | + let db = rusqlite::Connection::open(dir.join(".empryo/genome.db")).unwrap(); | |
| 319 | + db.execute_batch( | |
| 320 | + "PRAGMA journal_mode = WAL; | |
| 321 | + CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT, mtime_ms REAL, language TEXT, line_count INTEGER, | |
| 322 | + symbol_count INTEGER, pagerank REAL); | |
| 323 | + CREATE TABLE symbols (id INTEGER PRIMARY KEY, file_id INTEGER, name TEXT, kind TEXT, line INTEGER, | |
| 324 | + end_line INTEGER, is_exported INTEGER, signature TEXT); | |
| 325 | + CREATE TABLE edges (source_file_id INTEGER, target_file_id INTEGER, weight REAL); | |
| 326 | + INSERT INTO files VALUES (1, 'src/main.ts', 0, 'typescript', 4, 2, 0.35), (2, 'src/math.ts', 0, 'typescript', 3, 2, 0.65); | |
| 327 | + INSERT INTO symbols VALUES (1, 1, 'main', 'function', 3, 3, 1, NULL), (2, 2, 'get', 'method', 2, 2, 0, NULL), | |
| 328 | + (3, 2, 'add', 'function', 1, 1, 1, 'export function add(a, b)'); | |
| 329 | + INSERT INTO edges VALUES (1, 2, 4.5);", | |
| 330 | + ) | |
| 331 | + .unwrap(); | |
| 332 | + let genome = load(&sub).unwrap().unwrap(); | |
| 333 | + assert_eq!((genome.total_files, genome.total_symbols, genome.total_edges), (2, 3, 1)); | |
| 334 | + assert_eq!(genome.files[0].path, "src/math.ts"); | |
| 335 | + assert_eq!(genome.files[0].dependents, 1); | |
| 336 | + assert_eq!(genome.files[1].dependencies, 1); | |
| 337 | + assert_eq!(genome.files[0].symbols[0].name, "add"); | |
| 338 | + assert!(genome.files[0].symbols[0].exported); | |
| 339 | + assert_eq!(genome.edges.len(), 1); | |
| 340 | + assert_eq!((genome.edges[0].from, genome.edges[0].to), (1, 0)); | |
| 341 | + drop(db); | |
| 342 | + let _ = std::fs::remove_dir_all(&dir); | |
| 343 | + assert!(load(&std::env::temp_dir().join("vectrum-genome-none")).unwrap().is_none()); | |
| 344 | + } | |
| 345 | + } | |
| @@ -7,6 +7,7 @@ mod browser; | ||
| 7 | 7 | mod claude; |
| 8 | 8 | mod clock; |
| 9 | 9 | mod device; |
| 10 | + mod genome; | |
| 10 | 11 | mod git; |
| 11 | 12 | mod http; |
| 12 | 13 | mod inline_ui; |
| @@ -29,16 +29,18 @@ pub enum Provider { | ||
| 29 | 29 | Claude, |
| 30 | 30 | Pi, |
| 31 | 31 | Jcode, |
| 32 | + Empryo, | |
| 32 | 33 | } |
| 33 | 34 | |
| 34 | 35 | impl Provider { |
| 35 | - pub const ALL: [Provider; 3] = [Provider::Claude, Provider::Pi, Provider::Jcode]; | |
| 36 | + pub const ALL: [Provider; 4] = [Provider::Claude, Provider::Pi, Provider::Jcode, Provider::Empryo]; | |
| 36 | 37 | |
| 37 | 38 | pub fn label(self) -> &'static str { |
| 38 | 39 | match self { |
| 39 | 40 | Provider::Claude => "Claude", |
| 40 | 41 | Provider::Pi => "Pi", |
| 41 | 42 | Provider::Jcode => "jcode", |
| 43 | + Provider::Empryo => "Empryo", | |
| 42 | 44 | } |
| 43 | 45 | } |
| 44 | 46 | |
| @@ -48,6 +50,7 @@ impl Provider { | ||
| 48 | 50 | Provider::Claude => "Claude Code", |
| 49 | 51 | Provider::Pi => "Pi", |
| 50 | 52 | Provider::Jcode => "jcode", |
| 53 | + Provider::Empryo => "Empryo", | |
| 51 | 54 | } |
| 52 | 55 | } |
| 53 | 56 | |
| @@ -56,6 +59,7 @@ impl Provider { | ||
| 56 | 59 | Provider::Claude => "claude", |
| 57 | 60 | Provider::Pi => "pi", |
| 58 | 61 | Provider::Jcode => "jcode", |
| 62 | + Provider::Empryo => "empryo", | |
| 59 | 63 | } |
| 60 | 64 | } |
| 61 | 65 | |
| @@ -65,11 +69,17 @@ impl Provider { | ||
| 65 | 69 | self != Provider::Jcode |
| 66 | 70 | } |
| 67 | 71 | |
| 68 | - /// Whether the agent asks before running tools. Pi and jcode just run them. | |
| 72 | + /// Whether the agent asks before running tools. Pi, jcode and Empryo | |
| 73 | + /// just run them. | |
| 69 | 74 | pub fn asks_permission(self) -> bool { |
| 70 | 75 | self == Provider::Claude |
| 71 | 76 | } |
| 72 | 77 | |
| 78 | + /// Whether the agent has a plan mode the composer can switch to. | |
| 79 | + pub fn has_plan_mode(self) -> bool { | |
| 80 | + matches!(self, Provider::Claude | Provider::Empryo) | |
| 81 | + } | |
| 82 | + | |
| 73 | 83 | pub fn from_key(key: &str) -> Option<Self> { |
| 74 | 84 | Self::ALL.into_iter().find(|provider| provider.key() == key) |
| 75 | 85 | } |
| @@ -365,6 +375,7 @@ impl Catalog { | ||
| 365 | 375 | Provider::Claude => probe_claude(&config), |
| 366 | 376 | Provider::Pi => probe_pi(&config), |
| 367 | 377 | Provider::Jcode => probe_jcode(&config), |
| 378 | + Provider::Empryo => probe_empryo(&config), | |
| 368 | 379 | }; |
| 369 | 380 | let latest = if update_checks { latest_version(config.kind) } else { None }; |
| 370 | 381 | (probe, latest) |
| @@ -884,8 +895,125 @@ fn jcode_model_spec(model: &str, provider: &str, method: &str) -> String { | ||
| 884 | 895 | } |
| 885 | 896 | } |
| 886 | 897 | |
| 898 | + /// Asks `empryo --list-models` for the models of the providers Empryo has | |
| 899 | + /// a key or a sign-in for, and reads the model new threads start on from | |
| 900 | + /// its config. | |
| 901 | + fn probe_empryo(config: &ProviderInstance) -> Result<Probe, ProbeStatus> { | |
| 902 | + let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string()); | |
| 903 | + let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?; | |
| 904 | + command | |
| 905 | + .arg("--list-models") | |
| 906 | + .env("EMPRYO_NO_PROMPT", "1") | |
| 907 | + .current_dir(std::env::temp_dir()) | |
| 908 | + .stdin(Stdio::null()) | |
| 909 | + .stdout(Stdio::piped()) | |
| 910 | + .stderr(Stdio::null()); | |
| 911 | + let mut child = command.spawn().map_err(|err| failed(&err))?; | |
| 912 | + let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?; | |
| 913 | + let (tx, rx) = mpsc::channel(); | |
| 914 | + std::thread::spawn(move || { | |
| 915 | + let mut listing = String::new(); | |
| 916 | + let _ = std::io::Read::read_to_string(&mut BufReader::new(stdout), &mut listing); | |
| 917 | + let _ = tx.send(listing); | |
| 918 | + }); | |
| 919 | + // Each provider's list may come from its API. | |
| 920 | + let listing = rx.recv_timeout(Duration::from_secs(60)); | |
| 921 | + let _ = child.kill(); | |
| 922 | + let _ = child.wait(); | |
| 923 | + let listing = listing.map_err(|_| failed(&"empryo didn't list its models"))?; | |
| 924 | + let models = empryo_models_from(&config.id, &listing); | |
| 925 | + let version = base_command(config).ok().and_then(|(_, mut command)| { | |
| 926 | + let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; | |
| 927 | + // `empryo 3.9.4-beta` | |
| 928 | + let text = String::from_utf8_lossy(&output.stdout).to_string(); | |
| 929 | + text.split_whitespace().find(|word| word.starts_with(|c: char| c.is_ascii_digit())).map(str::to_string) | |
| 930 | + }); | |
| 931 | + let plan = empryo_default_model(config).map(|model| format!("Default {model}")); | |
| 932 | + Ok(Probe { models, plan, version, ..Probe::default() }) | |
| 933 | + } | |
| 934 | + | |
| 935 | + /// The models in `empryo --list-models`' listing: a line per provider, | |
| 936 | + /// `Anthropic (anthropic)`, then one per model, ` anthropic/claude-opus-5 1000k ctx …`. | |
| 937 | + pub fn empryo_models_from(instance: &str, listing: &str) -> Vec<ModelEntry> { | |
| 938 | + let mut models: Vec<ModelEntry> = Vec::new(); | |
| 939 | + let mut provider = String::new(); | |
| 940 | + for line in listing.lines().map(strip_ansi) { | |
| 941 | + if line.trim().is_empty() { | |
| 942 | + continue; | |
| 943 | + } | |
| 944 | + if !line.starts_with(' ') { | |
| 945 | + // `Fake (fake) [custom]` | |
| 946 | + provider = line.split(" (").next().unwrap_or_default().trim().to_string(); | |
| 947 | + continue; | |
| 948 | + } | |
| 949 | + let mut words = line.split_whitespace(); | |
| 950 | + let Some(id) = words.next().filter(|id| id.contains('/')) else { continue }; | |
| 951 | + if models.iter().any(|known| known.id == id) { | |
| 952 | + continue; | |
| 953 | + } | |
| 954 | + let words: Vec<&str> = words.collect(); | |
| 955 | + let context_window = words.windows(2).find(|pair| pair[1] == "ctx").and_then(|pair| { | |
| 956 | + let size = pair[0]; | |
| 957 | + let (number, scale) = match size.chars().last()? { | |
| 958 | + 'k' | 'K' => (&size[..size.len() - 1], 1_000.), | |
| 959 | + 'M' | 'm' => (&size[..size.len() - 1], 1_000_000.), | |
| 960 | + _ => (size, 1.), | |
| 961 | + }; | |
| 962 | + number.parse::<f64>().ok().map(|number| (number * scale) as u64) | |
| 963 | + }); | |
| 964 | + let name = id.split_once('/').map_or(id, |(_, model)| model); | |
| 965 | + models.push(ModelEntry { | |
| 966 | + provider: Provider::Empryo, | |
| 967 | + instance: instance.to_string(), | |
| 968 | + id: id.to_string(), | |
| 969 | + resolved: name.to_string(), | |
| 970 | + name: name.to_string(), | |
| 971 | + detail: provider.clone(), | |
| 972 | + legacy: false, | |
| 973 | + // `--effort` takes these; a model without reasoning ignores it. | |
| 974 | + efforts: vec![Effort::Low, Effort::Medium, Effort::High], | |
| 975 | + fast_mode: false, | |
| 976 | + auto_mode: false, | |
| 977 | + context_window, | |
| 978 | + }); | |
| 979 | + } | |
| 980 | + models | |
| 981 | + } | |
| 982 | + | |
| 983 | + /// The model Empryo starts on when none is picked, from its global config. | |
| 984 | + fn empryo_default_model(config: &ProviderInstance) -> Option<String> { | |
| 985 | + let home = match config.home.trim() { | |
| 986 | + "" => std::env::var_os("HOME").map(std::path::PathBuf::from)?, | |
| 987 | + home => settings::expand_home(home), | |
| 988 | + }; | |
| 989 | + let text = std::fs::read_to_string(home.join(".empryo/config.json")).ok()?; | |
| 990 | + let config: Value = serde_json::from_str(&text).ok()?; | |
| 991 | + config.get("defaultModel").and_then(Value::as_str).filter(|model| !model.is_empty()).map(str::to_string) | |
| 992 | + } | |
| 993 | + | |
| 994 | + /// `text` without its terminal color codes. | |
| 995 | + fn strip_ansi(text: &str) -> String { | |
| 996 | + let mut out = String::with_capacity(text.len()); | |
| 997 | + let mut chars = text.chars(); | |
| 998 | + while let Some(c) = chars.next() { | |
| 999 | + if c == '\u{1b}' { | |
| 1000 | + // CSI sequences end with a letter. | |
| 1001 | + if chars.next() == Some('[') { | |
| 1002 | + for c in chars.by_ref() { | |
| 1003 | + if c.is_ascii_alphabetic() { | |
| 1004 | + break; | |
| 1005 | + } | |
| 1006 | + } | |
| 1007 | + } | |
| 1008 | + continue; | |
| 1009 | + } | |
| 1010 | + out.push(c); | |
| 1011 | + } | |
| 1012 | + out | |
| 1013 | + } | |
| 1014 | + | |
| 887 | 1015 | /// The newest published version of an agent, from the npm registry (or |
| 888 | - /// GitHub, for jcode). | |
| 1016 | + /// GitHub, for jcode, and Empryo's download site). | |
| 889 | 1017 | fn latest_version(kind: Provider) -> Option<String> { |
| 890 | 1018 | let package = match kind { |
| 891 | 1019 | Provider::Claude => "@anthropic-ai/claude-code", |
| @@ -896,6 +1024,10 @@ fn latest_version(kind: Provider) -> Option<String> { | ||
| 896 | 1024 | let tag = serde_json::from_str::<Value>(&body).ok()?.get("tag_name")?.as_str()?.to_string(); |
| 897 | 1025 | return Some(tag.trim_start_matches('v').to_string()); |
| 898 | 1026 | } |
| 1027 | + Provider::Empryo => { | |
| 1028 | + let body = crate::http::get("https://dl.empryo.com/latest.txt", &[], Duration::from_secs(10)).ok()?; | |
| 1029 | + return Some(body.trim().trim_start_matches('v').to_string()).filter(|version| !version.is_empty()); | |
| 1030 | + } | |
| 899 | 1031 | }; |
| 900 | 1032 | let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?; |
| 901 | 1033 | serde_json::from_str::<Value>(&body).ok()?.get("version")?.as_str().map(str::to_string) |
| @@ -920,6 +1052,15 @@ pub fn update_command(config: &ProviderInstance) -> Option<(std::process::Comman | ||
| 920 | 1052 | command.args(["update"]); |
| 921 | 1053 | Some((command, "jcode update")) |
| 922 | 1054 | } |
| 1055 | + Provider::Empryo => { | |
| 1056 | + // Empryo updates by running its installer again. | |
| 1057 | + let mut command = std::process::Command::new("sh"); | |
| 1058 | + command | |
| 1059 | + .args(["-c", "curl -fsSL https://empryo.com/install.sh | bash"]) | |
| 1060 | + .env("EMPRYO_QUIET", "1") | |
| 1061 | + .env("EMPRYO_NO_PROMPT", "1"); | |
| 1062 | + Some((command, "curl -fsSL https://empryo.com/install.sh | bash")) | |
| 1063 | + } | |
| 923 | 1064 | } |
| 924 | 1065 | } |
| 925 | 1066 | |
| @@ -959,7 +1100,7 @@ fn provider_label(provider: &str) -> String { | ||
| 959 | 1100 | |
| 960 | 1101 | #[cfg(test)] |
| 961 | 1102 | mod tests { |
| 962 | - use super::{claude_probe_from, first_version, jcode_model_from, provider_label, version_newer}; | |
| 1103 | + use super::{claude_probe_from, empryo_models_from, first_version, jcode_model_from, provider_label, version_newer}; | |
| 963 | 1104 | use serde_json::json; |
| 964 | 1105 | |
| 965 | 1106 | #[::core::prelude::v1::test] |
| @@ -1028,4 +1169,19 @@ mod tests { | ||
| 1028 | 1169 | assert_eq!(jcode_model_from("jcode", &route("auto", "auto", "openrouter", true)).unwrap().id, "auto"); |
| 1029 | 1170 | assert!(jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-api", false)).is_none()); |
| 1030 | 1171 | } |
| 1172 | + | |
| 1173 | + #[::core::prelude::v1::test] | |
| 1174 | + fn empryo_listing() { | |
| 1175 | + let listing = "\u{1b}[1m\u{1b}[35mAnthropic\u{1b}[0m \u{1b}[2m(anthropic)\u{1b}[0m\n anthropic/claude-opus-5 1000k ctx $5/$25/M\n anthropic/claude-haiku-5\n\nFake (fake) [custom]\n fake/fake-1 128k ctx $3/$15/M (cache r$0.3/w$3.75) ~est\n (model fetch failed: offline)\n"; | |
| 1176 | + let models = empryo_models_from("empryo", listing); | |
| 1177 | + assert_eq!(models.len(), 3); | |
| 1178 | + assert_eq!(models[0].id, "anthropic/claude-opus-5"); | |
| 1179 | + assert_eq!(models[0].name, "claude-opus-5"); | |
| 1180 | + assert_eq!(models[0].detail, "Anthropic"); | |
| 1181 | + assert_eq!(models[0].context_window, Some(1_000_000)); | |
| 1182 | + assert_eq!(models[1].context_window, None); | |
| 1183 | + assert_eq!(models[2].detail, "Fake"); | |
| 1184 | + assert_eq!(models[2].key(), "empryo:fake/fake-1"); | |
| 1185 | + assert_eq!(models[2].context_window, Some(128_000)); | |
| 1186 | + } | |
| 1031 | 1187 | } |
| @@ -587,6 +587,8 @@ pub fn wait_limit(provider: crate::models::Provider) -> Duration { | ||
| 587 | 587 | crate::models::Provider::Claude => Duration::from_secs(30 * 60), |
| 588 | 588 | crate::models::Provider::Pi => Duration::from_secs(270), |
| 589 | 589 | crate::models::Provider::Jcode => Duration::from_secs(100), |
| 590 | + // Empryo threads don't get the app's tools. | |
| 591 | + crate::models::Provider::Empryo => Duration::from_secs(270), | |
| 590 | 592 | } |
| 591 | 593 | } |
| 592 | 594 | |
| @@ -827,6 +827,24 @@ struct JcodeLink { | ||
| 827 | 827 | instructions_sent: bool, |
| 828 | 828 | } |
| 829 | 829 | |
| 830 | + /// Where a thread's Empryo chat stands. Empryo takes a message per line and | |
| 831 | + /// streams each turn's events; it has no requests to keep track of. | |
| 832 | + #[derive(Default)] | |
| 833 | + struct EmpryoLink { | |
| 834 | + /// The session id a new chat was started under, until Empryo is up and | |
| 835 | + /// it can be resumed. | |
| 836 | + new_session: Option<String>, | |
| 837 | + /// Empryo said it's ready for a message since the process started. | |
| 838 | + ready: bool, | |
| 839 | + /// The answer the text streaming now goes into. | |
| 840 | + text: Option<usize>, | |
| 841 | + /// The model or effort changed mid-turn: the next turn starts a | |
| 842 | + /// process with the new flags. | |
| 843 | + relaunch: bool, | |
| 844 | + /// The user stopped the turn, so the process is on its way out. | |
| 845 | + stopping: bool, | |
| 846 | + } | |
| 847 | + | |
| 830 | 848 | pub struct Session { |
| 831 | 849 | /// Names the thread's file on disk. |
| 832 | 850 | pub id: String, |
| @@ -854,8 +872,8 @@ pub struct Session { | ||
| 854 | 872 | pub items: Vec<ChatItem>, |
| 855 | 873 | pub status: SessionStatus, |
| 856 | 874 | pub thinking: bool, |
| 857 | - /// What a restarted process resumes: Claude's or jcode's session id, or | |
| 858 | - /// Pi's session file. | |
| 875 | + /// What a restarted process resumes: Claude's, jcode's or Empryo's | |
| 876 | + /// session id, or Pi's session file. | |
| 859 | 877 | resume_id: Option<String>, |
| 860 | 878 | /// The model picked for the thread; `None` means the agent's default. |
| 861 | 879 | pub model: Option<String>, |
| @@ -942,6 +960,7 @@ pub struct Session { | ||
| 942 | 960 | pending_controls: HashMap<String, PendingControl>, |
| 943 | 961 | next_request_id: u64, |
| 944 | 962 | jcode: JcodeLink, |
| 963 | + empryo: EmpryoLink, | |
| 945 | 964 | _events_task: Task<()>, |
| 946 | 965 | } |
| 947 | 966 | |
| @@ -1100,6 +1119,7 @@ impl Session { | ||
| 1100 | 1119 | pending_controls: HashMap::new(), |
| 1101 | 1120 | next_request_id: 0, |
| 1102 | 1121 | jcode: JcodeLink::default(), |
| 1122 | + empryo: EmpryoLink::default(), | |
| 1103 | 1123 | _events_task: Task::ready(()), |
| 1104 | 1124 | } |
| 1105 | 1125 | } |
| @@ -1124,6 +1144,11 @@ impl Session { | ||
| 1124 | 1144 | |
| 1125 | 1145 | fn start(&mut self, resume: Option<String>, cx: &mut Context<Self>) { |
| 1126 | 1146 | let (tx, rx) = smol::channel::unbounded(); |
| 1147 | + // Empryo starts a new session under an id of the thread's choosing. | |
| 1148 | + let new_session = (self.provider == Provider::Empryo && resume.is_none()).then(|| { | |
| 1149 | + let millis = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(); | |
| 1150 | + format!("vectrum-{}-{millis:x}", self.id) | |
| 1151 | + }); | |
| 1127 | 1152 | let config = LaunchConfig { |
| 1128 | 1153 | instance: settings::instance(&self.instance, self.provider), |
| 1129 | 1154 | cwd: self.cwd.clone(), |
| @@ -1131,6 +1156,7 @@ impl Session { | ||
| 1131 | 1156 | permission_mode: self.permission_mode, |
| 1132 | 1157 | effort: self.reasoning.effort(), |
| 1133 | 1158 | resume: resume.clone(), |
| 1159 | + new_session: new_session.clone(), | |
| 1134 | 1160 | browser_session: settings::get_for(&self.project, |settings| settings.agent_browser) |
| 1135 | 1161 | .then(|| browser::session_name(&self.id)), |
| 1136 | 1162 | thread: self.id.clone(), |
| @@ -1194,6 +1220,8 @@ impl Session { | ||
| 1194 | 1220 | ); |
| 1195 | 1221 | } |
| 1196 | 1222 | } |
| 1223 | + // Model, effort and session went in as flags. | |
| 1224 | + Provider::Empryo => self.empryo = EmpryoLink { new_session, ..EmpryoLink::default() }, | |
| 1197 | 1225 | } |
| 1198 | 1226 | |
| 1199 | 1227 | self._events_task = cx.spawn(async move |this, cx| { |
| @@ -1546,6 +1574,11 @@ impl Session { | ||
| 1546 | 1574 | let mut images = Vec::new(); |
| 1547 | 1575 | let mut file_paths = Vec::new(); |
| 1548 | 1576 | for path in &attachments { |
| 1577 | + // Empryo's chat takes text only; it reads images from their path. | |
| 1578 | + if self.provider == Provider::Empryo { | |
| 1579 | + file_paths.push(path.display().to_string()); | |
| 1580 | + continue; | |
| 1581 | + } | |
| 1549 | 1582 | match image_media_type(path) { |
| 1550 | 1583 | Some(media_type) => match read_image(path) { |
| 1551 | 1584 | Ok(data) => images.push((media_type, data)), |
| @@ -1639,6 +1672,8 @@ impl Session { | ||
| 1639 | 1672 | self.jcode_request("send the message", request); |
| 1640 | 1673 | } |
| 1641 | 1674 | } |
| 1675 | + // Mid-turn, Empryo holds the message for its next turn. | |
| 1676 | + Provider::Empryo => process.send_line(empryo_line(&prompt)), | |
| 1642 | 1677 | } |
| 1643 | 1678 | self.status = SessionStatus::Working; |
| 1644 | 1679 | } |
| @@ -2134,6 +2169,14 @@ impl Session { | ||
| 2134 | 2169 | Provider::Claude => self.control(PendingControl::Interrupt, json!({"subtype": "interrupt"})), |
| 2135 | 2170 | Provider::Pi => self.pi(json!({"type": "abort"})), |
| 2136 | 2171 | Provider::Jcode => self.jcode_request("stop the turn", json!({"req": "cancel"})), |
| 2172 | + // Empryo stops the turn, saves the session and exits; the next | |
| 2173 | + // message resumes it. | |
| 2174 | + Provider::Empryo => { | |
| 2175 | + if let Some(process) = &self.process { | |
| 2176 | + self.empryo.stopping = true; | |
| 2177 | + process.interrupt(); | |
| 2178 | + } | |
| 2179 | + } | |
| 2137 | 2180 | } |
| 2138 | 2181 | cx.notify(); |
| 2139 | 2182 | } |
| @@ -2163,16 +2206,43 @@ impl Session { | ||
| 2163 | 2206 | } |
| 2164 | 2207 | } |
| 2165 | 2208 | Provider::Jcode => {} |
| 2209 | + Provider::Empryo => self.relaunch_empryo(), | |
| 2166 | 2210 | } |
| 2167 | 2211 | cx.notify(); |
| 2168 | 2212 | } |
| 2169 | 2213 | |
| 2214 | + /// Empryo takes its model and effort as flags: a process that's idle | |
| 2215 | + /// stops, so the next message starts one with the new ones, and a busy | |
| 2216 | + /// one stops after its turn. | |
| 2217 | + fn relaunch_empryo(&mut self) { | |
| 2218 | + if self.process.is_none() { | |
| 2219 | + return; | |
| 2220 | + } | |
| 2221 | + if self.is_working() || self.resume_id.is_none() { | |
| 2222 | + self.empryo.relaunch = true; | |
| 2223 | + return; | |
| 2224 | + } | |
| 2225 | + self.process.take(); | |
| 2226 | + self._events_task = Task::ready(()); | |
| 2227 | + if self.status != SessionStatus::Exited { | |
| 2228 | + self.status = SessionStatus::Idle; | |
| 2229 | + } | |
| 2230 | + } | |
| 2231 | + | |
| 2170 | 2232 | pub fn set_permission_mode(&mut self, mode: PermissionMode, cx: &mut Context<Self>) { |
| 2171 | 2233 | if self.forward(ThreadAction::SetPermissionMode { mode: mode.as_cli().into() }) { |
| 2172 | 2234 | self.permission_mode = mode; |
| 2173 | 2235 | cx.notify(); |
| 2174 | 2236 | return; |
| 2175 | 2237 | } |
| 2238 | + // Empryo plans in its plan mode, a launch flag, and otherwise runs | |
| 2239 | + // its tools without asking. | |
| 2240 | + if self.provider == Provider::Empryo { | |
| 2241 | + self.permission_mode = mode; | |
| 2242 | + self.relaunch_empryo(); | |
| 2243 | + cx.notify(); | |
| 2244 | + return; | |
| 2245 | + } | |
| 2176 | 2246 | // Pi and jcode have no permission prompts to configure. |
| 2177 | 2247 | if !self.provider.asks_permission() { |
| 2178 | 2248 | return; |
| @@ -2255,6 +2325,7 @@ impl Session { | ||
| 2255 | 2325 | } |
| 2256 | 2326 | } |
| 2257 | 2327 | Provider::Jcode => {} |
| 2328 | + Provider::Empryo => self.relaunch_empryo(), | |
| 2258 | 2329 | } |
| 2259 | 2330 | cx.notify(); |
| 2260 | 2331 | } |
| @@ -2604,6 +2675,7 @@ impl Session { | ||
| 2604 | 2675 | Provider::Claude => self.handle_message(message, cx), |
| 2605 | 2676 | Provider::Pi => self.handle_pi_message(message, cx), |
| 2606 | 2677 | Provider::Jcode => self.handle_jcode_message(message, cx), |
| 2678 | + Provider::Empryo => self.handle_empryo_message(message, cx), | |
| 2607 | 2679 | }, |
| 2608 | 2680 | ProcessEvent::Stderr(line) => { |
| 2609 | 2681 | if self.stderr_tail.len() == STDERR_TAIL { |
| @@ -2616,6 +2688,9 @@ impl Session { | ||
| 2616 | 2688 | } |
| 2617 | 2689 | |
| 2618 | 2690 | fn handle_exit(&mut self, code: Option<i32>, cx: &mut Context<Self>) { |
| 2691 | + if self.provider == Provider::Empryo && self.empryo_exit(code, cx) { | |
| 2692 | + return; | |
| 2693 | + } | |
| 2619 | 2694 | self.flush_text(cx); |
| 2620 | 2695 | self.process = None; |
| 2621 | 2696 | self.drop_background_tasks(); |
| @@ -3678,6 +3753,184 @@ impl Session { | ||
| 3678 | 3753 | } |
| 3679 | 3754 | } |
| 3680 | 3755 | |
| 3756 | + // ---- Empryo (headless chat events) -------------------------------- | |
| 3757 | + | |
| 3758 | + fn handle_empryo_message(&mut self, message: Value, cx: &mut Context<Self>) { | |
| 3759 | + let text = |key: &str| message.get(key).and_then(Value::as_str).unwrap_or_default(); | |
| 3760 | + let number = |key: &str| message.get(key).and_then(Value::as_u64); | |
| 3761 | + match text("type") { | |
| 3762 | + "start" => { | |
| 3763 | + if let Some(model) = message.get("model").and_then(Value::as_str) { | |
| 3764 | + self.active_model = Some(model.to_string()); | |
| 3765 | + } | |
| 3766 | + } | |
| 3767 | + // Before each message it takes, the first time once the session | |
| 3768 | + // is loaded and there's something to resume. | |
| 3769 | + "ready" => { | |
| 3770 | + self.empryo.ready = true; | |
| 3771 | + if let Some(session) = self.empryo.new_session.take() { | |
| 3772 | + self.resume_id = Some(session); | |
| 3773 | + } | |
| 3774 | + if self.status == SessionStatus::Starting { | |
| 3775 | + self.status = SessionStatus::Idle; | |
| 3776 | + } | |
| 3777 | + } | |
| 3778 | + "turn-start" => self.begin_turn(cx), | |
| 3779 | + "text-start" => self.empryo.text = None, | |
| 3780 | + "text" => { | |
| 3781 | + self.begin_turn(cx); | |
| 3782 | + self.thinking = false; | |
| 3783 | + let ix = match self.empryo.text { | |
| 3784 | + Some(ix) => ix, | |
| 3785 | + None => { | |
| 3786 | + let ix = self.push_assistant_text(""); | |
| 3787 | + self.empryo.text = Some(ix); | |
| 3788 | + ix | |
| 3789 | + } | |
| 3790 | + }; | |
| 3791 | + self.append_text(ix, text("content"), cx); | |
| 3792 | + } | |
| 3793 | + "text-end" => { | |
| 3794 | + if let Some(ix) = self.empryo.text.take() { | |
| 3795 | + self.flush_item(ix, cx); | |
| 3796 | + } | |
| 3797 | + } | |
| 3798 | + "reasoning" => { | |
| 3799 | + self.begin_turn(cx); | |
| 3800 | + self.thinking = true; | |
| 3801 | + } | |
| 3802 | + "tool-call" => { | |
| 3803 | + self.begin_turn(cx); | |
| 3804 | + self.thinking = false; | |
| 3805 | + if let Some(ix) = self.empryo.text.take() { | |
| 3806 | + self.flush_item(ix, cx); | |
| 3807 | + } | |
| 3808 | + let input = message.get("input").cloned().unwrap_or(Value::Null); | |
| 3809 | + match self.find_tool_mut(text("toolCallId")) { | |
| 3810 | + Some(tool) => tool.input = input, | |
| 3811 | + None => { | |
| 3812 | + self.push_tool(text("toolCallId"), text("tool"), input); | |
| 3813 | + } | |
| 3814 | + } | |
| 3815 | + } | |
| 3816 | + "tool-result" => { | |
| 3817 | + let failed = message.get("ok").and_then(Value::as_bool) == Some(false); | |
| 3818 | + let summary = message | |
| 3819 | + .get("summary") | |
| 3820 | + .and_then(Value::as_str) | |
| 3821 | + .or_else(|| message.get("error").and_then(Value::as_str)) | |
| 3822 | + .unwrap_or_default() | |
| 3823 | + .to_string(); | |
| 3824 | + let tool = match self.find_tool_mut(text("toolCallId")) { | |
| 3825 | + Some(tool) => tool, | |
| 3826 | + None => { | |
| 3827 | + let ix = self.push_tool(text("toolCallId"), text("tool"), Value::Null); | |
| 3828 | + let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) else { return }; | |
| 3829 | + tool | |
| 3830 | + } | |
| 3831 | + }; | |
| 3832 | + tool.state = if failed { ToolState::Error } else { ToolState::Done }; | |
| 3833 | + tool.result = Some(summary); | |
| 3834 | + } | |
| 3835 | + "context-status" => { | |
| 3836 | + if let Some(tokens) = number("tokens").filter(|tokens| *tokens > 0) { | |
| 3837 | + self.context_tokens = Some(tokens); | |
| 3838 | + } | |
| 3839 | + if let Some(window) = number("contextWindow").filter(|window| *window > 0) { | |
| 3840 | + self.context_window = Some(window); | |
| 3841 | + } | |
| 3842 | + } | |
| 3843 | + // Every model call, the agent's own and its helpers'. | |
| 3844 | + "model-call" if text("lane") == "main" && text("state") == "ok" => { | |
| 3845 | + if let Some(model) = message.get("modelId").and_then(Value::as_str) { | |
| 3846 | + self.active_model = Some(model.to_string()); | |
| 3847 | + } | |
| 3848 | + let output = number("output").unwrap_or(0); | |
| 3849 | + let secs = number("durationMs").unwrap_or(0) as f64 / 1000.; | |
| 3850 | + // Too short a response says little about the model's speed. | |
| 3851 | + if output >= 16 && secs >= 0.25 { | |
| 3852 | + self.tokens_per_sec = Some(output as f64 / secs); | |
| 3853 | + } | |
| 3854 | + } | |
| 3855 | + "warning" => self.push_notice(NoticeKind::Info, text("message").to_string()), | |
| 3856 | + // Stopping a turn reports it as aborted. | |
| 3857 | + "error" if self.interrupt_requested => {} | |
| 3858 | + "error" => { | |
| 3859 | + let error = text("error"); | |
| 3860 | + self.push_notice(NoticeKind::Error, if error.is_empty() { "Empryo ran into an error." } else { error }.to_string()); | |
| 3861 | + } | |
| 3862 | + "turn-done" => { | |
| 3863 | + self.thinking = false; | |
| 3864 | + self.status = SessionStatus::Idle; | |
| 3865 | + if let Some(ix) = self.empryo.text.take() { | |
| 3866 | + self.flush_item(ix, cx); | |
| 3867 | + } | |
| 3868 | + if std::mem::take(&mut self.interrupt_requested) { | |
| 3869 | + self.push_notice(NoticeKind::Info, "Interrupted."); | |
| 3870 | + } | |
| 3871 | + self.finish_open_tools("The turn ended before the tool finished."); | |
| 3872 | + let cost = message.get("cost").and_then(Value::as_f64).filter(|cost| *cost > 0.); | |
| 3873 | + self.total_cost += cost.unwrap_or(0.); | |
| 3874 | + // Empryo's own count leaves out waiting for the Genome. | |
| 3875 | + let duration_ms = self | |
| 3876 | + .turn_started | |
| 3877 | + .map(|start| start.elapsed().as_millis() as u64) | |
| 3878 | + .or_else(|| number("duration")) | |
| 3879 | + .unwrap_or(0); | |
| 3880 | + self.finish_turn(duration_ms, cost, cx); | |
| 3881 | + if std::mem::take(&mut self.empryo.relaunch) { | |
| 3882 | + self.relaunch_empryo(); | |
| 3883 | + } | |
| 3884 | + self.send_next_queued(cx); | |
| 3885 | + } | |
| 3886 | + _ => {} | |
| 3887 | + } | |
| 3888 | + } | |
| 3889 | + | |
| 3890 | + /// Handles Empryo's process ending where that's part of how it works: | |
| 3891 | + /// stopping a turn ends the chat (Ctrl+C takes it down), and so does an | |
| 3892 | + /// idle chat. Returns whether it did; other exits are reported as | |
| 3893 | + /// failures. | |
| 3894 | + fn empryo_exit(&mut self, code: Option<i32>, cx: &mut Context<Self>) -> bool { | |
| 3895 | + // The turn may have ended, clearing the request, before the exit. | |
| 3896 | + let interrupted = std::mem::take(&mut self.interrupt_requested) | std::mem::take(&mut self.empryo.stopping); | |
| 3897 | + let ready = std::mem::take(&mut self.empryo.ready); | |
| 3898 | + self.empryo.text = None; | |
| 3899 | + // A session it couldn't open: carry on in a new one. | |
| 3900 | + if !ready && !interrupted && self.resume_id.is_some() && code == Some(1) { | |
| 3901 | + self.resume_id = None; | |
| 3902 | + self.process = None; | |
| 3903 | + self.thinking = false; | |
| 3904 | + self.push_notice( | |
| 3905 | + NoticeKind::Info, | |
| 3906 | + "Empryo couldn't reopen this thread's session; the next message starts a new one.", | |
| 3907 | + ); | |
| 3908 | + if self.is_working() { | |
| 3909 | + self.status = SessionStatus::Idle; | |
| 3910 | + self.turn_started = None; | |
| 3911 | + self.base_snapshot = None; | |
| 3912 | + } | |
| 3913 | + return true; | |
| 3914 | + } | |
| 3915 | + if !(interrupted || code == Some(0)) { | |
| 3916 | + return false; | |
| 3917 | + } | |
| 3918 | + self.flush_text(cx); | |
| 3919 | + self.process = None; | |
| 3920 | + self.thinking = false; | |
| 3921 | + self.finish_open_tools("The turn was stopped before the tool finished."); | |
| 3922 | + if self.is_working() { | |
| 3923 | + // It went before saying the turn was done. | |
| 3924 | + self.push_notice(NoticeKind::Info, "Interrupted."); | |
| 3925 | + let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64); | |
| 3926 | + self.status = SessionStatus::Idle; | |
| 3927 | + self.finish_turn(duration_ms, None, cx); | |
| 3928 | + } | |
| 3929 | + self.status = SessionStatus::Idle; | |
| 3930 | + self.send_next_queued(cx); | |
| 3931 | + true | |
| 3932 | + } | |
| 3933 | + | |
| 3681 | 3934 | /// Registers the app's tools with the thread's jcode session (a |
| 3682 | 3935 | /// subagent's thread gets none) and switches off jcode's own ways of |
| 3683 | 3936 | /// spawning agents. jcode keeps the setup in memory, so it's sent on |
| @@ -3689,6 +3942,10 @@ impl Session { | ||
| 3689 | 3942 | |
| 3690 | 3943 | /// Which of the app's tools this thread's agent gets. |
| 3691 | 3944 | pub fn tool_caps(&self) -> orchestrator::Caps { |
| 3945 | + // Empryo only loads MCP servers from its own config files. | |
| 3946 | + if self.provider == Provider::Empryo { | |
| 3947 | + return orchestrator::Caps::default(); | |
| 3948 | + } | |
| 3692 | 3949 | orchestrator::Caps { delegate: self.delegation.is_none(), devices: crate::device::agent_access_for(&self.project) } |
| 3693 | 3950 | } |
| 3694 | 3951 | |
| @@ -4915,6 +5172,25 @@ pub fn tool_field(input: &Value, keys: &[&str]) -> Option<String> { | ||
| 4915 | 5172 | } |
| 4916 | 5173 | |
| 4917 | 5174 | /// A one-line description of what a tool call is doing. |
| 5175 | + /// A message as one line of Empryo's chat: each line but the last ends in a | |
| 5176 | + /// backslash, which continues the message on the next. A line of the message | |
| 5177 | + /// that ends in a backslash itself gets a space after it. | |
| 5178 | + fn empryo_line(text: &str) -> String { | |
| 5179 | + let lines: Vec<&str> = text.trim().lines().map(|line| line.trim_end_matches('\r')).collect(); | |
| 5180 | + let last = lines.len().saturating_sub(1); | |
| 5181 | + let mut out = String::with_capacity(text.len() + lines.len()); | |
| 5182 | + for (ix, line) in lines.iter().enumerate() { | |
| 5183 | + out.push_str(line); | |
| 5184 | + if line.ends_with('\\') { | |
| 5185 | + out.push(' '); | |
| 5186 | + } | |
| 5187 | + if ix < last { | |
| 5188 | + out.push_str("\\\n"); | |
| 5189 | + } | |
| 5190 | + } | |
| 5191 | + out | |
| 5192 | + } | |
| 5193 | + | |
| 4918 | 5194 | pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String { |
| 4919 | 5195 | let field = |keys: &[&str]| tool_field(input, keys); |
| 4920 | 5196 | let path = |keys: &[&str]| { |
| @@ -4926,8 +5202,16 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String | ||
| 4926 | 5202 | }) |
| 4927 | 5203 | }; |
| 4928 | 5204 | let summary = match name.to_ascii_lowercase().as_str() { |
| 4929 | - "bash" => field(&["command"]), | |
| 4930 | - "read" | "write" | "edit" | "multiedit" => path(&["file_path", "path"]), | |
| 5205 | + "bash" | "shell" => field(&["command"]), | |
| 5206 | + "read" | "write" | "edit" | "multiedit" | "edit_file" | "multi_edit" | "ast_edit" => { | |
| 5207 | + path(&["file_path", "path"]).or_else(|| { | |
| 5208 | + // Empryo reads several files at once. | |
| 5209 | + let files = input.get("files")?.as_array()?; | |
| 5210 | + let paths: Vec<String> = files.iter().filter_map(|file| tool_field(file, &["path"])).collect(); | |
| 5211 | + (!paths.is_empty()).then(|| paths.join(", ")) | |
| 5212 | + }) | |
| 5213 | + } | |
| 5214 | + "list_dir" => path(&["path"]), | |
| 4931 | 5215 | "notebookedit" => path(&["notebook_path"]), |
| 4932 | 5216 | "glob" | "grep" | "find" => field(&["pattern", "query"]), |
| 4933 | 5217 | "ls" => path(&["path"]), |
| @@ -4956,7 +5240,10 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String | ||
| 4956 | 5240 | |
| 4957 | 5241 | #[cfg(test)] |
| 4958 | 5242 | mod tests { |
| 4959 | - use super::{AssistantText, TaskStatus, ToolCall, ToolState, finished_prefix, jcode_agent_status, pi_subagent_results}; | |
| 5243 | + use super::{ | |
| 5244 | + AssistantText, TaskStatus, ToolCall, ToolState, empryo_line, finished_prefix, jcode_agent_status, pi_subagent_results, | |
| 5245 | + tool_summary, | |
| 5246 | + }; | |
| 4960 | 5247 | use serde_json::{Value, json}; |
| 4961 | 5248 | |
| 4962 | 5249 | fn tool(name: &str, input: Value) -> ToolCall { |
| @@ -5039,4 +5326,20 @@ mod tests { | ||
| 5039 | 5326 | assert!(answer.view.is_none()); |
| 5040 | 5327 | assert_eq!(answer.text, "First.\n\nSecond"); |
| 5041 | 5328 | } |
| 5329 | + | |
| 5330 | + #[test] | |
| 5331 | + fn empryo_lines() { | |
| 5332 | + assert_eq!(empryo_line("hi"), "hi"); | |
| 5333 | + assert_eq!(empryo_line("one\r\n\nthree\n"), "one\\\n\\\nthree"); | |
| 5334 | + // A line ending in a backslash would otherwise run on. | |
| 5335 | + assert_eq!(empryo_line("C:\\\nnext"), "C:\\ \\\nnext"); | |
| 5336 | + } | |
| 5337 | + | |
| 5338 | + #[test] | |
| 5339 | + fn empryo_tool_summaries() { | |
| 5340 | + let cwd = std::path::Path::new("/repo"); | |
| 5341 | + assert_eq!(tool_summary("read", &json!({"files": [{"path": "a.rs"}, {"path": "b.rs"}]}), cwd), "a.rs, b.rs"); | |
| 5342 | + assert_eq!(tool_summary("shell", &json!({"command": "cargo test"}), cwd), "cargo test"); | |
| 5343 | + assert_eq!(tool_summary("edit_file", &json!({"path": "/repo/src/x.rs"}), cwd), "src/x.rs"); | |
| 5344 | + } | |
| 5042 | 5345 | } |
| @@ -208,6 +208,17 @@ fn generate(project: &Path, prompt: &str) -> Option<String> { | ||
| 208 | 208 | } |
| 209 | 209 | } |
| 210 | 210 | Provider::Jcode => return None, |
| 211 | + Provider::Empryo => { | |
| 212 | + // Without the project, and in a folder of its own: Empryo keeps | |
| 213 | + // a `.empryo` folder wherever it runs. | |
| 214 | + let dir = std::env::temp_dir().join("vectrum-code-empryo"); | |
| 215 | + let _ = std::fs::create_dir_all(&dir); | |
| 216 | + command.args(["--headless", "--bare", "--quiet", "--no-render", "--max-steps", "3", "--model", model]); | |
| 217 | + command.arg("--cwd").arg(dir).env("EMPRYO_NO_PROMPT", "1"); | |
| 218 | + if let Some(effort) = effort.filter(|effort| *effort >= Effort::Low) { | |
| 219 | + command.args(["--effort", effort.as_cli()]); | |
| 220 | + } | |
| 221 | + } | |
| 211 | 222 | } |
| 212 | 223 | let output = command |
| 213 | 224 | .arg(prompt) |
| @@ -145,13 +145,19 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation { | ||
| 145 | 145 | return Presentation { icon: Icon::new(Lucide::Smartphone), label, action: Action::Other }; |
| 146 | 146 | } |
| 147 | 147 | let (action, label) = match name.to_ascii_lowercase().as_str() { |
| 148 | - "bash" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())), | |
| 148 | + "bash" | "shell" => (Action::Command, field(&["command"]).map(first_line).unwrap_or_else(|| "Ran command".into())), | |
| 149 | 149 | "bashoutput" | "killshell" => (Action::Command, name.to_string()), |
| 150 | 150 | "read" | "notebookread" => ( |
| 151 | 151 | Action::Read, |
| 152 | - field(&["file_path", "path", "notebook_path"]).map(|path| format!("Read {}", relative_path(&path, cwd))).unwrap_or_else(|| "Read file".into()), | |
| 152 | + field(&["file_path", "path", "notebook_path"]) | |
| 153 | + .map(|path| relative_path(&path, cwd)) | |
| 154 | + // Empryo's read takes a list of files. | |
| 155 | + .or_else(|| Some(crate::session::tool_summary(name, input, cwd)).filter(|paths| !paths.is_empty())) | |
| 156 | + .map(|path| format!("Read {path}")) | |
| 157 | + .unwrap_or_else(|| "Read file".into()), | |
| 153 | 158 | ), |
| 154 | - "write" | "edit" | "multiedit" | "notebookedit" => ( | |
| 159 | + // Empryo's edits, by path or by symbol. | |
| 160 | + "edit_file" | "multi_edit" | "ast_edit" | "write" | "edit" | "multiedit" | "notebookedit" => ( | |
| 155 | 161 | Action::Edit, |
| 156 | 162 | field(&["file_path", "path", "notebook_path"]).map(|path| relative_path(&path, cwd)).unwrap_or_else(|| "Changed a file".into()), |
| 157 | 163 | ), |
| @@ -159,6 +165,15 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation { | ||
| 159 | 165 | Action::CodeSearch, |
| 160 | 166 | field(&["pattern", "query"]).map(|pattern| format!("Searched for {}", first_line(pattern))).unwrap_or_else(|| "Searched code".into()), |
| 161 | 167 | ), |
| 168 | + // Empryo's queries of its Genome and language servers. | |
| 169 | + "genome_query" | "genome_analyze" | "genome_impact" | "navigate" | "analyze" => { | |
| 170 | + let summary = crate::session::tool_summary(name, input, cwd); | |
| 171 | + let what = if name.starts_with("genome") { "Genome" } else { "Code intelligence" }; | |
| 172 | + (Action::CodeSearch, if summary.is_empty() { what.to_string() } else { format!("{what}: {summary}") }) | |
| 173 | + } | |
| 174 | + "list_dir" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())), | |
| 175 | + "web_search" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())), | |
| 176 | + "fetch_page" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())), | |
| 162 | 177 | "ls" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())), |
| 163 | 178 | "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())), |
| 164 | 179 | "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())), |
| @@ -21,6 +21,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 21 | 21 | Code, |
| 22 | 22 | Feather, |
| 23 | 23 | CornerDownRight, |
| 24 | + Dna, | |
| 24 | 25 | FileCode, |
| 25 | 26 | FileDiff, |
| 26 | 27 | FolderGit2, |
| @@ -28,6 +28,7 @@ pub fn provider_icon(provider: Provider, size: Pixels, p: &Palette) -> Icon { | ||
| 28 | 28 | Provider::Claude => Icon::new(IconName::Asterisk).size(size).text_color(rgba(CLAUDE_ORANGE)), |
| 29 | 29 | Provider::Pi => Icon::new(Lucide::Pi).size(size).text_color(p.ink), |
| 30 | 30 | Provider::Jcode => Icon::new(Lucide::Feather).size(size).text_color(p.ink), |
| 31 | + Provider::Empryo => Icon::new(Lucide::Dna).size(size).text_color(p.ink), | |
| 31 | 32 | } |
| 32 | 33 | } |
| 33 | 34 | |
| @@ -139,7 +140,8 @@ pub fn traits_items( | ||
| 139 | 140 | let mut items = Vec::new(); |
| 140 | 141 | if !levels.is_empty() || provider == Provider::Claude { |
| 141 | 142 | items.push(composer::menu_label(if provider == Provider::Pi { "Thinking" } else { "Reasoning" }, p).into_any_element()); |
| 142 | - if default.is_none() && provider == Provider::Claude { | |
| 143 | + // Empryo leaves effort to the model unless it's told one. | |
| 144 | + if default.is_none() && matches!(provider, Provider::Claude | Provider::Empryo) { | |
| 143 | 145 | items.push(item("traits-default".into(), Reasoning::Default, "Default", None, true)); |
| 144 | 146 | } |
| 145 | 147 | for (ix, level) in levels.into_iter().enumerate() { |
| @@ -493,7 +495,7 @@ impl ModelPicker { | ||
| 493 | 495 | let locked = !state.items.is_empty() && model.instance != state.instance; |
| 494 | 496 | let detail = match model.provider { |
| 495 | 497 | Provider::Claude => instance, |
| 496 | - Provider::Pi | Provider::Jcode => format!("{instance} · {}", model.detail), | |
| 498 | + Provider::Pi | Provider::Jcode | Provider::Empryo => format!("{instance} · {}", model.detail), | |
| 497 | 499 | }; |
| 498 | 500 | h_flex() |
| 499 | 501 | .id(SharedString::from(format!("model-{}", model.key()))) |
| @@ -593,6 +595,9 @@ impl ModelPicker { | ||
| 593 | 595 | } |
| 594 | 596 | (ProbeStatus::Missing, Provider::Claude) => "Open provider setup to install Claude on this environment.".into(), |
| 595 | 597 | (ProbeStatus::Missing, Provider::Jcode) => "Open provider setup to install jcode on this environment.".into(), |
| 598 | + (ProbeStatus::Missing, Provider::Empryo) => { | |
| 599 | + "Open provider setup to install Empryo on this environment: curl -fsSL https://empryo.com/install.sh | bash".into() | |
| 600 | + } | |
| 596 | 601 | (ProbeStatus::Failed(error), _) => format!("{label} provider is unavailable. {error}"), |
| 597 | 602 | (ProbeStatus::Ready, _) => "No models are available for this provider.".into(), |
| 598 | 603 | }; |
| @@ -134,7 +134,7 @@ impl SettingsView { | ||
| 134 | 134 | .row( |
| 135 | 135 | row("Permissions") |
| 136 | 136 | .id("default-permissions") |
| 137 | - .description("Default permissions for new threads. Pi and jcode threads always run with full access.") | |
| 137 | + .description("Default permissions for new threads. Pi, jcode and Empryo threads always run with full access.") | |
| 138 | 138 | .reset(reset(&view, cx, |s| s.default_access.clone(), |s, access| s.default_access = access)) |
| 139 | 139 | .control(rich_choice("default-access", current_access, permissions, &view, |settings, mode| { |
| 140 | 140 | settings.default_access = mode.map(|mode: PermissionMode| mode.as_cli().to_string()) |
| @@ -92,8 +92,10 @@ fn status_headline(state: &InstanceState) -> (Led, String) { | ||
| 92 | 92 | match state.kind() { |
| 93 | 93 | Provider::Claude if state.account.is_some() => parts.push("Authenticated".to_string()), |
| 94 | 94 | Provider::Claude => parts.push("Not authenticated".to_string()), |
| 95 | - Provider::Pi | Provider::Jcode if state.models.is_empty() => parts.push("Needs attention · No models configured".into()), | |
| 96 | - Provider::Pi | Provider::Jcode => parts.push("Available".to_string()), | |
| 95 | + Provider::Pi | Provider::Jcode | Provider::Empryo if state.models.is_empty() => { | |
| 96 | + parts.push("Needs attention · No models configured".into()) | |
| 97 | + } | |
| 98 | + Provider::Pi | Provider::Jcode | Provider::Empryo => parts.push("Available".to_string()), | |
| 97 | 99 | } |
| 98 | 100 | parts.extend(state.plan.clone()); |
| 99 | 101 | let led = if parts[0].starts_with("Not") || parts[0].starts_with("Needs") { Led::Waiting } else { Led::Live }; |
| @@ -147,6 +149,7 @@ fn home_title(kind: Provider) -> &'static str { | ||
| 147 | 149 | Provider::Claude => "CLAUDE_CONFIG_DIR path", |
| 148 | 150 | Provider::Pi => "PI_CODING_AGENT_DIR path", |
| 149 | 151 | Provider::Jcode => "JCODE_HOME path", |
| 152 | + Provider::Empryo => "HOME path", | |
| 150 | 153 | } |
| 151 | 154 | } |
| 152 | 155 | |
| @@ -155,6 +158,7 @@ fn home_placeholder(kind: Provider) -> &'static str { | ||
| 155 | 158 | Provider::Claude => "~/.claude", |
| 156 | 159 | Provider::Pi => "~/.pi/agent", |
| 157 | 160 | Provider::Jcode => "~/.jcode", |
| 161 | + Provider::Empryo => "~/empryo-home", | |
| 158 | 162 | } |
| 159 | 163 | } |
| 160 | 164 | |
| @@ -163,6 +167,7 @@ fn custom_placeholder(kind: Provider) -> &'static str { | ||
| 163 | 167 | Provider::Claude => "claude-sonnet-5", |
| 164 | 168 | Provider::Pi => "anthropic/claude-sonnet-5", |
| 165 | 169 | Provider::Jcode => "claude-oauth:claude-opus-5", |
| 170 | + Provider::Empryo => "anthropic/claude-sonnet-5", | |
| 166 | 171 | } |
| 167 | 172 | } |
| 168 | 173 | |
| @@ -824,6 +829,10 @@ impl SettingsView { | ||
| 824 | 829 | (Provider::Claude, None, _) => "Not signed in. Run claude in a terminal and log in, then refresh.".into(), |
| 825 | 830 | (Provider::Pi, _, _) => "Pi signs in to model providers itself: run pi in a terminal and use /login.".into(), |
| 826 | 831 | (Provider::Jcode, _, _) => "jcode signs in itself: run jcode login in a terminal.".into(), |
| 832 | + (Provider::Empryo, _, _) => { | |
| 833 | + "Empryo signs in itself: run empryo --login claude (or codex, copilot…), or save a key with empryo --set-key <provider> <key>." | |
| 834 | + .into() | |
| 835 | + } | |
| 827 | 836 | }; |
| 828 | 837 | let setup = Section::new("Setup").row(row("Account").description(account)).render(cx); |
| 829 | 838 | |
| @@ -836,6 +845,7 @@ impl SettingsView { | ||
| 836 | 845 | Provider::Claude => "Path to the Claude binary used by this instance.", |
| 837 | 846 | Provider::Pi => "Path to the Pi coding agent binary.", |
| 838 | 847 | Provider::Jcode => "Path to the jcode binary used by this instance.", |
| 848 | + Provider::Empryo => "Path to the empryo binary used by this instance.", | |
| 839 | 849 | }) |
| 840 | 850 | .status(binary_status) |
| 841 | 851 | .control(text_field(&form.binary, 224., window, cx)), |
| @@ -846,6 +856,9 @@ impl SettingsView { | ||
| 846 | 856 | Provider::Claude => "Custom Claude home and config directory. Keeps .claude.json and .claude separate.", |
| 847 | 857 | Provider::Pi => "Custom Pi agent directory, so this instance keeps its own logins, settings and sessions.", |
| 848 | 858 | Provider::Jcode => "Custom jcode home, so this instance keeps its own logins and sessions.", |
| 859 | + Provider::Empryo => { | |
| 860 | + "A folder Empryo runs with as its HOME, so this instance keeps its own ~/.empryo: keys, settings and sign-ins. Its commands see it too." | |
| 861 | + } | |
| 849 | 862 | }) |
| 850 | 863 | .control(text_field(&form.home, 224., window, cx)), |
| 851 | 864 | ); |
| @@ -863,6 +876,7 @@ impl SettingsView { | ||
| 863 | 876 | Provider::Claude => "Additional CLI arguments passed on session start.", |
| 864 | 877 | Provider::Pi => "Additional CLI arguments passed to pi --mode rpc on session start.", |
| 865 | 878 | Provider::Jcode => "Additional CLI arguments passed to jcode ahead of api --stdio on session start.", |
| 879 | + Provider::Empryo => "Additional CLI arguments passed to empryo --headless --chat on session start.", | |
| 866 | 880 | }) |
| 867 | 881 | .control(text_field(&form.args, 224., window, cx)), |
| 868 | 882 | ); |
| @@ -816,7 +816,7 @@ pub(crate) fn model_control( | ||
| 816 | 816 | let name = match model.provider { |
| 817 | 817 | Provider::Claude if model.legacy => format!("{} (legacy)", model.name), |
| 818 | 818 | Provider::Claude => model.name.clone(), |
| 819 | - Provider::Pi | Provider::Jcode => format!("{} · {}", model.name, model.detail), | |
| 819 | + Provider::Pi | Provider::Jcode | Provider::Empryo => format!("{} · {}", model.name, model.detail), | |
| 820 | 820 | }; |
| 821 | 821 | (model.key(), name) |
| 822 | 822 | }) |
| @@ -136,7 +136,7 @@ impl UsageView { | ||
| 136 | 136 | Provider::Claude => sources.claude_dirs.push((label.clone(), home)), |
| 137 | 137 | Provider::Pi => sources.pi_dirs.push((label.clone(), home)), |
| 138 | 138 | // jcode keeps no usage logs this page reads. |
| 139 | - Provider::Jcode => {} | |
| 139 | + Provider::Jcode | Provider::Empryo => {} | |
| 140 | 140 | } |
| 141 | 141 | if let Some(usage) = &state.usage { |
| 142 | 142 | limits.push((SharedString::from(label), usage.clone())); |
| @@ -52,6 +52,7 @@ use crate::{ | ||
| 52 | 52 | mod actions; |
| 53 | 53 | mod delegation; |
| 54 | 54 | mod devices; |
| 55 | + mod genome; | |
| 55 | 56 | mod git_actions; |
| 56 | 57 | mod header; |
| 57 | 58 | mod palette; |
| @@ -261,6 +262,8 @@ pub struct Workspace { | ||
| 261 | 262 | right_panels: HashMap<EntityId, surfaces::Surface>, |
| 262 | 263 | /// The right panel while no thread is open. |
| 263 | 264 | idle_right_panel: Option<surfaces::Surface>, |
| 265 | + /// The Genome map, while it shows: Empryo's map of a checkout. | |
| 266 | + genome: Option<genome::GenomePanel>, | |
| 264 | 267 | /// The surface picker's focus, for its keys. |
| 265 | 268 | surface_focus: FocusHandle, |
| 266 | 269 | settled_open: bool, |
| @@ -435,6 +438,7 @@ impl Workspace { | ||
| 435 | 438 | panel_open: true, |
| 436 | 439 | right_panels: HashMap::new(), |
| 437 | 440 | idle_right_panel: None, |
| 441 | + genome: None, | |
| 438 | 442 | surface_focus: cx.focus_handle(), |
| 439 | 443 | settled_open: false, |
| 440 | 444 | snoozed_open: false, |
| @@ -2255,6 +2259,15 @@ impl Render for Workspace { | ||
| 2255 | 2259 | let floating_device = self.render_floating_device(cx); |
| 2256 | 2260 | let p = metal::palette(cx); |
| 2257 | 2261 | let subagents_visible = self.surface_open(surfaces::Surface::Subagents); |
| 2262 | + // The map follows the open thread to its checkout, and is only for Empryo's. | |
| 2263 | + if self.surface_open(surfaces::Surface::Genome) && !self.genome_available(cx) { | |
| 2264 | + self.set_right_panel(None); | |
| 2265 | + } | |
| 2266 | + self.sync_genome(cx); | |
| 2267 | + let genome_visible = self.surface_open(surfaces::Surface::Genome) && !self.settings_open; | |
| 2268 | + if !self.surface_open(surfaces::Surface::Genome) { | |
| 2269 | + self.genome = None; | |
| 2270 | + } | |
| 2258 | 2271 | let picker_visible = self.surface_open(surfaces::Surface::Picker) && !self.settings_open; |
| 2259 | 2272 | let docked_terminal = |
| 2260 | 2273 | if self.surface_open(surfaces::Surface::Terminal) { self.render_terminal_drawer(true, window, cx) } else { None }; |
| @@ -2263,7 +2276,11 @@ impl Render for Workspace { | ||
| 2263 | 2276 | && self.active_project().is_some() |
| 2264 | 2277 | && window.viewport_size().width >= px(PANEL_MIN_WINDOW); |
| 2265 | 2278 | // The browser gives way before the conversation gets too narrow. |
| 2266 | - let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() { px(360.) } else { px(0.) }; | |
| 2279 | + let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() || genome_visible { | |
| 2280 | + px(360.) | |
| 2281 | + } else { | |
| 2282 | + px(0.) | |
| 2283 | + }; | |
| 2267 | 2284 | let mut drawer = self.render_terminal_drawer(false, window, cx); |
| 2268 | 2285 | let drawer_full_width = panel_visible; |
| 2269 | 2286 | let wallpaper = self.render_start_wallpaper(chat.is_some(), cx); |
| @@ -2430,6 +2447,7 @@ impl Render for Workspace { | ||
| 2430 | 2447 | ) |
| 2431 | 2448 | .when(panel_visible, |this| this.child(self.render_panel(cx))) |
| 2432 | 2449 | .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx))) |
| 2450 | + .when(genome_visible, |this| this.child(self.render_genome_panel(cx))) | |
| 2433 | 2451 | .when(picker_visible, |this| this.child(self.render_surface_picker(cx))) |
| 2434 | 2452 | .children(docked_terminal) |
| 2435 | 2453 | .when_some(browser, |this, browser| this.child(browser)) |
| @@ -0,0 +1,537 @@ | ||
| 1 | + //! The Genome map: Empryo's ranked map of the open thread's checkout, read | |
| 2 | + //! from the `.empryo/genome.db` Empryo keeps there. A surface of the right | |
| 3 | + //! panel that the sidebar offers while the thread runs on Empryo. The graph | |
| 4 | + //! shows the highest ranked files and which import which; the list below | |
| 5 | + //! ranks them, and a file opens to what it exports. | |
| 6 | + | |
| 7 | + use std::path::{Path, PathBuf}; | |
| 8 | + use std::rc::Rc; | |
| 9 | + use std::sync::Arc; | |
| 10 | + use std::time::{Duration, SystemTime}; | |
| 11 | + | |
| 12 | + use gpui_kit::component::{Icon, IconName, h_flex, v_flex}; | |
| 13 | + use gpui_kit::prelude::FluentBuilder as _; | |
| 14 | + use gpui_kit::*; | |
| 15 | + | |
| 16 | + use super::Workspace; | |
| 17 | + use super::surfaces::Surface; | |
| 18 | + use crate::genome::{self, Genome, GenomeFile}; | |
| 19 | + use crate::models::Provider; | |
| 20 | + use crate::ui::icons::Lucide; | |
| 21 | + use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; | |
| 22 | + | |
| 23 | + pub(super) const GENOME_PANEL_WIDTH: f32 = 440.; | |
| 24 | + /// The graph's size inside the panel. | |
| 25 | + const MAP_WIDTH: f32 = GENOME_PANEL_WIDTH - 12. - 12. - 16.; | |
| 26 | + const MAP_HEIGHT: f32 = 300.; | |
| 27 | + /// How often an open map looks for Empryo's updates. | |
| 28 | + const WATCH_EVERY: Duration = Duration::from_secs(3); | |
| 29 | + /// How many files the map labels by name. | |
| 30 | + const LABELED: usize = 6; | |
| 31 | + | |
| 32 | + /// An import line in the map: from, to, and whether it's lit. | |
| 33 | + type MapLine = ((f32, f32), (f32, f32), bool); | |
| 34 | + | |
| 35 | + /// What the Genome map shows for a checkout. | |
| 36 | + enum GenomeLoad { | |
| 37 | + Loading, | |
| 38 | + /// Empryo hasn't mapped this checkout yet. | |
| 39 | + Missing, | |
| 40 | + Failed(String), | |
| 41 | + Ready(Arc<Genome>), | |
| 42 | + } | |
| 43 | + | |
| 44 | + pub(super) struct GenomePanel { | |
| 45 | + /// The checkout it maps. | |
| 46 | + cwd: PathBuf, | |
| 47 | + load: GenomeLoad, | |
| 48 | + /// When the database was last written, as of the last read. | |
| 49 | + read_at: Option<SystemTime>, | |
| 50 | + reading: bool, | |
| 51 | + /// The file opened in the list, by path, so it stays open across reloads. | |
| 52 | + selected: Option<String>, | |
| 53 | + hovered: Option<usize>, | |
| 54 | + _watch: Task<()>, | |
| 55 | + } | |
| 56 | + | |
| 57 | + impl Workspace { | |
| 58 | + /// Whether the open thread (or the new thread's draft) runs on Empryo in | |
| 59 | + /// a checkout on this device, where its Genome can be read. | |
| 60 | + pub(super) fn genome_available(&self, cx: &App) -> bool { | |
| 61 | + let local = self.active_project().is_some_and(|project| project.environment.is_none()); | |
| 62 | + let provider = match &self.active { | |
| 63 | + Some(session) => { | |
| 64 | + let session = session.read(cx); | |
| 65 | + if session.remote.is_some() { | |
| 66 | + return false; | |
| 67 | + } | |
| 68 | + session.provider | |
| 69 | + } | |
| 70 | + None => self.current_settings(cx).provider, | |
| 71 | + }; | |
| 72 | + local && provider == Provider::Empryo | |
| 73 | + } | |
| 74 | + | |
| 75 | + /// The checkout the open thread works in. | |
| 76 | + fn genome_cwd(&self, cx: &App) -> Option<PathBuf> { | |
| 77 | + match &self.active { | |
| 78 | + Some(session) => Some(session.read(cx).cwd.clone()), | |
| 79 | + None => self.active_project().map(|project| project.path.clone()), | |
| 80 | + } | |
| 81 | + } | |
| 82 | + | |
| 83 | + /// Keeps the map on the open thread's checkout while it shows: a thread | |
| 84 | + /// in another checkout loads that one's. | |
| 85 | + pub(super) fn sync_genome(&mut self, cx: &mut Context<Self>) { | |
| 86 | + if !self.surface_open(Surface::Genome) || self.settings_open { | |
| 87 | + return; | |
| 88 | + } | |
| 89 | + let Some(cwd) = self.genome_cwd(cx) else { return }; | |
| 90 | + if self.genome.as_ref().is_some_and(|panel| panel.cwd == cwd) { | |
| 91 | + return; | |
| 92 | + } | |
| 93 | + let watch = cx.spawn(async move |this, cx| { | |
| 94 | + loop { | |
| 95 | + cx.background_executor().timer(WATCH_EVERY).await; | |
| 96 | + let alive = this.update(cx, |this, cx| { | |
| 97 | + let Some(panel) = this.genome.as_ref() else { return }; | |
| 98 | + if panel.reading || !this.surface_open(Surface::Genome) { | |
| 99 | + return; | |
| 100 | + } | |
| 101 | + let written = written_at(&panel.cwd); | |
| 102 | + if written != panel.read_at { | |
| 103 | + this.read_genome(cx); | |
| 104 | + } | |
| 105 | + }); | |
| 106 | + if alive.is_err() { | |
| 107 | + return; | |
| 108 | + } | |
| 109 | + } | |
| 110 | + }); | |
| 111 | + self.genome = Some(GenomePanel { | |
| 112 | + cwd, | |
| 113 | + load: GenomeLoad::Loading, | |
| 114 | + read_at: None, | |
| 115 | + reading: false, | |
| 116 | + selected: None, | |
| 117 | + hovered: None, | |
| 118 | + _watch: watch, | |
| 119 | + }); | |
| 120 | + self.read_genome(cx); | |
| 121 | + } | |
| 122 | + | |
| 123 | + /// Reads the Genome again, in the background. | |
| 124 | + fn read_genome(&mut self, cx: &mut Context<Self>) { | |
| 125 | + let Some(panel) = self.genome.as_mut() else { return }; | |
| 126 | + panel.reading = true; | |
| 127 | + let cwd = panel.cwd.clone(); | |
| 128 | + let read = cx.background_executor().spawn({ | |
| 129 | + let cwd = cwd.clone(); | |
| 130 | + async move { (written_at(&cwd), genome::load(&cwd)) } | |
| 131 | + }); | |
| 132 | + cx.spawn(async move |this, cx| { | |
| 133 | + let (written, result) = read.await; | |
| 134 | + let _ = this.update(cx, |this, cx| { | |
| 135 | + let Some(panel) = this.genome.as_mut().filter(|panel| panel.cwd == cwd) else { return }; | |
| 136 | + panel.reading = false; | |
| 137 | + panel.read_at = written; | |
| 138 | + panel.hovered = None; | |
| 139 | + panel.load = match result { | |
| 140 | + Ok(Some(genome)) => GenomeLoad::Ready(Arc::new(genome)), | |
| 141 | + Ok(None) => GenomeLoad::Missing, | |
| 142 | + Err(err) => GenomeLoad::Failed(format!("{err:#}")), | |
| 143 | + }; | |
| 144 | + cx.notify(); | |
| 145 | + }); | |
| 146 | + }) | |
| 147 | + .detach(); | |
| 148 | + cx.notify(); | |
| 149 | + } | |
| 150 | + | |
| 151 | + /// Opens the Genome map in the right panel, or closes it. | |
| 152 | + pub(super) fn toggle_genome(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 153 | + self.toggle_surface(Surface::Genome, window, cx); | |
| 154 | + } | |
| 155 | + | |
| 156 | + fn select_genome_file(&mut self, path: String, cx: &mut Context<Self>) { | |
| 157 | + if let Some(panel) = self.genome.as_mut() { | |
| 158 | + panel.selected = if panel.selected.as_deref() == Some(path.as_str()) { None } else { Some(path) }; | |
| 159 | + cx.notify(); | |
| 160 | + } | |
| 161 | + } | |
| 162 | + | |
| 163 | + pub(super) fn render_genome_panel(&self, cx: &mut Context<Self>) -> impl IntoElement { | |
| 164 | + let p = metal::palette(cx); | |
| 165 | + let panel = self.genome.as_ref(); | |
| 166 | + let reading = panel.is_some_and(|panel| panel.reading); | |
| 167 | + let genome = panel.and_then(|panel| match &panel.load { | |
| 168 | + GenomeLoad::Ready(genome) => Some(genome.clone()), | |
| 169 | + _ => None, | |
| 170 | + }); | |
| 171 | + | |
| 172 | + let header = h_flex() | |
| 173 | + .h(px(36.)) | |
| 174 | + .pl(px(10.)) | |
| 175 | + .gap(px(10.)) | |
| 176 | + .child(Icon::new(Lucide::Dna).size(px(16.)).text_color(p.icon)) | |
| 177 | + .child(div().flex_1().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child("Genome")) | |
| 178 | + .when_some(genome.as_ref(), |this, genome| { | |
| 179 | + this.child(metal::tag(format!("{} files · {} symbols", genome.total_files, genome.total_symbols), p)) | |
| 180 | + }) | |
| 181 | + .child( | |
| 182 | + MetalButton::new("reload-genome") | |
| 183 | + .ghost() | |
| 184 | + .mini() | |
| 185 | + .icon(Lucide::RotateCcw) | |
| 186 | + .tooltip(if reading { "Reading the Genome…" } else { "Read the Genome again" }) | |
| 187 | + .on_click(cx.listener(|this, _, _, cx| this.read_genome(cx))), | |
| 188 | + ) | |
| 189 | + .child( | |
| 190 | + MetalButton::new("close-genome") | |
| 191 | + .ghost() | |
| 192 | + .mini() | |
| 193 | + .icon(IconName::Close) | |
| 194 | + .tooltip("Close the Genome map") | |
| 195 | + .on_click(cx.listener(|this, _, _, cx| { | |
| 196 | + this.set_right_panel(None); | |
| 197 | + cx.notify(); | |
| 198 | + })), | |
| 199 | + ); | |
| 200 | + | |
| 201 | + let body: AnyElement = match (panel.map(|panel| &panel.load), &genome) { | |
| 202 | + (_, Some(genome)) => self.render_genome(genome.clone(), p, cx).into_any_element(), | |
| 203 | + (Some(GenomeLoad::Failed(error)), _) => { | |
| 204 | + genome_message("Couldn't read the Genome", error.clone(), p).into_any_element() | |
| 205 | + } | |
| 206 | + (Some(GenomeLoad::Missing), _) => genome_message( | |
| 207 | + "No Genome here yet", | |
| 208 | + "Empryo maps a checkout when a thread starts in it. Send this thread a message and the map appears here." | |
| 209 | + .to_string(), | |
| 210 | + p, | |
| 211 | + ) | |
| 212 | + .into_any_element(), | |
| 213 | + _ => genome_message("Reading the Genome…", String::new(), p).into_any_element(), | |
| 214 | + }; | |
| 215 | + | |
| 216 | + v_flex() | |
| 217 | + .w(px(GENOME_PANEL_WIDTH)) | |
| 218 | + .flex_none() | |
| 219 | + .h_full() | |
| 220 | + .p(px(12.)) | |
| 221 | + .pl(px(0.)) | |
| 222 | + .child( | |
| 223 | + v_flex() | |
| 224 | + .size_full() | |
| 225 | + .p(px(6.)) | |
| 226 | + .rounded(px(18.)) | |
| 227 | + .raised(p) | |
| 228 | + .child(header) | |
| 229 | + .child(div().px(px(8.)).py(px(4.)).child(metal::rule(p))) | |
| 230 | + .child(body), | |
| 231 | + ) | |
| 232 | + } | |
| 233 | + | |
| 234 | + fn render_genome(&self, genome: Arc<Genome>, p: &Palette, cx: &mut Context<Self>) -> impl IntoElement { | |
| 235 | + let panel = self.genome.as_ref(); | |
| 236 | + let selected = panel.and_then(|panel| panel.selected.clone()); | |
| 237 | + let selected_ix = selected.as_ref().and_then(|path| genome.files.iter().position(|file| &file.path == path)); | |
| 238 | + let hovered = panel.and_then(|panel| panel.hovered); | |
| 239 | + let focus = hovered.or(selected_ix); | |
| 240 | + let updated = panel.and_then(|panel| panel.read_at).map(crate::clock::when); | |
| 241 | + | |
| 242 | + let rows: Vec<AnyElement> = genome | |
| 243 | + .files | |
| 244 | + .iter() | |
| 245 | + .enumerate() | |
| 246 | + .map(|(ix, file)| self.render_genome_row(ix, file, selected_ix == Some(ix), p, cx).into_any_element()) | |
| 247 | + .collect(); | |
| 248 | + | |
| 249 | + v_flex() | |
| 250 | + .flex_1() | |
| 251 | + .min_h_0() | |
| 252 | + .gap(px(6.)) | |
| 253 | + .child(div().px(px(2.)).child(self.render_genome_map(genome.clone(), focus, p, cx))) | |
| 254 | + .child( | |
| 255 | + h_flex() | |
| 256 | + .px(px(10.)) | |
| 257 | + .gap(px(8.)) | |
| 258 | + .text_size(px(11.5)) | |
| 259 | + .text_color(p.ink3) | |
| 260 | + .child(div().flex_1().child(format!( | |
| 261 | + "Top {} of {} files by rank · {} imports", | |
| 262 | + genome.files.len(), | |
| 263 | + genome.total_files, | |
| 264 | + genome.total_edges | |
| 265 | + ))) | |
| 266 | + .when_some(updated, |this, updated| this.child(format!("updated {updated}"))), | |
| 267 | + ) | |
| 268 | + .child(div().px(px(8.)).child(metal::rule(p))) | |
| 269 | + .child(v_flex().id("genome-files").flex_1().min_h_0().overflow_y_scroll().gap(px(1.)).children(rows)) | |
| 270 | + } | |
| 271 | + | |
| 272 | + /// The import graph of the top files: a dot per file, sized by rank and | |
| 273 | + /// colored by folder, with a line for each import. The file under the | |
| 274 | + /// pointer, or the one open in the list, lights up with its neighbors. | |
| 275 | + fn render_genome_map(&self, genome: Arc<Genome>, focus: Option<usize>, p: &Palette, cx: &mut Context<Self>) -> impl IntoElement { | |
| 276 | + let max_rank = genome.files.iter().map(|file| file.rank).fold(0., f64::max).max(f64::EPSILON); | |
| 277 | + let radius = |file: &GenomeFile| 4. + 10. * (file.rank / max_rank).sqrt() as f32; | |
| 278 | + let inset = 18.; | |
| 279 | + let spot = |ix: usize| { | |
| 280 | + let (x, y) = genome.layout[ix]; | |
| 281 | + (inset + x * (MAP_WIDTH - 2. * inset), inset + y * (MAP_HEIGHT - 2. * inset)) | |
| 282 | + }; | |
| 283 | + let neighbors: Vec<usize> = focus | |
| 284 | + .map(|focus| { | |
| 285 | + genome | |
| 286 | + .edges | |
| 287 | + .iter() | |
| 288 | + .filter_map(|edge| match (edge.from == focus, edge.to == focus) { | |
| 289 | + (true, _) => Some(edge.to), | |
| 290 | + (_, true) => Some(edge.from), | |
| 291 | + _ => None, | |
| 292 | + }) | |
| 293 | + .collect() | |
| 294 | + }) | |
| 295 | + .unwrap_or_default(); | |
| 296 | + | |
| 297 | + // The import lines, painted under the dots. | |
| 298 | + let lines: Rc<Vec<MapLine>> = Rc::new( | |
| 299 | + genome | |
| 300 | + .edges | |
| 301 | + .iter() | |
| 302 | + .map(|edge| (spot(edge.from), spot(edge.to), focus.is_some_and(|focus| edge.from == focus || edge.to == focus))) | |
| 303 | + .collect(), | |
| 304 | + ); | |
| 305 | + let (dim, lit) = (p.ink3.opacity(0.28), p.tints[2].opacity(0.9)); | |
| 306 | + let edges = canvas( | |
| 307 | + |_, _, _| {}, | |
| 308 | + move |bounds, _, window, _| { | |
| 309 | + for ((x1, y1), (x2, y2), on) in lines.iter() { | |
| 310 | + let mut path = PathBuilder::stroke(px(if *on { 1.6 } else { 1. })); | |
| 311 | + let from = bounds.origin + point(px(*x1), px(*y1)); | |
| 312 | + let to = bounds.origin + point(px(*x2), px(*y2)); | |
| 313 | + // A slight bend tells crossing lines apart. | |
| 314 | + let mid = point((from.x + to.x) / 2. + (to.y - from.y) * 0.12, (from.y + to.y) / 2. - (to.x - from.x) * 0.12); | |
| 315 | + path.move_to(from); | |
| 316 | + path.curve_to(to, mid); | |
| 317 | + if let Ok(path) = path.build() { | |
| 318 | + window.paint_path(path, if *on { lit } else { dim }); | |
| 319 | + } | |
| 320 | + } | |
| 321 | + }, | |
| 322 | + ) | |
| 323 | + .absolute() | |
| 324 | + .size_full(); | |
| 325 | + | |
| 326 | + let dots = genome.files.iter().enumerate().map(|(ix, file)| { | |
| 327 | + let (x, y) = spot(ix); | |
| 328 | + let r = radius(file); | |
| 329 | + let color = folder_color(&file.path, p); | |
| 330 | + let on = focus == Some(ix); | |
| 331 | + let near = neighbors.contains(&ix); | |
| 332 | + let faded = focus.is_some() && !on && !near; | |
| 333 | + let path = file.path.clone(); | |
| 334 | + div() | |
| 335 | + .id(SharedString::from(format!("genome-dot-{ix}"))) | |
| 336 | + .absolute() | |
| 337 | + .left(px(x - r)) | |
| 338 | + .top(px(y - r)) | |
| 339 | + .size(px(r * 2.)) | |
| 340 | + .rounded_full() | |
| 341 | + .bg(if faded { color.opacity(0.3) } else { color }) | |
| 342 | + .when(on, |this| this.border_2().border_color(p.ink)) | |
| 343 | + .when(near && !on, |this| this.border_1().border_color(p.tints[2])) | |
| 344 | + .cursor_pointer() | |
| 345 | + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { | |
| 346 | + if let Some(panel) = this.genome.as_mut() { | |
| 347 | + if *hovered { | |
| 348 | + panel.hovered = Some(ix); | |
| 349 | + } else if panel.hovered == Some(ix) { | |
| 350 | + panel.hovered = None; | |
| 351 | + } | |
| 352 | + cx.notify(); | |
| 353 | + } | |
| 354 | + })) | |
| 355 | + .on_click(cx.listener(move |this, _, _, cx| this.select_genome_file(path.clone(), cx))) | |
| 356 | + }); | |
| 357 | + | |
| 358 | + // Names for the top files, and for the one in focus. | |
| 359 | + let labeled: Vec<usize> = (0..genome.files.len().min(LABELED)).chain(focus).collect(); | |
| 360 | + let labels = labeled.into_iter().enumerate().filter(|(n, ix)| *n < LABELED || !(0..LABELED).contains(ix)).map(|(_, ix)| { | |
| 361 | + let file = &genome.files[ix]; | |
| 362 | + let (x, y) = spot(ix); | |
| 363 | + let r = radius(file); | |
| 364 | + let name = file_name(&file.path); | |
| 365 | + let width = (name.chars().count() as f32 * 6.2 + 10.).min(160.); | |
| 366 | + // Labels stay inside the map. | |
| 367 | + let left = (x - width / 2.).clamp(2., MAP_WIDTH - width - 2.); | |
| 368 | + let below = y + r + 18. < MAP_HEIGHT; | |
| 369 | + div() | |
| 370 | + .absolute() | |
| 371 | + .left(px(left)) | |
| 372 | + .top(px(if below { y + r + 2. } else { y - r - 17. })) | |
| 373 | + .w(px(width)) | |
| 374 | + .flex() | |
| 375 | + .justify_center() | |
| 376 | + .child( | |
| 377 | + div() | |
| 378 | + .px(px(4.)) | |
| 379 | + .rounded(px(4.)) | |
| 380 | + .bg(p.page.opacity(0.75)) | |
| 381 | + .text_size(px(10.5)) | |
| 382 | + .font_family(metal::MONO) | |
| 383 | + .text_color(if focus == Some(ix) { p.ink } else { p.ink2 }) | |
| 384 | + .truncate() | |
| 385 | + .child(name), | |
| 386 | + ) | |
| 387 | + }); | |
| 388 | + | |
| 389 | + div() | |
| 390 | + .relative() | |
| 391 | + .w(px(MAP_WIDTH)) | |
| 392 | + .h(px(MAP_HEIGHT)) | |
| 393 | + .mx(px(6.)) | |
| 394 | + .rounded(px(12.)) | |
| 395 | + .well(p) | |
| 396 | + .overflow_hidden() | |
| 397 | + .child(edges) | |
| 398 | + .children(dots) | |
| 399 | + .children(labels) | |
| 400 | + } | |
| 401 | + | |
| 402 | + fn render_genome_row(&self, ix: usize, file: &GenomeFile, open: bool, p: &Palette, cx: &mut Context<Self>) -> impl IntoElement { | |
| 403 | + let path = file.path.clone(); | |
| 404 | + let (dir, name) = match file.path.rsplit_once('/') { | |
| 405 | + Some((dir, name)) => (format!("{dir}/"), name.to_string()), | |
| 406 | + None => (String::new(), file.path.clone()), | |
| 407 | + }; | |
| 408 | + let row = h_flex() | |
| 409 | + .id(SharedString::from(format!("genome-file-{ix}"))) | |
| 410 | + .h(px(28.)) | |
| 411 | + .px(px(10.)) | |
| 412 | + .gap(px(8.)) | |
| 413 | + .rounded(px(8.)) | |
| 414 | + .cursor_pointer() | |
| 415 | + .hover(|style| style.bg(p.row_hover)) | |
| 416 | + .when(open, |this| this.bg(p.row_hover)) | |
| 417 | + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { | |
| 418 | + if let Some(panel) = this.genome.as_mut() { | |
| 419 | + if *hovered { | |
| 420 | + panel.hovered = Some(ix); | |
| 421 | + } else if panel.hovered == Some(ix) { | |
| 422 | + panel.hovered = None; | |
| 423 | + } | |
| 424 | + cx.notify(); | |
| 425 | + } | |
| 426 | + })) | |
| 427 | + .on_click(cx.listener(move |this, _, _, cx| this.select_genome_file(path.clone(), cx))) | |
| 428 | + .child( | |
| 429 | + div() | |
| 430 | + .w(px(18.)) | |
| 431 | + .flex_none() | |
| 432 | + .text_size(px(10.5)) | |
| 433 | + .font_family(metal::MONO) | |
| 434 | + .text_color(p.ink3) | |
| 435 | + .child(format!("{}", ix + 1)), | |
| 436 | + ) | |
| 437 | + .child(div().size(px(8.)).flex_none().rounded_full().bg(folder_color(&file.path, p))) | |
| 438 | + .child( | |
| 439 | + h_flex() | |
| 440 | + .flex_1() | |
| 441 | + .min_w_0() | |
| 442 | + .text_size(px(12.5)) | |
| 443 | + .child(div().flex_shrink(1.).min_w_0().truncate().text_color(p.ink3).child(dir)) | |
| 444 | + .child(div().flex_none().text_color(p.ink).child(name)), | |
| 445 | + ) | |
| 446 | + .when(file.recent, |this| this.child(metal::tag("new", p))) | |
| 447 | + .when(file.dependents > 0, |this| { | |
| 448 | + this.child( | |
| 449 | + div() | |
| 450 | + .flex_none() | |
| 451 | + .text_size(px(11.)) | |
| 452 | + .font_family(metal::MONO) | |
| 453 | + .text_color(p.ink2) | |
| 454 | + .child(format!("→{}", file.dependents)), | |
| 455 | + ) | |
| 456 | + }); | |
| 457 | + | |
| 458 | + v_flex().child(row).when(open, |this| { | |
| 459 | + this.child( | |
| 460 | + v_flex() | |
| 461 | + .pl(px(44.)) | |
| 462 | + .pr(px(10.)) | |
| 463 | + .pb(px(6.)) | |
| 464 | + .gap(px(2.)) | |
| 465 | + .child( | |
| 466 | + div().text_size(px(11.5)).text_color(p.ink3).child(format!( | |
| 467 | + "{} · {} lines · imported by {} · imports {}", | |
| 468 | + file.language, file.lines, file.dependents, file.dependencies | |
| 469 | + )), | |
| 470 | + ) | |
| 471 | + .when(file.symbols.is_empty(), |this| { | |
| 472 | + this.child(div().text_size(px(12.)).text_color(p.ink3).child("No symbols")) | |
| 473 | + }) | |
| 474 | + .children(file.symbols.iter().map(|symbol| { | |
| 475 | + let signature = symbol | |
| 476 | + .signature | |
| 477 | + .as_deref() | |
| 478 | + .map(|signature| signature.lines().next().unwrap_or_default().trim().to_string()) | |
| 479 | + .filter(|signature| !signature.is_empty()) | |
| 480 | + .unwrap_or_else(|| format!("{} {}", symbol.kind, symbol.name)); | |
| 481 | + h_flex() | |
| 482 | + .gap(px(6.)) | |
| 483 | + .text_size(px(11.5)) | |
| 484 | + .font_family(metal::MONO) | |
| 485 | + .child( | |
| 486 | + div() | |
| 487 | + .w(px(10.)) | |
| 488 | + .flex_none() | |
| 489 | + .text_color(if symbol.exported { p.green_ink } else { p.ink3 }) | |
| 490 | + .child(if symbol.exported { "+" } else { " " }), | |
| 491 | + ) | |
| 492 | + .child(div().flex_1().min_w_0().truncate().text_color(p.ink2).child(signature)) | |
| 493 | + .child(div().flex_none().text_color(p.ink3).child(format!(":{}", symbol.line))) | |
| 494 | + })) | |
| 495 | + .when(file.symbol_count as usize > file.symbols.len(), |this| { | |
| 496 | + this.child( | |
| 497 | + div() | |
| 498 | + .text_size(px(11.5)) | |
| 499 | + .text_color(p.ink3) | |
| 500 | + .child(format!("… {} more", file.symbol_count as usize - file.symbols.len())), | |
| 501 | + ) | |
| 502 | + }), | |
| 503 | + ) | |
| 504 | + }) | |
| 505 | + } | |
| 506 | + } | |
| 507 | + | |
| 508 | + /// When Empryo last wrote the Genome of the checkout `cwd` is in: its | |
| 509 | + /// write-ahead log or the database itself. | |
| 510 | + fn written_at(cwd: &Path) -> Option<SystemTime> { | |
| 511 | + let path = genome::db_path(cwd)?; | |
| 512 | + [path.with_extension("db-wal"), path] | |
| 513 | + .iter() | |
| 514 | + .filter_map(|path| std::fs::metadata(path).and_then(|meta| meta.modified()).ok()) | |
| 515 | + .max() | |
| 516 | + } | |
| 517 | + | |
| 518 | + fn file_name(path: &str) -> String { | |
| 519 | + path.rsplit('/').next().unwrap_or(path).to_string() | |
| 520 | + } | |
| 521 | + | |
| 522 | + /// A file's color in the map: one of the palette's tints, the same for | |
| 523 | + /// every file in a folder. | |
| 524 | + fn folder_color(path: &str, p: &Palette) -> Hsla { | |
| 525 | + let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder); | |
| 526 | + let hash = folder.bytes().fold(7u32, |hash, byte| hash.wrapping_mul(31).wrapping_add(byte as u32)); | |
| 527 | + p.tints[hash as usize % p.tints.len()] | |
| 528 | + } | |
| 529 | + | |
| 530 | + fn genome_message(title: &str, detail: String, p: &Palette) -> impl IntoElement { | |
| 531 | + v_flex() | |
| 532 | + .px(px(10.)) | |
| 533 | + .py(px(10.)) | |
| 534 | + .gap(px(4.)) | |
| 535 | + .child(div().text_size(px(13.)).text_color(p.ink2).child(title.to_string())) | |
| 536 | + .when(!detail.is_empty(), |this| this.child(div().text_size(px(12.)).text_color(p.ink3).child(detail))) | |
| 537 | + } | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | //! The right panel, as T3 Code has it: it shows one surface at a time (the |
| 2 | - //! subagents, the browser, a device or a terminal), and opens on "Open a surface", a | |
| 3 | - //! short list to pick one from, each with a key of its own. | |
| 2 | + //! subagents, the browser, a device, a terminal, or for Empryo threads the | |
| 3 | + //! Genome map), and opens on "Open a surface", a short list to pick one | |
| 4 | + //! from, each with a key of its own. | |
| 4 | 5 | |
| 5 | 6 | use gpui_kit::component::{Icon, IconName, h_flex, v_flex}; |
| 6 | 7 | use gpui_kit::prelude::FluentBuilder as _; |
| @@ -19,14 +20,17 @@ pub(super) enum Surface { | ||
| 19 | 20 | Browser, |
| 20 | 21 | Device, |
| 21 | 22 | Terminal, |
| 23 | + /// Empryo's map of the thread's checkout. | |
| 24 | + Genome, | |
| 22 | 25 | } |
| 23 | 26 | |
| 24 | 27 | /// The surfaces the picker lists: each with its icon, name and key. |
| 25 | - const SURFACES: [(Surface, &str, &str); 4] = [ | |
| 28 | + const SURFACES: [(Surface, &str, &str); 5] = [ | |
| 26 | 29 | (Surface::Subagents, "Subagents", "S"), |
| 27 | 30 | (Surface::Browser, "Browser", "B"), |
| 28 | 31 | (Surface::Device, "Device", "D"), |
| 29 | 32 | (Surface::Terminal, "Terminal", "T"), |
| 33 | + (Surface::Genome, "Genome map", "G"), | |
| 30 | 34 | ]; |
| 31 | 35 | |
| 32 | 36 | /// How wide a terminal in the right panel is. |
| @@ -93,6 +97,9 @@ impl Workspace { | ||
| 93 | 97 | } |
| 94 | 98 | |
| 95 | 99 | pub(super) fn open_surface(&mut self, surface: Surface, window: &mut Window, cx: &mut Context<Self>) { |
| 100 | + if surface == Surface::Genome && !self.genome_available(cx) { | |
| 101 | + return; | |
| 102 | + } | |
| 96 | 103 | if surface == Surface::Terminal { |
| 97 | 104 | if !self.terminal_available(cx) { |
| 98 | 105 | return; |
| @@ -107,7 +114,7 @@ impl Workspace { | ||
| 107 | 114 | cx.notify(); |
| 108 | 115 | } |
| 109 | 116 | |
| 110 | - /// The picker's keys: S, B, D and T open their surfaces; Escape closes it. | |
| 117 | + /// The picker's keys: S, B, D, T and G open their surfaces; Escape closes it. | |
| 111 | 118 | fn surface_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) { |
| 112 | 119 | let keystroke = &event.keystroke; |
| 113 | 120 | if keystroke.modifiers.modified() { |
| @@ -127,13 +134,16 @@ impl Workspace { | ||
| 127 | 134 | pub(super) fn render_surface_picker(&self, cx: &mut Context<Self>) -> impl IntoElement { |
| 128 | 135 | let p = metal::palette(cx); |
| 129 | 136 | let terminal = self.terminal_available(cx); |
| 130 | - let rows = SURFACES.iter().map(|(surface, name, key)| { | |
| 137 | + let genome = self.genome_available(cx); | |
| 138 | + // The Genome map is Empryo's; other agents' threads don't list it. | |
| 139 | + let rows = SURFACES.iter().filter(|(surface, _, _)| *surface != Surface::Genome || genome).map(|(surface, name, key)| { | |
| 131 | 140 | let surface = *surface; |
| 132 | 141 | let enabled = surface != Surface::Terminal || terminal; |
| 133 | 142 | let icon = match surface { |
| 134 | 143 | Surface::Subagents => Icon::new(Lucide::Bot), |
| 135 | 144 | Surface::Browser => Icon::new(IconName::Globe), |
| 136 | 145 | Surface::Device => Icon::new(Lucide::Smartphone), |
| 146 | + Surface::Genome => Icon::new(Lucide::Dna), | |
| 137 | 147 | _ => Icon::new(Lucide::SquareTerminal), |
| 138 | 148 | }; |
| 139 | 149 | h_flex() |