Vectrum
Sign In

Add Empryo as a fourth agent, with its Genome map in the sidebar

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>
JjohannSocommitted 1 parent 6d6e49fcommite3107fa

Showing 30 changed files with 1674 additions and 56 deletions.

+79−0Cargo.lockView File
@@ -1733,6 +1733,18 @@ dependencies = [
17331733 "zune-inflate",
17341734 ]
17351735
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+
17361748 [[package]]
17371749 name = "fastrand"
17381750 version = "2.5.0"
@@ -2985,6 +2997,18 @@ name = "hashbrown"
29852997 version = "0.17.1"
29862998 source = "registry+https://github.com/rust-lang/crates.io-index"
29872999 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+ ]
29883012
29893013 [[package]]
29903014 name = "heapless"
@@ -3705,6 +3729,17 @@ dependencies = [
37053729 "libc",
37063730 ]
37073731
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+
37083743 [[package]]
37093744 name = "linebender_resource_handle"
37103745 version = "0.1.1"
@@ -5735,6 +5770,31 @@ dependencies = [
57355770 "memchr",
57365771 ]
57375772
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+
57385798 [[package]]
57395799 name = "rust-embed"
57405800 version = "8.13.0"
@@ -6452,6 +6512,18 @@ dependencies = [
64526512 "bitflags 2.13.2",
64536513 ]
64546514
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+
64556527 [[package]]
64566528 name = "stable_deref_trait"
64576529 version = "1.2.1"
@@ -7475,6 +7547,12 @@ dependencies = [
74757547 "sval_serde",
74767548 ]
74777549
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+
74787556 [[package]]
74797557 name = "vectrum-code"
74807558 version = "0.2.5"
@@ -7490,6 +7568,7 @@ dependencies = [
74907568 "openh264",
74917569 "qrcode",
74927570 "ring",
7571+ "rusqlite",
74937572 "serde",
74947573 "serde_json",
74957574 "smol",
+1−0Cargo.tomlView File
@@ -21,6 +21,7 @@ openh264 = "0.9"
2121 mermaid-rs-renderer = { version = "0.3.1", default-features = false }
2222 qrcode = { version = "0.14.1", default-features = false }
2323 ring = "0.17"
24+ rusqlite = { version = "0.40", features = ["bundled"] }
2425
2526 [profile.dev]
2627 debug = "line-tables-only"
+36−12README.mdView File
@@ -3,7 +3,8 @@
33 A native [T3 Code](https://github.com/pingdotgg/t3code)-style desktop client for coding
44 agents, built with [GPUI](https://gpui.rs) and [GPUI Kit](https://github.com/longbridge/gpui-kit).
55 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/).
78
89 Every thread runs its own agent process in the background. Claude Code threads talk to the
910 `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
1112 talk to `pi --mode rpc` over its JSONL RPC protocol. jcode threads talk to
1213 `jcode api --stdio`, a bridge to jcode's versioned harness API that starts jcode's shared
1314 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`).
1418
1519 The layout and workflow follow T3 Code. The look follows [MetalUI](https://metalui.dev/), the
1620 "soft hardware" design system [vectrum.cc](https://vectrum.cc/) is built on: the
@@ -64,19 +68,22 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
6468 it there once the thread has started.
6569 - A model picker across providers (Ctrl+Shift+M): a rail with **Favorites** and each provider,
6670 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`).
6873 Star models to keep them under Favorites, search across all of them, pick the first nine
6974 with Ctrl+1–9, and switch providers with Ctrl+Shift+↑/↓. On a new thread, picking another
7075 provider's model starts a thread of that kind in its place; once a thread has started,
7176 other providers' models are greyed out, as in T3 Code. `/model` opens the picker too.
7277 - A reasoning menu (Ctrl+Shift+E): Low to Max (as the model supports, its default marked),
7378 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.
8087 - Finished turns fold their tool calls and interim messages into one **Worked for 12s** row
8188 above the answer. Click it to unfold.
8289 - Answers can carry live diagrams and UI, written as plain fenced code blocks. ` ```mermaid `
@@ -138,6 +145,14 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
138145 before every run, that extension also switches off Pi's other subagent tools.
139146 jcode gets them as session tools over its API, with its `swarm` and `schedule`
140147 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.
141156 - Background tasks: when a command, monitor or subagent Claude left running finishes, the
142157 transcript says so and Claude picks the work back up and reports back on its own. A
143158 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.
292307 (`npm install -g @earendil-works/pi-coding-agent`, then log in to a provider with `pi`)
293308 - Optional: [jcode](https://github.com/1jehuang/jcode) for jcode threads
294309 (`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.
295313 - Optional: agent-browser for the browser panel
296314 (`npm install -g agent-browser && agent-browser install`)
297315 - 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.
352370 `~/.local/bin`, `~/.npm-global/bin`, `~/.bun/bin`, `~/.volta/bin` and nvm's Node versions.
353371 - `VECTRUM_CODE_JCODE_BIN`: path to the `jcode` binary. Otherwise it's looked up on `PATH`,
354372 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`.
355375 - `VECTRUM_UPDATE_URL`: where to look for updates instead of `https://vectrum.cc`, such as a
356376 test server. Releases from there still need the release key's signature.
357377
@@ -392,12 +412,14 @@ after changing them, `cargo test export_android_themes -- --ignored` rewrites
392412
393413 ## Layout
394414
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`
396417 (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
399420 extension dialogs, and snapshots each turn's changes.
400421 - `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.
401423 - `src/git.rs`: branch and change stats, working-tree snapshots, and diffs.
402424 - `src/settings.rs`: the saved settings file, cached in memory.
403425 - `src/update.rs`: the app's updater: release checks, signature and checksum checks, the
@@ -437,4 +459,6 @@ highlighting, and T3 Code's project
437459 scripts aren't here yet. The terminal drawer doesn't report mouse clicks to full-screen
438460 programs (only the wheel), open links, or show T3's popup after a selection. jcode models can't write thread titles (every
439461 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.
+1−0android/app/src/main/java/cc/vectrum/code/ui/Icons.ktView File
@@ -59,6 +59,7 @@ enum class Lucide(val res: Int) {
5959 Ellipsis(R.drawable.lucide_ellipsis),
6060 EllipsisVertical(R.drawable.lucide_ellipsis_vertical),
6161 ExternalLink(R.drawable.lucide_external_link),
62+ Dna(R.drawable.lucide_dna),
6263 Eye(R.drawable.lucide_eye),
6364 EyeOff(R.drawable.lucide_eye_off),
6465 Feather(R.drawable.lucide_feather),
+1−1android/app/src/main/java/cc/vectrum/code/ui/composer/Composer.ktView File
@@ -270,7 +270,7 @@ fun ComposerFrame(
270270 val p = palette
271271 var focused by remember { mutableStateOf(false) }
272272 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)
274274 val attach = rememberAttach(app, state)
275275 LaunchedEffect(state.modelPickerRequested) {
276276 if (state.modelPickerRequested) {
+10−1android/app/src/main/java/cc/vectrum/code/ui/composer/Model.ktView File
@@ -32,12 +32,14 @@ val ClaudeOrange = Color(0xffd97757)
3232 fun providerIcon(provider: String): Lucide = when (provider) {
3333 "pi" -> Lucide.Pi
3434 "jcode" -> Lucide.Feather
35+ "empryo" -> Lucide.Dna
3536 else -> Lucide.Asterisk
3637 }
3738
3839 fun providerLabel(provider: String): String = when (provider) {
3940 "pi" -> "Pi"
4041 "jcode" -> "jcode"
42+ "empryo" -> "Empryo"
4143 else -> "Claude"
4244 }
4345
@@ -45,12 +47,16 @@ fun providerLabel(provider: String): String = when (provider) {
4547 fun agentName(provider: String): String = when (provider) {
4648 "pi" -> "Pi"
4749 "jcode" -> "jcode"
50+ "empryo" -> "Empryo"
4851 else -> "Claude Code"
4952 }
5053
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. */
5255 fun asksPermission(provider: String) = provider == "claude"
5356
57+ /** Whether the agent has a plan mode the composer can switch to. */
58+ fun hasPlanMode(provider: String) = provider == "claude" || provider == "empryo"
59+
5460 // ---- reasoning -----------------------------------------------------------------
5561
5662 /** 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
8288 else -> model?.efforts?.takeIf { it.isNotEmpty() } ?: DefaultEfforts
8389 }
8490
91+ /** Whether the reasoning menu offers `Default`: the model's own level. */
92+ fun offersDefaultLevel(provider: String) = provider == "claude" || provider == "empryo"
93+
8594 /** The level `Default` stands for on this model, when known. */
8695 fun defaultLevel(provider: String, model: RemoteModel?): String? =
8796 if (provider == "claude") model?.defaultEffort else null
+2−1android/app/src/main/java/cc/vectrum/code/ui/composer/Pickers.ktView File
@@ -234,7 +234,7 @@ fun ColumnScope.TraitsItems(target: PickTarget, close: () -> Unit) {
234234 if (levels.isNotEmpty() || provider == "claude") {
235235 any = true
236236 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)
238238 for (level in levels) item(level, effortLabel(level), null, default == level)
239239 if (provider == "claude") {
240240 if (model == null || model.ultracode) {
@@ -531,6 +531,7 @@ private fun setupText(instance: RemoteInstance?): String? {
531531 "missing" -> when (instance.provider) {
532532 "pi" -> "Open provider setup to install Pi on this environment: npm install -g @earendil-works/pi-coding-agent"
533533 "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"
534535 else -> "Open provider setup to install Claude on this environment."
535536 }
536537 else -> "$label provider is unavailable."
+1−1android/app/src/main/java/cc/vectrum/code/ui/composer/Strip.ktView File
@@ -155,7 +155,7 @@ fun newThreadDefaults(host: HostState, project: String, git: Boolean, from: Thre
155155 // A host that doesn't list its agents: its instance ids start with the provider.
156156 instances.isEmpty() -> {
157157 instance = id
158- provider = listOf("pi", "jcode").firstOrNull { id.startsWith(it) } ?: "claude"
158+ provider = listOf("pi", "jcode", "empryo").firstOrNull { id.startsWith(it) } ?: "claude"
159159 model = key.substring(split + 1)
160160 }
161161 }
+3−2android/app/src/main/java/cc/vectrum/code/ui/settings/Widgets.ktView File
@@ -589,13 +589,14 @@ fun Code(text: String, modifier: Modifier = Modifier) {
589589 /** Claude's asterisk orange, as the desktop's pickers draw it. */
590590 val ClaudeOrange = Color(0xffd97757)
591591
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. */
593593 @Composable
594594 fun ProviderIcon(provider: String, size: Dp = 13.dp, modifier: Modifier = Modifier) {
595595 val p = palette
596596 when (provider) {
597597 "pi" -> LucideIcon(Lucide.Pi, modifier, size = size, tint = p.ink)
598598 "jcode" -> LucideIcon(Lucide.Feather, modifier, size = size, tint = p.ink)
599+ "empryo" -> LucideIcon(Lucide.Dna, modifier, size = size, tint = p.ink)
599600 else -> LucideIcon(Lucide.Asterisk, modifier, size = size, tint = ClaudeOrange)
600601 }
601602 }
@@ -630,7 +631,7 @@ fun ModelControl(
630631 model != null -> model.name
631632 else -> modelId ?: current
632633 }
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") }
634635 val leading: (@Composable androidx.compose.foundation.layout.RowScope.() -> Unit)? =
635636 if (provider != null) { { ProviderIcon(provider) } } else null
636637 Cap(
+2−1android/app/src/main/java/cc/vectrum/code/ui/sidebar/ThreadRows.ktView File
@@ -300,13 +300,14 @@ fun Modifier.pressTint(radius: Dp, onClick: () -> Unit): Modifier {
300300 .clickable(interactionSource = interaction, indication = null, onClick = onClick)
301301 }
302302
303- /** The provider's mark: Claude's orange asterisk, Pi, jcode's feather. */
303+ /** The provider's mark: Claude's orange asterisk, Pi, jcode's feather, Empryo's DNA strand. */
304304 @Composable
305305 fun ProviderIcon(provider: String, size: Dp) {
306306 val p = palette
307307 when (provider) {
308308 "pi" -> LucideIcon(Lucide.Pi, size = size, tint = p.ink)
309309 "jcode" -> LucideIcon(Lucide.Feather, size = size, tint = p.ink)
310+ "empryo" -> LucideIcon(Lucide.Dna, size = size, tint = p.ink)
310311 else -> LucideIcon(Lucide.Asterisk, size = size, tint = ClaudeOrange)
311312 }
312313 }
+16−0android/app/src/main/res/drawable/lucide_dna.xmladdedView File
@@ -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>
+67−11src/claude.rsView File
@@ -1,7 +1,8 @@
11 //! Spawns the long-running agent process behind a thread: the `claude` CLI
22 //! 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.
56
67 use std::{
78 io::{BufRead, BufReader, Write},
@@ -142,15 +143,18 @@ pub struct LaunchConfig {
142143 /// Which agent, with its binary, home folder, arguments and environment.
143144 pub instance: ProviderInstance,
144145 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.
147148 pub model: Option<String>,
148149 pub permission_mode: PermissionMode,
149150 /// `None` leaves effort at the model's default.
150151 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.
153154 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>,
154158 /// The thread's agent-browser session, shared with the browser panel.
155159 pub browser_session: Option<String>,
156160 /// The thread the agent runs for.
@@ -181,6 +185,7 @@ impl AgentProcess {
181185 // jcode's tools run in its daemon, out of reach of this process's
182186 // environment; the thread tells it about the browser in a message.
183187 Provider::Jcode => jcode_command(config)?,
188+ Provider::Empryo => empryo_command(config)?,
184189 };
185190 // The app's tools; jcode's are registered over its API.
186191 orchestrator::set_caps(&config.thread, config.tools);
@@ -192,20 +197,22 @@ impl AgentProcess {
192197 }
193198 }
194199 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 => {}
196202 }
197203 // The agent-device launcher, on PATH for agents that may drive devices.
198204 if config.tools.devices {
199205 crate::device::agent::prepare_command(&mut command);
200206 }
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.
203209 if config.instance.kind != Provider::Jcode {
204210 let browser = config.browser_session.as_ref().and_then(|session| browser::prepare_agent(&mut command, session));
205211 let inline = crate::settings::get(|settings| settings.inline_ui).then(|| crate::inline_ui::INSTRUCTIONS.to_string());
206212 let prompts: Vec<String> = browser.into_iter().chain(inline).collect();
207213 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")]);
209216 }
210217 }
211218 Self::spawn_command(&binary, command, events)
@@ -292,6 +299,11 @@ impl AgentProcess {
292299 let _ = self.stdin_tx.send(message.to_string());
293300 }
294301
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+
295307 /// Sends a Claude user turn made of API content blocks (text, images).
296308 pub fn send_user_content(&self, content: Vec<Value>) {
297309 self.send(json!({
@@ -312,6 +324,16 @@ impl AgentProcess {
312324 }));
313325 }
314326
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+
315337 pub fn kill(&self) {
316338 if let Ok(mut child) = self.child.lock() {
317339 let _ = child.kill();
@@ -393,12 +415,43 @@ fn jcode_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
393415 Ok((binary, command))
394416 }
395417
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 `~`.
397449 pub fn home_var(kind: Provider) -> &'static str {
398450 match kind {
399451 Provider::Claude => "CLAUDE_CONFIG_DIR",
400452 Provider::Pi => "PI_CODING_AGENT_DIR",
401453 Provider::Jcode => "JCODE_HOME",
454+ Provider::Empryo => "HOME",
402455 }
403456 }
404457
@@ -408,6 +461,7 @@ pub fn binary_var(kind: Provider) -> &'static str {
408461 Provider::Claude => "VECTRUM_CODE_CLAUDE_BIN",
409462 Provider::Pi => "VECTRUM_CODE_PI_BIN",
410463 Provider::Jcode => "VECTRUM_CODE_JCODE_BIN",
464+ Provider::Empryo => "VECTRUM_CODE_EMPRYO_BIN",
411465 }
412466 }
413467
@@ -482,6 +536,8 @@ pub fn find_binary(instance: &ProviderInstance) -> Option<PathBuf> {
482536 home.join(".volta/bin/pi"),
483537 ],
484538 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")],
485541 };
486542 fallbacks.extend([PathBuf::from("/usr/local/bin").join(name), PathBuf::from("/usr/bin").join(name)]);
487543 // nvm keeps one bin directory per Node version.
+345−0src/genome.rsaddedView File
@@ -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+ }
+1−0src/main.rsView File
@@ -7,6 +7,7 @@ mod browser;
77 mod claude;
88 mod clock;
99 mod device;
10+ mod genome;
1011 mod git;
1112 mod http;
1213 mod inline_ui;
+160−4src/models.rsView File
@@ -29,16 +29,18 @@ pub enum Provider {
2929 Claude,
3030 Pi,
3131 Jcode,
32+ Empryo,
3233 }
3334
3435 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];
3637
3738 pub fn label(self) -> &'static str {
3839 match self {
3940 Provider::Claude => "Claude",
4041 Provider::Pi => "Pi",
4142 Provider::Jcode => "jcode",
43+ Provider::Empryo => "Empryo",
4244 }
4345 }
4446
@@ -48,6 +50,7 @@ impl Provider {
4850 Provider::Claude => "Claude Code",
4951 Provider::Pi => "Pi",
5052 Provider::Jcode => "jcode",
53+ Provider::Empryo => "Empryo",
5154 }
5255 }
5356
@@ -56,6 +59,7 @@ impl Provider {
5659 Provider::Claude => "claude",
5760 Provider::Pi => "pi",
5861 Provider::Jcode => "jcode",
62+ Provider::Empryo => "empryo",
5963 }
6064 }
6165
@@ -65,11 +69,17 @@ impl Provider {
6569 self != Provider::Jcode
6670 }
6771
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.
6974 pub fn asks_permission(self) -> bool {
7075 self == Provider::Claude
7176 }
7277
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+
7383 pub fn from_key(key: &str) -> Option<Self> {
7484 Self::ALL.into_iter().find(|provider| provider.key() == key)
7585 }
@@ -365,6 +375,7 @@ impl Catalog {
365375 Provider::Claude => probe_claude(&config),
366376 Provider::Pi => probe_pi(&config),
367377 Provider::Jcode => probe_jcode(&config),
378+ Provider::Empryo => probe_empryo(&config),
368379 };
369380 let latest = if update_checks { latest_version(config.kind) } else { None };
370381 (probe, latest)
@@ -884,8 +895,125 @@ fn jcode_model_spec(model: &str, provider: &str, method: &str) -> String {
884895 }
885896 }
886897
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+
8871015 /// 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).
8891017 fn latest_version(kind: Provider) -> Option<String> {
8901018 let package = match kind {
8911019 Provider::Claude => "@anthropic-ai/claude-code",
@@ -896,6 +1024,10 @@ fn latest_version(kind: Provider) -> Option<String> {
8961024 let tag = serde_json::from_str::<Value>(&body).ok()?.get("tag_name")?.as_str()?.to_string();
8971025 return Some(tag.trim_start_matches('v').to_string());
8981026 }
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+ }
8991031 };
9001032 let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?;
9011033 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
9201052 command.args(["update"]);
9211053 Some((command, "jcode update"))
9221054 }
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+ }
9231064 }
9241065 }
9251066
@@ -959,7 +1100,7 @@ fn provider_label(provider: &str) -> String {
9591100
9601101 #[cfg(test)]
9611102 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};
9631104 use serde_json::json;
9641105
9651106 #[::core::prelude::v1::test]
@@ -1028,4 +1169,19 @@ mod tests {
10281169 assert_eq!(jcode_model_from("jcode", &route("auto", "auto", "openrouter", true)).unwrap().id, "auto");
10291170 assert!(jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-api", false)).is_none());
10301171 }
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+ }
10311187 }
+2−0src/orchestrator.rsView File
@@ -587,6 +587,8 @@ pub fn wait_limit(provider: crate::models::Provider) -> Duration {
587587 crate::models::Provider::Claude => Duration::from_secs(30 * 60),
588588 crate::models::Provider::Pi => Duration::from_secs(270),
589589 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),
590592 }
591593 }
592594
+308−5src/session.rsView File
@@ -827,6 +827,24 @@ struct JcodeLink {
827827 instructions_sent: bool,
828828 }
829829
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+
830848 pub struct Session {
831849 /// Names the thread's file on disk.
832850 pub id: String,
@@ -854,8 +872,8 @@ pub struct Session {
854872 pub items: Vec<ChatItem>,
855873 pub status: SessionStatus,
856874 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.
859877 resume_id: Option<String>,
860878 /// The model picked for the thread; `None` means the agent's default.
861879 pub model: Option<String>,
@@ -942,6 +960,7 @@ pub struct Session {
942960 pending_controls: HashMap<String, PendingControl>,
943961 next_request_id: u64,
944962 jcode: JcodeLink,
963+ empryo: EmpryoLink,
945964 _events_task: Task<()>,
946965 }
947966
@@ -1100,6 +1119,7 @@ impl Session {
11001119 pending_controls: HashMap::new(),
11011120 next_request_id: 0,
11021121 jcode: JcodeLink::default(),
1122+ empryo: EmpryoLink::default(),
11031123 _events_task: Task::ready(()),
11041124 }
11051125 }
@@ -1124,6 +1144,11 @@ impl Session {
11241144
11251145 fn start(&mut self, resume: Option<String>, cx: &mut Context<Self>) {
11261146 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+ });
11271152 let config = LaunchConfig {
11281153 instance: settings::instance(&self.instance, self.provider),
11291154 cwd: self.cwd.clone(),
@@ -1131,6 +1156,7 @@ impl Session {
11311156 permission_mode: self.permission_mode,
11321157 effort: self.reasoning.effort(),
11331158 resume: resume.clone(),
1159+ new_session: new_session.clone(),
11341160 browser_session: settings::get_for(&self.project, |settings| settings.agent_browser)
11351161 .then(|| browser::session_name(&self.id)),
11361162 thread: self.id.clone(),
@@ -1194,6 +1220,8 @@ impl Session {
11941220 );
11951221 }
11961222 }
1223+ // Model, effort and session went in as flags.
1224+ Provider::Empryo => self.empryo = EmpryoLink { new_session, ..EmpryoLink::default() },
11971225 }
11981226
11991227 self._events_task = cx.spawn(async move |this, cx| {
@@ -1546,6 +1574,11 @@ impl Session {
15461574 let mut images = Vec::new();
15471575 let mut file_paths = Vec::new();
15481576 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+ }
15491582 match image_media_type(path) {
15501583 Some(media_type) => match read_image(path) {
15511584 Ok(data) => images.push((media_type, data)),
@@ -1639,6 +1672,8 @@ impl Session {
16391672 self.jcode_request("send the message", request);
16401673 }
16411674 }
1675+ // Mid-turn, Empryo holds the message for its next turn.
1676+ Provider::Empryo => process.send_line(empryo_line(&prompt)),
16421677 }
16431678 self.status = SessionStatus::Working;
16441679 }
@@ -2134,6 +2169,14 @@ impl Session {
21342169 Provider::Claude => self.control(PendingControl::Interrupt, json!({"subtype": "interrupt"})),
21352170 Provider::Pi => self.pi(json!({"type": "abort"})),
21362171 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+ }
21372180 }
21382181 cx.notify();
21392182 }
@@ -2163,16 +2206,43 @@ impl Session {
21632206 }
21642207 }
21652208 Provider::Jcode => {}
2209+ Provider::Empryo => self.relaunch_empryo(),
21662210 }
21672211 cx.notify();
21682212 }
21692213
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+
21702232 pub fn set_permission_mode(&mut self, mode: PermissionMode, cx: &mut Context<Self>) {
21712233 if self.forward(ThreadAction::SetPermissionMode { mode: mode.as_cli().into() }) {
21722234 self.permission_mode = mode;
21732235 cx.notify();
21742236 return;
21752237 }
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+ }
21762246 // Pi and jcode have no permission prompts to configure.
21772247 if !self.provider.asks_permission() {
21782248 return;
@@ -2255,6 +2325,7 @@ impl Session {
22552325 }
22562326 }
22572327 Provider::Jcode => {}
2328+ Provider::Empryo => self.relaunch_empryo(),
22582329 }
22592330 cx.notify();
22602331 }
@@ -2604,6 +2675,7 @@ impl Session {
26042675 Provider::Claude => self.handle_message(message, cx),
26052676 Provider::Pi => self.handle_pi_message(message, cx),
26062677 Provider::Jcode => self.handle_jcode_message(message, cx),
2678+ Provider::Empryo => self.handle_empryo_message(message, cx),
26072679 },
26082680 ProcessEvent::Stderr(line) => {
26092681 if self.stderr_tail.len() == STDERR_TAIL {
@@ -2616,6 +2688,9 @@ impl Session {
26162688 }
26172689
26182690 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+ }
26192694 self.flush_text(cx);
26202695 self.process = None;
26212696 self.drop_background_tasks();
@@ -3678,6 +3753,184 @@ impl Session {
36783753 }
36793754 }
36803755
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+
36813934 /// Registers the app's tools with the thread's jcode session (a
36823935 /// subagent's thread gets none) and switches off jcode's own ways of
36833936 /// spawning agents. jcode keeps the setup in memory, so it's sent on
@@ -3689,6 +3942,10 @@ impl Session {
36893942
36903943 /// Which of the app's tools this thread's agent gets.
36913944 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+ }
36923949 orchestrator::Caps { delegate: self.delegation.is_none(), devices: crate::device::agent_access_for(&self.project) }
36933950 }
36943951
@@ -4915,6 +5172,25 @@ pub fn tool_field(input: &Value, keys: &[&str]) -> Option<String> {
49155172 }
49165173
49175174 /// 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+
49185194 pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String {
49195195 let field = |keys: &[&str]| tool_field(input, keys);
49205196 let path = |keys: &[&str]| {
@@ -4926,8 +5202,16 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String
49265202 })
49275203 };
49285204 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"]),
49315215 "notebookedit" => path(&["notebook_path"]),
49325216 "glob" | "grep" | "find" => field(&["pattern", "query"]),
49335217 "ls" => path(&["path"]),
@@ -4956,7 +5240,10 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String
49565240
49575241 #[cfg(test)]
49585242 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+ };
49605247 use serde_json::{Value, json};
49615248
49625249 fn tool(name: &str, input: Value) -> ToolCall {
@@ -5039,4 +5326,20 @@ mod tests {
50395326 assert!(answer.view.is_none());
50405327 assert_eq!(answer.text, "First.\n\nSecond");
50415328 }
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+ }
50425345 }
+11−0src/textgen.rsView File
@@ -208,6 +208,17 @@ fn generate(project: &Path, prompt: &str) -> Option<String> {
208208 }
209209 }
210210 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+ }
211222 }
212223 let output = command
213224 .arg(prompt)
+1−1src/ui/chat/footer.rsView File
@@ -94,7 +94,7 @@ impl ChatView {
9494 let traits_label = pickers::traits_label(session, model.as_ref());
9595 let access = session.access_mode();
9696 let planning = session.is_planning();
97- let plan_toggle = settings::get(|settings| settings.plan_mode) && provider.asks_permission();
97+ let plan_toggle = settings::get(|settings| settings.plan_mode) && provider.has_plan_mode();
9898
9999 let label_width = |label: &str| label.chars().count() as f32 * CHAR_WIDTH;
100100 let model_width = 52. + label_width(&model_label).min(200.);
+18−3src/ui/chat/timeline.rsView File
@@ -145,13 +145,19 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
145145 return Presentation { icon: Icon::new(Lucide::Smartphone), label, action: Action::Other };
146146 }
147147 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())),
149149 "bashoutput" | "killshell" => (Action::Command, name.to_string()),
150150 "read" | "notebookread" => (
151151 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()),
153158 ),
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" => (
155161 Action::Edit,
156162 field(&["file_path", "path", "notebook_path"]).map(|path| relative_path(&path, cwd)).unwrap_or_else(|| "Changed a file".into()),
157163 ),
@@ -159,6 +165,15 @@ fn present_call(name: &str, input: &Value, cwd: &Path) -> Presentation {
159165 Action::CodeSearch,
160166 field(&["pattern", "query"]).map(|pattern| format!("Searched for {}", first_line(pattern))).unwrap_or_else(|| "Searched code".into()),
161167 ),
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())),
162177 "ls" => (Action::CodeSearch, field(&["path"]).map(|path| format!("Listed {}", relative_path(&path, cwd))).unwrap_or_else(|| "Listed files".into())),
163178 "websearch" => (Action::WebSearch, field(&["query"]).map(|query| format!("Web search: {query}")).unwrap_or_else(|| "Web search".into())),
164179 "webfetch" => (Action::WebSearch, field(&["url"]).unwrap_or_else(|| "Fetched a page".into())),
+1−0src/ui/icons.rsView File
@@ -21,6 +21,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
2121 Code,
2222 Feather,
2323 CornerDownRight,
24+ Dna,
2425 FileCode,
2526 FileDiff,
2627 FolderGit2,
+7−2src/ui/pickers.rsView File
@@ -28,6 +28,7 @@ pub fn provider_icon(provider: Provider, size: Pixels, p: &Palette) -> Icon {
2828 Provider::Claude => Icon::new(IconName::Asterisk).size(size).text_color(rgba(CLAUDE_ORANGE)),
2929 Provider::Pi => Icon::new(Lucide::Pi).size(size).text_color(p.ink),
3030 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),
3132 }
3233 }
3334
@@ -139,7 +140,8 @@ pub fn traits_items(
139140 let mut items = Vec::new();
140141 if !levels.is_empty() || provider == Provider::Claude {
141142 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) {
143145 items.push(item("traits-default".into(), Reasoning::Default, "Default", None, true));
144146 }
145147 for (ix, level) in levels.into_iter().enumerate() {
@@ -493,7 +495,7 @@ impl ModelPicker {
493495 let locked = !state.items.is_empty() && model.instance != state.instance;
494496 let detail = match model.provider {
495497 Provider::Claude => instance,
496- Provider::Pi | Provider::Jcode => format!("{instance} · {}", model.detail),
498+ Provider::Pi | Provider::Jcode | Provider::Empryo => format!("{instance} · {}", model.detail),
497499 };
498500 h_flex()
499501 .id(SharedString::from(format!("model-{}", model.key())))
@@ -593,6 +595,9 @@ impl ModelPicker {
593595 }
594596 (ProbeStatus::Missing, Provider::Claude) => "Open provider setup to install Claude on this environment.".into(),
595597 (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+ }
596601 (ProbeStatus::Failed(error), _) => format!("{label} provider is unavailable. {error}"),
597602 (ProbeStatus::Ready, _) => "No models are available for this provider.".into(),
598603 };
+1−1src/ui/settings/general.rsView File
@@ -134,7 +134,7 @@ impl SettingsView {
134134 .row(
135135 row("Permissions")
136136 .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.")
138138 .reset(reset(&view, cx, |s| s.default_access.clone(), |s, access| s.default_access = access))
139139 .control(rich_choice("default-access", current_access, permissions, &view, |settings, mode| {
140140 settings.default_access = mode.map(|mode: PermissionMode| mode.as_cli().to_string())
+16−2src/ui/settings/providers.rsView File
@@ -92,8 +92,10 @@ fn status_headline(state: &InstanceState) -> (Led, String) {
9292 match state.kind() {
9393 Provider::Claude if state.account.is_some() => parts.push("Authenticated".to_string()),
9494 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()),
9799 }
98100 parts.extend(state.plan.clone());
99101 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 {
147149 Provider::Claude => "CLAUDE_CONFIG_DIR path",
148150 Provider::Pi => "PI_CODING_AGENT_DIR path",
149151 Provider::Jcode => "JCODE_HOME path",
152+ Provider::Empryo => "HOME path",
150153 }
151154 }
152155
@@ -155,6 +158,7 @@ fn home_placeholder(kind: Provider) -> &'static str {
155158 Provider::Claude => "~/.claude",
156159 Provider::Pi => "~/.pi/agent",
157160 Provider::Jcode => "~/.jcode",
161+ Provider::Empryo => "~/empryo-home",
158162 }
159163 }
160164
@@ -163,6 +167,7 @@ fn custom_placeholder(kind: Provider) -> &'static str {
163167 Provider::Claude => "claude-sonnet-5",
164168 Provider::Pi => "anthropic/claude-sonnet-5",
165169 Provider::Jcode => "claude-oauth:claude-opus-5",
170+ Provider::Empryo => "anthropic/claude-sonnet-5",
166171 }
167172 }
168173
@@ -824,6 +829,10 @@ impl SettingsView {
824829 (Provider::Claude, None, _) => "Not signed in. Run claude in a terminal and log in, then refresh.".into(),
825830 (Provider::Pi, _, _) => "Pi signs in to model providers itself: run pi in a terminal and use /login.".into(),
826831 (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+ }
827836 };
828837 let setup = Section::new("Setup").row(row("Account").description(account)).render(cx);
829838
@@ -836,6 +845,7 @@ impl SettingsView {
836845 Provider::Claude => "Path to the Claude binary used by this instance.",
837846 Provider::Pi => "Path to the Pi coding agent binary.",
838847 Provider::Jcode => "Path to the jcode binary used by this instance.",
848+ Provider::Empryo => "Path to the empryo binary used by this instance.",
839849 })
840850 .status(binary_status)
841851 .control(text_field(&form.binary, 224., window, cx)),
@@ -846,6 +856,9 @@ impl SettingsView {
846856 Provider::Claude => "Custom Claude home and config directory. Keeps .claude.json and .claude separate.",
847857 Provider::Pi => "Custom Pi agent directory, so this instance keeps its own logins, settings and sessions.",
848858 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+ }
849862 })
850863 .control(text_field(&form.home, 224., window, cx)),
851864 );
@@ -863,6 +876,7 @@ impl SettingsView {
863876 Provider::Claude => "Additional CLI arguments passed on session start.",
864877 Provider::Pi => "Additional CLI arguments passed to pi --mode rpc on session start.",
865878 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.",
866880 })
867881 .control(text_field(&form.args, 224., window, cx)),
868882 );
+1−1src/ui/settings/widgets.rsView File
@@ -816,7 +816,7 @@ pub(crate) fn model_control(
816816 let name = match model.provider {
817817 Provider::Claude if model.legacy => format!("{} (legacy)", model.name),
818818 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),
820820 };
821821 (model.key(), name)
822822 })
+1−1src/ui/usage.rsView File
@@ -136,7 +136,7 @@ impl UsageView {
136136 Provider::Claude => sources.claude_dirs.push((label.clone(), home)),
137137 Provider::Pi => sources.pi_dirs.push((label.clone(), home)),
138138 // jcode keeps no usage logs this page reads.
139- Provider::Jcode => {}
139+ Provider::Jcode | Provider::Empryo => {}
140140 }
141141 if let Some(usage) = &state.usage {
142142 limits.push((SharedString::from(label), usage.clone()));
+19−1src/ui/workspace.rsView File
@@ -52,6 +52,7 @@ use crate::{
5252 mod actions;
5353 mod delegation;
5454 mod devices;
55+ mod genome;
5556 mod git_actions;
5657 mod header;
5758 mod palette;
@@ -261,6 +262,8 @@ pub struct Workspace {
261262 right_panels: HashMap<EntityId, surfaces::Surface>,
262263 /// The right panel while no thread is open.
263264 idle_right_panel: Option<surfaces::Surface>,
265+ /// The Genome map, while it shows: Empryo's map of a checkout.
266+ genome: Option<genome::GenomePanel>,
264267 /// The surface picker's focus, for its keys.
265268 surface_focus: FocusHandle,
266269 settled_open: bool,
@@ -435,6 +438,7 @@ impl Workspace {
435438 panel_open: true,
436439 right_panels: HashMap::new(),
437440 idle_right_panel: None,
441+ genome: None,
438442 surface_focus: cx.focus_handle(),
439443 settled_open: false,
440444 snoozed_open: false,
@@ -2255,6 +2259,15 @@ impl Render for Workspace {
22552259 let floating_device = self.render_floating_device(cx);
22562260 let p = metal::palette(cx);
22572261 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+ }
22582271 let picker_visible = self.surface_open(surfaces::Surface::Picker) && !self.settings_open;
22592272 let docked_terminal =
22602273 if self.surface_open(surfaces::Surface::Terminal) { self.render_terminal_drawer(true, window, cx) } else { None };
@@ -2263,7 +2276,11 @@ impl Render for Workspace {
22632276 && self.active_project().is_some()
22642277 && window.viewport_size().width >= px(PANEL_MIN_WINDOW);
22652278 // 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+ };
22672284 let mut drawer = self.render_terminal_drawer(false, window, cx);
22682285 let drawer_full_width = panel_visible;
22692286 let wallpaper = self.render_start_wallpaper(chat.is_some(), cx);
@@ -2430,6 +2447,7 @@ impl Render for Workspace {
24302447 )
24312448 .when(panel_visible, |this| this.child(self.render_panel(cx)))
24322449 .when(subagents_visible, |this| this.child(self.render_subagents_panel(cx)))
2450+ .when(genome_visible, |this| this.child(self.render_genome_panel(cx)))
24332451 .when(picker_visible, |this| this.child(self.render_surface_picker(cx)))
24342452 .children(docked_terminal)
24352453 .when_some(browser, |this, browser| this.child(browser))
+537−0src/ui/workspace/genome.rsaddedView File
@@ -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+ }
+11−0src/ui/workspace/sidebar.rsView File
@@ -1781,6 +1781,17 @@ impl Workspace {
17811781 .selected(self.usage_open)
17821782 .on_click(cx.listener(|this, _, window, cx| this.toggle_usage(window, cx))),
17831783 )
1784+ // Empryo's map of the open thread's code, while the thread runs on Empryo.
1785+ .when(self.genome_available(cx), |this| {
1786+ this.child(
1787+ MetalButton::new("open-genome")
1788+ .ghost()
1789+ .icon(Lucide::Dna)
1790+ .tooltip("Genome map")
1791+ .selected(self.surface_open(super::surfaces::Surface::Genome))
1792+ .on_click(cx.listener(|this, _, window, cx| this.toggle_genome(window, cx))),
1793+ )
1794+ })
17841795 .child(div().flex_1())
17851796 .child(self.render_update_button(cx)),
17861797 )
+15−5src/ui/workspace/surfaces.rsView File
@@ -1,6 +1,7 @@
11 //! 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.
45
56 use gpui_kit::component::{Icon, IconName, h_flex, v_flex};
67 use gpui_kit::prelude::FluentBuilder as _;
@@ -19,14 +20,17 @@ pub(super) enum Surface {
1920 Browser,
2021 Device,
2122 Terminal,
23+ /// Empryo's map of the thread's checkout.
24+ Genome,
2225 }
2326
2427 /// 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] = [
2629 (Surface::Subagents, "Subagents", "S"),
2730 (Surface::Browser, "Browser", "B"),
2831 (Surface::Device, "Device", "D"),
2932 (Surface::Terminal, "Terminal", "T"),
33+ (Surface::Genome, "Genome map", "G"),
3034 ];
3135
3236 /// How wide a terminal in the right panel is.
@@ -93,6 +97,9 @@ impl Workspace {
9397 }
9498
9599 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+ }
96103 if surface == Surface::Terminal {
97104 if !self.terminal_available(cx) {
98105 return;
@@ -107,7 +114,7 @@ impl Workspace {
107114 cx.notify();
108115 }
109116
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.
111118 fn surface_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
112119 let keystroke = &event.keystroke;
113120 if keystroke.modifiers.modified() {
@@ -127,13 +134,16 @@ impl Workspace {
127134 pub(super) fn render_surface_picker(&self, cx: &mut Context<Self>) -> impl IntoElement {
128135 let p = metal::palette(cx);
129136 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)| {
131140 let surface = *surface;
132141 let enabled = surface != Surface::Terminal || terminal;
133142 let icon = match surface {
134143 Surface::Subagents => Icon::new(Lucide::Bot),
135144 Surface::Browser => Icon::new(IconName::Globe),
136145 Surface::Device => Icon::new(Lucide::Smartphone),
146+ Surface::Genome => Icon::new(Lucide::Dna),
137147 _ => Icon::new(Lucide::SquareTerminal),
138148 };
139149 h_flex()