Vectrum
Sign In

Add jcode as a third agent, over its harness API

jcode threads run `jcode api --stdio`, the bridge to jcode's versioned
NDJSON harness API, and create or reattach their jcode session on it.
Streamed text, tool calls, cancel, steering via soft interrupts, model
and effort switching, token counts and retries map onto the transcript.
The health check opens a throwaway session (jcode only keeps sessions
that got a message) to read the model routes jcode can use. Settings
gain a jcode instance with JCODE_HOME (and its own daemon per home),
GitHub release update checks and `jcode update`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 627e200commit5e87f97

Showing 11 changed files with 638 additions and 64 deletions.

+27−17README.mdView File
@@ -2,13 +2,15 @@
22
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).
5- It drives [Claude Code](https://docs.claude.com/en/docs/claude-code) and
6- [Pi](https://github.com/earendil-works/pi).
5+ It drives [Claude Code](https://docs.claude.com/en/docs/claude-code),
6+ [Pi](https://github.com/earendil-works/pi) and [jcode](https://github.com/1jehuang/jcode).
77
88 Every thread runs its own agent process in the background. Claude Code threads talk to the
99 `claude` CLI over the stream-json protocol the Claude Agent SDKs use
1010 (`--input-format/--output-format stream-json` plus the stdio control channel). Pi threads
11- talk to `pi --mode rpc` over its JSONL RPC protocol.
11+ talk to `pi --mode rpc` over its JSONL RPC protocol. jcode threads talk to
12+ `jcode api --stdio`, a bridge to jcode's versioned harness API that starts jcode's shared
13+ daemon if it isn't running, so jcode threads also show up in jcode's own session list.
1214
1315 The layout and workflow follow T3 Code. The look follows [MetalUI](https://metalui.dev/), the
1416 "soft hardware" design system [vectrum.cc](https://vectrum.cc/) is built on: the
@@ -30,14 +32,16 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
3032 - A new thread opens on a start screen, "What should we build in *project*?", with the
3133 project as a switcher, a way to start without a project, and the branch it starts from.
3234 - A model picker across providers: Claude's models (current ones, plus legacy models
33- folded away) and every model Pi's configured providers offer. Star models to keep them
34- under **Favorites**, search across all of them, and pick the first nine with Ctrl+1–9.
35- Picking another provider's model starts a Pi or Claude thread in its place.
35+ folded away), every model Pi's configured providers offer, and every model route jcode's
36+ logins and provider profiles make available. Star models to keep them under **Favorites**,
37+ search across all of them, and pick the first nine with Ctrl+1–9. Picking another
38+ provider's model starts a thread of that kind in its place.
3639 - A reasoning picker: Low to Max (as the model supports), Claude's Ultracode and
37- Ultrathink, and Fast mode on models that have it. Pi threads list Pi's thinking levels.
40+ Ultrathink, and Fast mode on models that have it. Pi threads list Pi's thinking levels;
41+ jcode threads offer effort levels on its Anthropic, OpenAI and OpenRouter routes.
3842 All of it switches live.
39- - An access picker: Supervised, Auto-accept edits, Auto, Plan or Full access. Pi has no
40- approval prompts, so its threads always run with full access.
43+ - An access picker: Supervised, Auto-accept edits, Auto, Plan or Full access. Pi and jcode
44+ have no approval prompts, so their threads always run with full access.
4145 - Finished turns fold their tool calls and interim messages into one **Worked for 12s** row
4246 above the answer. Click it to unfold.
4347 - Under each turn that touched files, **N changed files +a −r** with a folder tree and
@@ -106,8 +110,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
106110 Catppuccin (Mocha, Macchiato, Frappé, Latte); GitHub (Dark, Dark Dimmed, Light); One Dark
107111 and One Light; Nord; Gruvbox Dark and Light; Rosé Pine (Main, Moon, Dawn); Solarized Dark
108112 and Light; and Dracula.
109- - **Providers**: every agent instance (several Claude or Pi instances side by side, say two
110- accounts), each with a switch, its version, login and plan, display name, binary, config
113+ - **Providers**: every agent instance (several Claude, Pi or jcode instances side by side,
114+ say two accounts), each with a switch, its version, login and plan, display name, binary, config
111115 folder, launch arguments and environment variables, plus updates and the health check
112116 interval, and usage hubs (CLIProxyAPI servers, with their management key).
113117 - **Usage**: cost, tokens and plan limits over the past day, week, month or quarter, read
@@ -120,6 +124,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
120124 - The `claude` CLI, logged in (`claude` once interactively)
121125 - Optional: [Pi](https://github.com/earendil-works/pi) for Pi threads
122126 (`npm install -g @earendil-works/pi-coding-agent`, then log in to a provider with `pi`)
127+ - Optional: [jcode](https://github.com/1jehuang/jcode) for jcode threads
128+ (`curl -fsSL https://jcode.sh/install | bash`, then `jcode login`)
123129 - Optional: agent-browser for the browser panel
124130 (`npm install -g agent-browser && agent-browser install`)
125131 - GPUI's Linux system libraries: Vulkan, Wayland/X11, xkbcommon, fontconfig, freetype
@@ -150,6 +156,8 @@ cargo run --release -- /path/to/project # defaults to the current directory
150156 then `~/.local/bin` and `~/.claude/local`.
151157 - `VECTRUM_CODE_PI_BIN`: path to the `pi` binary. Otherwise it's looked up on `PATH`, then in
152158 `~/.local/bin`, `~/.npm-global/bin`, `~/.bun/bin`, `~/.volta/bin` and nvm's Node versions.
159+ - `VECTRUM_CODE_JCODE_BIN`: path to the `jcode` binary. Otherwise it's looked up on `PATH`,
160+ then in `~/.local/bin` and `~/.cargo/bin`.
153161
154162 Favorite models and everything in the settings view are saved in
155163 `~/.config/vectrum-code/settings.json`. The environment variables above win over the binary
@@ -159,11 +167,11 @@ in its `scratch` folder.
159167
160168 ## Layout
161169
162- - `src/claude.rs`: spawns and owns the agent process, `claude` or `pi` (stdin writer and
163- stdout/stderr reader threads).
164- - `src/session.rs`: per-thread state. Folds Claude's stream-json and Pi's RPC events into
165- chat items, handles permission requests, control requests and extension dialogs, and
166- snapshots each turn's changes.
170+ - `src/claude.rs`: spawns and owns the agent process, `claude`, `pi` or `jcode api --stdio`
171+ (stdin writer and stdout/stderr reader threads).
172+ - `src/session.rs`: per-thread state. Folds Claude's stream-json, Pi's RPC and jcode's
173+ harness API events into chat items, handles permission requests, control requests and
174+ extension dialogs, and snapshots each turn's changes.
167175 - `src/models.rs`: the model catalog across providers, and favorites.
168176 - `src/git.rs`: branch and change stats, working-tree snapshots, and diffs.
169177 - `src/settings.rs`: the saved settings file, cached in memory.
@@ -188,4 +196,6 @@ in its `scratch` folder.
188196 There is no UI for Claude's
189197 `AskUserQuestion` (the tool is disabled, so Claude asks in plain text) or for Pi extensions
190198 that ask for free text, code blocks have no syntax highlighting, and T3 Code's project
191- scripts and terminal drawer aren't here yet.
199+ scripts and terminal drawer aren't here yet. jcode models can't write thread titles (every
200+ jcode run is saved as a session), and jcode's side panel, applets and background-task
201+ progress aren't shown.
+15−0src/browser.rsView File
@@ -134,6 +134,21 @@ pub fn agent_prompt(session: &str) -> String {
134134 )
135135 }
136136
137+ /// The browser instructions for jcode, whose tools run in its daemon rather
138+ /// than under the thread's process, so the session and binary are spelled out.
139+ pub fn jcode_prompt(session: &str, binary: &Path) -> String {
140+ let binary = binary.display();
141+ format!(
142+ "The user's app has a browser panel showing a shared headless browser that you drive with the \
143+ `agent-browser` CLI at `{binary}`. Its session is `{session}`; pass `--session {session}` to every \
144+ command. The user watches the browser live and may click and type in it too. Core commands: \
145+ `open <url>`, `snapshot -i` (interactive elements with @refs), `click @e2`, `fill @e3 \"text\"`, \
146+ `screenshot [path]`, `get url`, `back|forward|reload`; `skills get core` has the full guide. Use it \
147+ to check web pages you build or need to see, and leave the page open when you're done so the user \
148+ can look at it. Prefer it over your own browser tool for pages the user should see."
149+ )
150+ }
151+
137152 /// Sets a thread's browser session in an agent's environment, with
138153 /// `agent-browser`'s folder on PATH, and returns the prompt that tells the
139154 /// agent about it. `None` when agent-browser isn't installed.
+38−8src/claude.rsView File
@@ -1,6 +1,7 @@
11 //! Spawns the long-running agent process behind a thread: the `claude` CLI
2- //! speaking stream-json (the transport the Claude Agent SDKs use), or Pi in
3- //! its JSONL RPC mode. Both read commands on stdin and stream events on stdout.
2+ //! speaking stream-json (the transport the Claude Agent SDKs use), Pi in its
3+ //! JSONL RPC mode, or jcode's harness API bridge, which connects to jcode's
4+ //! shared daemon. All read commands on stdin and stream events on stdout.
45
56 use std::{
67 io::{BufRead, BufReader, Write},
@@ -140,13 +141,14 @@ pub struct LaunchConfig {
140141 /// Which agent, with its binary, home folder, arguments and environment.
141142 pub instance: ProviderInstance,
142143 pub cwd: PathBuf,
143- /// A Claude model alias, or `provider/model` for Pi. `None` is the default.
144+ /// A Claude model alias, `provider/model` for Pi, or a jcode model spec.
145+ /// `None` is the default.
144146 pub model: Option<String>,
145147 pub permission_mode: PermissionMode,
146148 /// `None` leaves effort at the model's default.
147149 pub effort: Option<Effort>,
148- /// Session to resume (a Claude session id or a Pi session file), used to
149- /// restart a thread whose process stopped.
150+ /// Session to resume (a Claude or jcode session id, or a Pi session
151+ /// file), used to restart a thread whose process stopped.
150152 pub resume: Option<String>,
151153 /// The thread's agent-browser session, shared with the browser panel.
152154 pub browser_session: Option<String>,
@@ -170,9 +172,13 @@ impl AgentProcess {
170172 let (binary, mut command) = match config.instance.kind {
171173 Provider::Claude => claude_command(config)?,
172174 Provider::Pi => pi_command(config)?,
175+ // jcode's tools run in its daemon, out of reach of this process's
176+ // environment; the thread tells it about the browser in a message.
177+ Provider::Jcode => jcode_command(config)?,
173178 };
174- // Both agents take extra system prompt with this flag.
175- if let Some(session) = &config.browser_session
179+ // Claude and Pi take extra system prompt with this flag.
180+ if config.instance.kind != Provider::Jcode
181+ && let Some(session) = &config.browser_session
176182 && let Some(prompt) = browser::prepare_agent(&mut command, session)
177183 {
178184 command.args(["--append-system-prompt", &prompt]);
@@ -342,11 +348,22 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
342348 Ok((binary, command))
343349 }
344350
351+ /// The bridge to jcode's harness API on stdin and stdout. It starts jcode's
352+ /// daemon if none is running; the thread then creates or attaches its
353+ /// session over the API.
354+ fn jcode_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
355+ let (binary, mut command) = base_command(&config.instance)?;
356+ command.args(split_args(&config.instance.args));
357+ command.args(["--no-update", "api", "--stdio"]).current_dir(&config.cwd);
358+ Ok((binary, command))
359+ }
360+
345361 /// The environment variable that moves an agent's home folder.
346362 pub fn home_var(kind: Provider) -> &'static str {
347363 match kind {
348364 Provider::Claude => "CLAUDE_CONFIG_DIR",
349365 Provider::Pi => "PI_CODING_AGENT_DIR",
366+ Provider::Jcode => "JCODE_HOME",
350367 }
351368 }
352369
@@ -355,6 +372,7 @@ pub fn binary_var(kind: Provider) -> &'static str {
355372 match kind {
356373 Provider::Claude => "VECTRUM_CODE_CLAUDE_BIN",
357374 Provider::Pi => "VECTRUM_CODE_PI_BIN",
375+ Provider::Jcode => "VECTRUM_CODE_JCODE_BIN",
358376 }
359377 }
360378
@@ -372,7 +390,18 @@ pub fn base_command(instance: &ProviderInstance) -> Result<(PathBuf, Command)> {
372390 command.env_remove(key);
373391 }
374392 if !instance.home.trim().is_empty() {
375- command.env(home_var(instance.kind), expand_home(instance.home.trim()));
393+ let home = expand_home(instance.home.trim());
394+ // jcode's daemon is found by its socket, which lives in the runtime
395+ // folder rather than the home; give each home its own daemon.
396+ if instance.kind == Provider::Jcode {
397+ let runtime = home.join("runtime");
398+ if std::fs::create_dir_all(&runtime).is_ok() {
399+ use std::os::unix::fs::PermissionsExt;
400+ let _ = std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o700));
401+ command.env("JCODE_RUNTIME_DIR", runtime);
402+ }
403+ }
404+ command.env(home_var(instance.kind), home);
376405 }
377406 for var in &instance.env {
378407 if !var.key.trim().is_empty() {
@@ -419,6 +448,7 @@ pub fn find_binary(instance: &ProviderInstance) -> Option<PathBuf> {
419448 home.join(".bun/bin/pi"),
420449 home.join(".volta/bin/pi"),
421450 ],
451+ Provider::Jcode => vec![home.join(".local/bin/jcode"), home.join(".cargo/bin/jcode")],
422452 };
423453 fallbacks.extend([PathBuf::from("/usr/local/bin").join(name), PathBuf::from("/usr/bin").join(name)]);
424454 // nvm keeps one bin directory per Node version.
+205−9src/models.rsView File
@@ -2,8 +2,9 @@
22 //! pickers and settings read. Each configured provider instance gets a
33 //! background health check: Claude's models, account, slash commands and
44 //! usage limits come from a short-lived `claude` asked over its control
5- //! channel, Pi's models from asking `pi` for what its providers offer. Also
6- //! the user's favorite models.
5+ //! channel, Pi's models from asking `pi` for what its providers offer,
6+ //! jcode's from the model routes its daemon reports. Also the user's
7+ //! favorite models.
78
89 use std::{
910 io::{BufRead, BufReader, Write},
@@ -27,15 +28,26 @@ pub enum Provider {
2728 #[default]
2829 Claude,
2930 Pi,
31+ Jcode,
3032 }
3133
3234 impl Provider {
33- pub const ALL: [Provider; 2] = [Provider::Claude, Provider::Pi];
35+ pub const ALL: [Provider; 3] = [Provider::Claude, Provider::Pi, Provider::Jcode];
3436
3537 pub fn label(self) -> &'static str {
3638 match self {
3739 Provider::Claude => "Claude",
3840 Provider::Pi => "Pi",
41+ Provider::Jcode => "jcode",
42+ }
43+ }
44+
45+ /// The agent's full name, for menus and messages.
46+ pub fn agent_name(self) -> &'static str {
47+ match self {
48+ Provider::Claude => "Claude Code",
49+ Provider::Pi => "Pi",
50+ Provider::Jcode => "jcode",
3951 }
4052 }
4153
@@ -43,9 +55,21 @@ impl Provider {
4355 match self {
4456 Provider::Claude => "claude",
4557 Provider::Pi => "pi",
58+ Provider::Jcode => "jcode",
4659 }
4760 }
4861
62+ /// Whether the agent can answer a one-off prompt, for text generation,
63+ /// without keeping a session around. Every jcode run is saved.
64+ pub fn answers_one_off(self) -> bool {
65+ self != Provider::Jcode
66+ }
67+
68+ /// Whether the agent asks before running tools. Pi and jcode just run them.
69+ pub fn asks_permission(self) -> bool {
70+ self == Provider::Claude
71+ }
72+
4973 pub fn from_key(key: &str) -> Option<Self> {
5074 Self::ALL.into_iter().find(|provider| provider.key() == key)
5175 }
@@ -56,8 +80,8 @@ pub struct ModelEntry {
5680 pub provider: Provider,
5781 /// The provider instance offering it (`claude`, `claude-2`, `pi`).
5882 pub instance: String,
59- /// What the agent is launched with: a Claude alias or model id, or Pi's
60- /// `provider/model`.
83+ /// What the agent is launched with: a Claude alias or model id, Pi's
84+ /// `provider/model`, or a jcode model spec like `claude-oauth:claude-opus-5`.
6185 pub id: String,
6286 /// The concrete model, e.g. `claude-opus-5-5` behind the `opus` alias.
6387 pub resolved: String,
@@ -263,6 +287,7 @@ impl Catalog {
263287 let probe = match config.kind {
264288 Provider::Claude => probe_claude(&config),
265289 Provider::Pi => probe_pi(&config),
290+ Provider::Jcode => probe_jcode(&config),
266291 };
267292 let latest = if update_checks { latest_version(config.kind) } else { None };
268293 (probe, latest)
@@ -282,7 +307,7 @@ impl Catalog {
282307 match result {
283308 Ok(probe) => {
284309 state.status = ProbeStatus::Ready;
285- if !probe.models.is_empty() || state.config.kind == Provider::Pi {
310+ if !probe.models.is_empty() || state.config.kind != Provider::Claude {
286311 state.models = probe.models;
287312 state.default = probe.default;
288313 }
@@ -300,7 +325,7 @@ impl Catalog {
300325 if state.config.enabled {
301326 state.status = status;
302327 }
303- if state.config.kind == Provider::Pi {
328+ if state.config.kind != Provider::Claude {
304329 state.models.clear();
305330 }
306331 }
@@ -623,11 +648,157 @@ pub fn pi_model_from(instance: &str, model: &Value) -> Option<ModelEntry> {
623648 })
624649 }
625650
626- /// The newest published version of an agent, from the npm registry.
651+ /// Starts jcode's API bridge just long enough to open a throwaway session
652+ /// and read the model routes its daemon offers. jcode only keeps sessions
653+ /// that got a message, so the check leaves nothing behind.
654+ fn probe_jcode(config: &ProviderInstance) -> Result<Probe, ProbeStatus> {
655+ let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string());
656+ let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?;
657+ command
658+ .args(["--no-update", "api", "--stdio"])
659+ .current_dir(std::env::temp_dir())
660+ .stdin(Stdio::piped())
661+ .stdout(Stdio::piped())
662+ .stderr(Stdio::null());
663+ let mut child = command.spawn().map_err(|err| failed(&err))?;
664+ let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?;
665+ let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?;
666+ let (tx, rx) = mpsc::channel();
667+ std::thread::spawn(move || {
668+ for line in BufReader::new(stdout).lines().map_while(Result::ok) {
669+ if let Ok(value) = serde_json::from_str::<Value>(&line)
670+ && tx.send(value).is_err()
671+ {
672+ return;
673+ }
674+ }
675+ });
676+ let mut send = |id: u64, request: Value| {
677+ let mut request = request;
678+ request["v"] = 1.into();
679+ request["id"] = id.into();
680+ writeln!(stdin, "{request}")
681+ };
682+ let hello = json!({"req": "hello", "min_version": 1, "max_version": 1, "client": crate::session::JCODE_CLIENT});
683+ let working_dir = std::env::temp_dir().display().to_string();
684+ send(1, hello)
685+ .and_then(|_| send(2, json!({"req": "create_session", "working_dir": working_dir})))
686+ .map_err(|err| failed(&err))?;
687+
688+ // The daemon may need a while to start the first time.
689+ let deadline = Instant::now() + Duration::from_secs(45);
690+ let mut info = None;
691+ while info.is_none() {
692+ let Some(left) = deadline.checked_duration_since(Instant::now()) else { break };
693+ let Ok(event) = rx.recv_timeout(left) else { break };
694+ match event.get("ev").and_then(Value::as_str) {
695+ Some("attached") => {
696+ let session = event.pointer("/session/session_id").cloned().unwrap_or_default();
697+ send(3, json!({"req": "get_runtime_info", "session_id": session})).map_err(|err| failed(&err))?;
698+ }
699+ Some("runtime_info") if event.get("reply_to").and_then(Value::as_u64) == Some(3) => info = Some(event),
700+ Some("error") => {
701+ let message = event.get("message").and_then(Value::as_str).unwrap_or("unknown error").to_string();
702+ let _ = child.kill();
703+ let _ = child.wait();
704+ return Err(failed(&message));
705+ }
706+ _ => {}
707+ }
708+ }
709+ let _ = child.kill();
710+ let _ = child.wait();
711+ let info = info.ok_or_else(|| failed(&"jcode didn't list its models"))?;
712+ let mut models: Vec<ModelEntry> = Vec::new();
713+ for route in info.get("routes").and_then(Value::as_array).into_iter().flatten() {
714+ if let Some(model) = jcode_model_from(&config.id, route)
715+ && !models.iter().any(|known| known.id == model.id)
716+ {
717+ models.push(model);
718+ }
719+ }
720+ let version = base_command(config).ok().and_then(|(_, mut command)| {
721+ let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?;
722+ first_version(&String::from_utf8_lossy(&output.stdout))
723+ });
724+ // What new sessions start on, e.g. `Default claude-opus-5 via Anthropic`.
725+ let plan = match (info.get("provider").and_then(Value::as_str), info.get("model").and_then(Value::as_str)) {
726+ (Some(provider), Some(model)) => Some(format!("Default {model} via {provider}")),
727+ _ => None,
728+ };
729+ Ok(Probe { models, plan, version, ..Probe::default() })
730+ }
731+
732+ /// One of jcode's model routes as a picker entry, if it's usable.
733+ pub fn jcode_model_from(instance: &str, route: &Value) -> Option<ModelEntry> {
734+ if route.get("available").and_then(Value::as_bool) != Some(true) {
735+ return None;
736+ }
737+ let model = route.get("model")?.as_str()?;
738+ let provider = route.get("provider").and_then(Value::as_str).unwrap_or_default();
739+ let method = route.get("api_method").and_then(Value::as_str).unwrap_or_default();
740+ // Anthropic and OpenAI can be reached with a login or an API key.
741+ let detail = match method {
742+ "claude-oauth" | "openai-oauth" => format!("{provider} · Subscription"),
743+ "claude-api" | "openai-api-key" => format!("{provider} · API key"),
744+ _ => provider.to_string(),
745+ };
746+ let efforts = match method {
747+ "claude-oauth" | "claude-api" => vec![Effort::Low, Effort::Medium, Effort::High, Effort::XHigh, Effort::Max],
748+ "openai-oauth" | "openai-api-key" => vec![Effort::Low, Effort::Medium, Effort::High, Effort::XHigh],
749+ "openrouter" => vec![Effort::Low, Effort::Medium, Effort::High],
750+ _ => Vec::new(),
751+ };
752+ Some(ModelEntry {
753+ provider: Provider::Jcode,
754+ instance: instance.to_string(),
755+ id: jcode_model_spec(model, provider, method),
756+ resolved: model.to_string(),
757+ name: model.to_string(),
758+ detail,
759+ legacy: false,
760+ efforts,
761+ fast_mode: false,
762+ auto_mode: false,
763+ context_window: None,
764+ })
765+ }
766+
767+ /// What jcode's `set_model` takes to pick a model on a particular route, as
768+ /// its own model picker builds it. A bare name stays on the current provider.
769+ fn jcode_model_spec(model: &str, provider: &str, method: &str) -> String {
770+ let prefix = match method {
771+ "claude-oauth" => Some("claude-oauth"),
772+ "claude-api" => Some("claude-api"),
773+ "openai-oauth" => Some("openai-oauth"),
774+ "openai-api-key" => Some("openai-api"),
775+ "copilot" => Some("copilot"),
776+ "cursor" => Some("cursor"),
777+ "bedrock" => Some("bedrock"),
778+ _ if provider == "Antigravity" => Some("antigravity"),
779+ _ => method.strip_prefix("openai-compatible:"),
780+ };
781+ match prefix {
782+ Some(prefix) => format!("{prefix}:{model}"),
783+ None if method == "openrouter" && !matches!(provider, "auto" | "OpenRouter" | "") => {
784+ format!("{model}@{provider}")
785+ }
786+ None => model.to_string(),
787+ }
788+ }
789+
790+ /// The newest published version of an agent, from the npm registry (or
791+ /// GitHub, for jcode).
627792 fn latest_version(kind: Provider) -> Option<String> {
628793 let package = match kind {
629794 Provider::Claude => "@anthropic-ai/claude-code",
630795 Provider::Pi => "@earendil-works/pi-coding-agent",
796+ Provider::Jcode => {
797+ let url = "https://api.github.com/repos/1jehuang/jcode/releases/latest";
798+ let body = crate::http::get(url, &[("Accept", "application/vnd.github+json")], Duration::from_secs(10)).ok()?;
799+ let tag = serde_json::from_str::<Value>(&body).ok()?.get("tag_name")?.as_str()?.to_string();
800+ return Some(tag.trim_start_matches('v').to_string());
801+ }
631802 };
632803 let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?;
633804 serde_json::from_str::<Value>(&body).ok()?.get("version")?.as_str().map(str::to_string)
@@ -648,6 +819,10 @@ pub fn update_command(config: &ProviderInstance) -> Option<(std::process::Comman
648819 command.args(["install", "-g", "@earendil-works/pi-coding-agent@latest"]);
649820 Some((command, "npm install -g @earendil-works/pi-coding-agent"))
650821 }
822+ Provider::Jcode => {
823+ command.args(["update"]);
824+ Some((command, "jcode update"))
825+ }
651826 }
652827 }
653828
@@ -687,7 +862,7 @@ fn provider_label(provider: &str) -> String {
687862
688863 #[cfg(test)]
689864 mod tests {
690- use super::{claude_probe_from, first_version, provider_label, version_newer};
865+ use super::{claude_probe_from, first_version, jcode_model_from, provider_label, version_newer};
691866 use serde_json::json;
692867
693868 #[::core::prelude::v1::test]
@@ -735,4 +910,25 @@ mod tests {
735910 assert_eq!(first_version("pi v1.1.0\n").as_deref(), Some("1.1.0"));
736911 assert_eq!(first_version("2.1.296 (Claude Code)").as_deref(), Some("2.1.296"));
737912 }
913+
914+ #[::core::prelude::v1::test]
915+ fn jcode_routes() {
916+ let route = |model: &str, provider: &str, method: &str, available: bool| {
917+ json!({"model": model, "provider": provider, "api_method": method, "available": available, "detail": ""})
918+ };
919+ let model = jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-oauth", true)).unwrap();
920+ assert_eq!(model.id, "claude-oauth:claude-opus-5");
921+ assert_eq!(model.detail, "Anthropic · Subscription");
922+ assert_eq!(model.key(), "jcode:claude-oauth:claude-opus-5");
923+ assert!(model.efforts.contains(&crate::claude::Effort::Max));
924+ let model = jcode_model_from("jcode", &route("glm-5", "Z.AI", "openai-compatible:zai", true)).unwrap();
925+ assert_eq!(model.id, "zai:glm-5");
926+ assert!(model.efforts.is_empty());
927+ let model = jcode_model_from("jcode", &route("gpt-5.5", "OpenAI", "openai-api-key", true)).unwrap();
928+ assert_eq!(model.id, "openai-api:gpt-5.5");
929+ let model = jcode_model_from("jcode", &route("anthropic/claude-sonnet-5", "Anthropic", "openrouter", true));
930+ assert_eq!(model.unwrap().id, "anthropic/claude-sonnet-5@Anthropic");
931+ assert_eq!(jcode_model_from("jcode", &route("auto", "auto", "openrouter", true)).unwrap().id, "auto");
932+ assert!(jcode_model_from("jcode", &route("claude-opus-5", "Anthropic", "claude-api", false)).is_none());
933+ }
738934 }
+310−10src/session.rsView File
@@ -1,5 +1,5 @@
1- //! One chat thread: owns the background agent process (Claude Code or Pi) and
2- //! folds its event stream into a list of renderable chat items.
1+ //! One chat thread: owns the background agent process (Claude Code, Pi or
2+ //! jcode) and folds its event stream into a list of renderable chat items.
33
44 use std::{
55 collections::{HashMap, HashSet, VecDeque},
@@ -29,6 +29,8 @@ const STDERR_TAIL: usize = 20;
2929 pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
3030 /// The API's limit for one base64 image.
3131 const MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
32+ /// How the app introduces itself to jcode's harness API.
33+ pub const JCODE_CLIENT: &str = concat!("vectrum-code/", env!("CARGO_PKG_VERSION"));
3234
3335 static NEXT_SESSION: AtomicU64 = AtomicU64::new(1);
3436
@@ -380,6 +382,25 @@ struct StreamingMessage {
380382 blocks: HashMap<u64, usize>,
381383 }
382384
385+ /// Where a thread's connection to jcode's harness API stands. Requests are
386+ /// numbered; most name the session, so they wait until it's attached.
387+ #[derive(Default)]
388+ struct JcodeLink {
389+ /// The jcode session this process is attached to.
390+ session: Option<String>,
391+ /// Session requests waiting for the attach.
392+ held: Vec<(&'static str, Value)>,
393+ /// What each request waiting for its reply was for, to explain errors.
394+ requests: HashMap<u64, &'static str>,
395+ /// The chat item each streamed text message renders into.
396+ texts: HashMap<String, usize>,
397+ /// The serving provider, e.g. `Anthropic`, which decides how token
398+ /// counts add up.
399+ provider: Option<String>,
400+ /// The browser panel's instructions, sent along with the next message.
401+ browser_note: Option<String>,
402+ }
403+
383404 pub struct Session {
384405 /// Names the thread's file on disk.
385406 pub id: String,
@@ -402,7 +423,8 @@ pub struct Session {
402423 pub items: Vec<ChatItem>,
403424 pub status: SessionStatus,
404425 pub thinking: bool,
405- /// What a restarted process resumes: Claude's session id or Pi's session file.
426+ /// What a restarted process resumes: Claude's or jcode's session id, or
427+ /// Pi's session file.
406428 resume_id: Option<String>,
407429 /// The model picked for the thread; `None` means the agent's default.
408430 pub model: Option<String>,
@@ -465,6 +487,7 @@ pub struct Session {
465487 streamed_message_ids: HashSet<String>,
466488 pending_controls: HashMap<String, PendingControl>,
467489 next_request_id: u64,
490+ jcode: JcodeLink,
468491 _events_task: Task<()>,
469492 }
470493
@@ -562,6 +585,7 @@ impl Session {
562585 streamed_message_ids: HashSet::new(),
563586 pending_controls: HashMap::new(),
564587 next_request_id: 0,
588+ jcode: JcodeLink::default(),
565589 _events_task: Task::ready(()),
566590 }
567591 }
@@ -592,7 +616,7 @@ impl Session {
592616 model: self.model.clone(),
593617 permission_mode: self.permission_mode,
594618 effort: self.reasoning.effort(),
595- resume,
619+ resume: resume.clone(),
596620 browser_session: Some(browser::session_name(&self.id)),
597621 };
598622 let process = match AgentProcess::spawn(&config, tx) {
@@ -630,6 +654,28 @@ impl Session {
630654 self.pi(json!({"type": "get_state"}));
631655 self.pi(json!({"type": "get_available_thinking_levels"}));
632656 }
657+ Provider::Jcode => {
658+ self.jcode = JcodeLink {
659+ browser_note: browser::find_binary()
660+ .map(|binary| browser::jcode_prompt(&browser::session_name(&self.id), &binary)),
661+ ..JcodeLink::default()
662+ };
663+ let hello = json!({"req": "hello", "min_version": 1, "max_version": 1, "client": JCODE_CLIENT});
664+ self.jcode_send("connect", hello);
665+ match resume {
666+ Some(session_id) => self.jcode_send("attach", json!({"req": "attach_session", "session_id": session_id})),
667+ None => self.jcode_create_session(),
668+ }
669+ if let Some(model) = self.model.clone() {
670+ self.jcode_request("switch models", json!({"req": "set_model", "model": model}));
671+ }
672+ if let Some(effort) = self.reasoning.effort() {
673+ self.jcode_request(
674+ "change the reasoning effort",
675+ json!({"req": "set_reasoning_effort", "effort": effort.as_cli()}),
676+ );
677+ }
678+ }
633679 }
634680
635681 self._events_task = cx.spawn(async move |this, cx| {
@@ -676,6 +722,35 @@ impl Session {
676722 }
677723 }
678724
725+ /// Sends a jcode API request now. `what` finishes "jcode couldn't …" if
726+ /// it fails.
727+ fn jcode_send(&mut self, what: &'static str, mut request: Value) {
728+ self.next_request_id += 1;
729+ let id = self.next_request_id;
730+ request["v"] = 1.into();
731+ request["id"] = id.into();
732+ if let Some(process) = &self.process {
733+ self.jcode.requests.insert(id, what);
734+ process.send(request);
735+ }
736+ }
737+
738+ /// Sends a request about the thread's jcode session, once it's attached.
739+ fn jcode_request(&mut self, what: &'static str, mut request: Value) {
740+ match self.jcode.session.clone() {
741+ Some(session_id) => {
742+ request["session_id"] = session_id.into();
743+ self.jcode_send(what, request);
744+ }
745+ None => self.jcode.held.push((what, request)),
746+ }
747+ }
748+
749+ fn jcode_create_session(&mut self) {
750+ let working_dir = self.cwd.display().to_string();
751+ self.jcode_send("start a session", json!({"req": "create_session", "working_dir": working_dir}));
752+ }
753+
679754 // ---- user actions -------------------------------------------------
680755
681756 /// Sends a message now, even mid-turn, where the agent takes it in as
@@ -877,6 +952,21 @@ impl Session {
877952 }
878953 self.pi(command);
879954 }
955+ Provider::Jcode => {
956+ let images: Vec<(&str, &String)> = images.iter().map(|(media_type, data)| (*media_type, data)).collect();
957+ // Mid-turn, a soft interrupt reaches the model at its next
958+ // stop without cancelling what it's doing.
959+ if self.is_working() {
960+ let request = json!({"req": "soft_interrupt", "content": prompt, "images": images});
961+ self.jcode_request("steer the turn", request);
962+ } else {
963+ let mut request = json!({"req": "send_message", "content": prompt, "images": images});
964+ if let Some(note) = self.jcode.browser_note.take() {
965+ request["system_reminder"] = note.into();
966+ }
967+ self.jcode_request("send the message", request);
968+ }
969+ }
880970 }
881971 self.status = SessionStatus::Working;
882972 }
@@ -1068,6 +1158,7 @@ impl Session {
10681158 match self.provider {
10691159 Provider::Claude => self.control(PendingControl::Interrupt, json!({"subtype": "interrupt"})),
10701160 Provider::Pi => self.pi(json!({"type": "abort"})),
1161+ Provider::Jcode => self.jcode_request("stop the turn", json!({"req": "cancel"})),
10711162 }
10721163 cx.notify();
10731164 }
@@ -1090,6 +1181,13 @@ impl Session {
10901181 self.pi(json!({"type": "get_available_thinking_levels"}));
10911182 }
10921183 }
1184+ // Without a process, the next one starts on the new model.
1185+ Provider::Jcode if self.process.is_some() => {
1186+ if let Some(model) = model {
1187+ self.jcode_request("switch models", json!({"req": "set_model", "model": model}));
1188+ }
1189+ }
1190+ Provider::Jcode => {}
10931191 }
10941192 cx.notify();
10951193 }
@@ -1100,8 +1198,8 @@ impl Session {
11001198 cx.notify();
11011199 return;
11021200 }
1103- // Pi has no permission prompts to configure.
1104- if self.provider != Provider::Claude {
1201+ // Pi and jcode have no permission prompts to configure.
1202+ if !self.provider.asks_permission() {
11051203 return;
11061204 }
11071205 self.permission_mode = mode;
@@ -1135,6 +1233,15 @@ impl Session {
11351233 self.pi(json!({"type": "set_thinking_level", "level": effort.as_cli()}));
11361234 }
11371235 }
1236+ Provider::Jcode if self.process.is_some() => {
1237+ if let Some(effort) = reasoning.effort() {
1238+ self.jcode_request(
1239+ "change the reasoning effort",
1240+ json!({"req": "set_reasoning_effort", "effort": effort.as_cli()}),
1241+ );
1242+ }
1243+ }
1244+ Provider::Jcode => {}
11381245 }
11391246 cx.notify();
11401247 }
@@ -1453,6 +1560,7 @@ impl Session {
14531560 ProcessEvent::Message(message) => match self.provider {
14541561 Provider::Claude => self.handle_message(message, cx),
14551562 Provider::Pi => self.handle_pi_message(message, cx),
1563+ Provider::Jcode => self.handle_jcode_message(message, cx),
14561564 },
14571565 ProcessEvent::Stderr(line) => {
14581566 if self.stderr_tail.len() == STDERR_TAIL {
@@ -1475,10 +1583,7 @@ impl Session {
14751583 self.base_snapshot = None;
14761584 self.finish_open_tools("The agent exited before the tool finished.");
14771585
1478- let agent = match self.provider {
1479- Provider::Claude => "Claude Code",
1480- Provider::Pi => "Pi",
1481- };
1586+ let agent = self.provider.agent_name();
14821587 let mut text = match code {
14831588 Some(code) => format!("{agent} exited with status {code}."),
14841589 None => format!("{agent} was terminated."),
@@ -2245,6 +2350,191 @@ impl Session {
22452350 self.updated = SystemTime::now();
22462351 }
22472352
2353+ // ---- jcode (harness API) -------------------------------------------
2354+
2355+ fn handle_jcode_message(&mut self, message: Value, cx: &mut Context<Self>) {
2356+ let reply_to = message.get("reply_to").and_then(Value::as_u64);
2357+ let request = reply_to.and_then(|id| self.jcode.requests.remove(&id));
2358+ let text = |key: &str| message.get(key).and_then(Value::as_str).unwrap_or_default();
2359+ match text("ev") {
2360+ "attached" => {
2361+ let Some(session_id) = message.pointer("/session/session_id").and_then(Value::as_str) else { return };
2362+ self.jcode.session = Some(session_id.to_string());
2363+ self.resume_id = Some(session_id.to_string());
2364+ if self.status == SessionStatus::Starting {
2365+ self.status = SessionStatus::Idle;
2366+ }
2367+ for (what, request) in std::mem::take(&mut self.jcode.held) {
2368+ self.jcode_request(what, request);
2369+ }
2370+ }
2371+ "error" => {
2372+ let error = text("message");
2373+ match request {
2374+ // The session is gone from jcode's store; carry on in a new one.
2375+ Some("attach") => {
2376+ self.push_notice(
2377+ NoticeKind::Info,
2378+ "jcode no longer has this thread's session, so it continues in a new one.",
2379+ );
2380+ self.jcode_create_session();
2381+ }
2382+ Some(what) => {
2383+ if what == "send the message" {
2384+ // The message never started a turn, so none will end.
2385+ self.status = SessionStatus::Idle;
2386+ self.turn_started = None;
2387+ self.base_snapshot = None;
2388+ }
2389+ self.push_notice(NoticeKind::Error, format!("jcode couldn't {what}: {error}"));
2390+ }
2391+ None => self.push_notice(NoticeKind::Error, format!("jcode: {error}")),
2392+ }
2393+ }
2394+ "model_info" => {
2395+ if let Some(model) = message.get("model").and_then(Value::as_str) {
2396+ self.active_model = Some(model.to_string());
2397+ }
2398+ self.jcode.provider = message.get("provider").and_then(Value::as_str).map(str::to_string);
2399+ }
2400+ "message_accepted" => self.begin_turn(cx),
2401+ "soft_interrupt_injected" if text("point") == "turn_start" => self.begin_turn(cx),
2402+ "text_delta" => {
2403+ self.begin_turn(cx);
2404+ self.thinking = false;
2405+ let ix = self.jcode_text_item(text("message_id"), cx);
2406+ self.append_text(ix, text("text"), cx);
2407+ }
2408+ // A rewrite of what was streamed; empty takes the message back.
2409+ "text_replace" => {
2410+ let ix = self.jcode_text_item(text("message_id"), cx);
2411+ if let Some(ChatItem::Assistant(answer)) = self.items.get_mut(ix) {
2412+ answer.text = text("text").to_string();
2413+ answer.shown = answer.text.len();
2414+ let replacement = answer.text.clone();
2415+ answer.view.update(cx, |view, cx| view.set_text(&replacement, cx));
2416+ }
2417+ }
2418+ "text_done" => {
2419+ if let Some(&ix) = self.jcode.texts.get(text("message_id")) {
2420+ self.flush_item(ix, cx);
2421+ }
2422+ }
2423+ "reasoning_delta" => {
2424+ self.begin_turn(cx);
2425+ self.thinking = true;
2426+ }
2427+ "reasoning_done" => self.thinking = false,
2428+ "tool_start" => {
2429+ self.begin_turn(cx);
2430+ self.thinking = false;
2431+ let id = text("call_id");
2432+ if self.find_tool_mut(id).is_none() {
2433+ self.push_tool(id, text("name"), Value::Null);
2434+ }
2435+ }
2436+ "tool_input_delta" => {
2437+ let delta = text("delta");
2438+ if let Some(tool) = self.jcode_tool(text("call_id")) {
2439+ tool.partial_input.push_str(delta);
2440+ }
2441+ }
2442+ "tool_exec" => {
2443+ if let Some(tool) = self.jcode_tool(text("call_id")) {
2444+ take_partial_input(tool);
2445+ }
2446+ }
2447+ "tool_done" => {
2448+ let output = text("output").to_string();
2449+ let error = message.get("error").and_then(Value::as_str).map(str::to_string);
2450+ let tool = match self.find_tool_mut(text("call_id")) {
2451+ Some(tool) => tool,
2452+ None => {
2453+ let ix = self.push_tool(text("call_id"), text("name"), Value::Null);
2454+ let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) else { return };
2455+ tool
2456+ }
2457+ };
2458+ take_partial_input(tool);
2459+ tool.state = if error.is_some() { ToolState::Error } else { ToolState::Done };
2460+ tool.result = Some(match error {
2461+ Some(error) if output.trim().is_empty() => error,
2462+ Some(error) => format!("{output}\n\n{error}"),
2463+ None => output,
2464+ });
2465+ }
2466+ "token_usage" => {
2467+ let count = |key: &str| message.get(key).and_then(Value::as_u64).unwrap_or(0);
2468+ // Anthropic counts cache reads apart from the input; OpenAI
2469+ // includes them.
2470+ let anthropic = self
2471+ .jcode
2472+ .provider
2473+ .as_deref()
2474+ .is_some_and(|provider| provider.to_lowercase().contains("anthropic") || provider.to_lowercase().contains("claude"));
2475+ let cache_read = if anthropic { count("cache_read_input") } else { 0 };
2476+ let tokens = count("input") + count("output") + count("cache_creation_input") + cache_read;
2477+ if tokens > 0 {
2478+ self.context_tokens = Some(tokens);
2479+ }
2480+ }
2481+ "connection_phase" if text("phase").starts_with("retrying") => {
2482+ self.push_notice(NoticeKind::Info, format!("jcode is {}.", text("phase")));
2483+ }
2484+ "compacted" => {
2485+ let note = text("message");
2486+ self.push_notice(
2487+ NoticeKind::Info,
2488+ if note.is_empty() { "Compacting the conversation to free up context." } else { note }.to_string(),
2489+ );
2490+ }
2491+ "turn_stopped" => match text("reason") {
2492+ "interrupted" => self.push_notice(NoticeKind::Info, "Interrupted."),
2493+ _ if self.interrupt_requested => {}
2494+ _ => {
2495+ let error = text("message");
2496+ self.push_notice(
2497+ NoticeKind::Error,
2498+ if error.is_empty() { "The turn stopped early." } else { error }.to_string(),
2499+ );
2500+ }
2501+ },
2502+ "turn_done" => {
2503+ self.thinking = false;
2504+ self.status = SessionStatus::Idle;
2505+ self.interrupt_requested = false;
2506+ self.jcode.texts.clear();
2507+ self.finish_open_tools("The turn ended before the tool finished.");
2508+ let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64);
2509+ self.finish_turn(duration_ms, None, cx);
2510+ self.send_next_queued(cx);
2511+ }
2512+ _ => {}
2513+ }
2514+ }
2515+
2516+ /// The chat item a streamed jcode message renders into, made on its first delta.
2517+ fn jcode_text_item(&mut self, message_id: &str, cx: &mut Context<Self>) -> usize {
2518+ if let Some(&ix) = self.jcode.texts.get(message_id) {
2519+ return ix;
2520+ }
2521+ let ix = self.push_assistant_text("", cx);
2522+ self.jcode.texts.insert(message_id.to_string(), ix);
2523+ ix
2524+ }
2525+
2526+ /// A jcode tool call by id; older daemons leave the id out of input
2527+ /// deltas, which then belong to the latest call.
2528+ fn jcode_tool(&mut self, call_id: &str) -> Option<&mut ToolCall> {
2529+ if !call_id.is_empty() {
2530+ return self.find_tool_mut(call_id);
2531+ }
2532+ self.items.iter_mut().rev().find_map(|item| match item {
2533+ ChatItem::Tool(tool) if tool.state == ToolState::Running => Some(tool),
2534+ _ => None,
2535+ })
2536+ }
2537+
22482538 // ---- helpers -----------------------------------------------------
22492539
22502540 fn push_assistant_text(&mut self, text: &str, cx: &mut Context<Self>) -> usize {
@@ -3145,6 +3435,16 @@ fn title_from(text: &str) -> String {
31453435 title
31463436 }
31473437
3438+ /// Parses a tool call's streamed arguments into its input, once they're complete.
3439+ fn take_partial_input(tool: &mut ToolCall) {
3440+ if tool.input.is_null()
3441+ && let Ok(input) = serde_json::from_str::<Value>(&tool.partial_input)
3442+ {
3443+ tool.input = input;
3444+ tool.partial_input.clear();
3445+ }
3446+ }
3447+
31483448 fn tool_result_text(content: &Value) -> String {
31493449 match content {
31503450 Value::String(text) => text.clone(),
+4−0src/textgen.rsView File
@@ -30,6 +30,9 @@ pub fn title(message: &str) -> Option<String> {
3030 let message: String = message.chars().take(2_000).collect();
3131 let prompt = format!("{TITLE_PROMPT}\n\n<message>\n{message}\n</message>");
3232
33+ if !kind.answers_one_off() {
34+ return None;
35+ }
3336 let (_, mut command) = base_command(&instance).ok()?;
3437 match kind {
3538 Provider::Claude => {
@@ -48,6 +51,7 @@ pub fn title(message: &str) -> Option<String> {
4851 command.args(["--thinking", effort.as_cli()]);
4952 }
5053 }
54+ Provider::Jcode => return None,
5155 }
5256 let output = command
5357 .arg(prompt)
+3−2src/ui/chat.rsView File
@@ -24,7 +24,7 @@ use gpui_kit::*;
2424 use crate::Interrupt;
2525 use crate::clock;
2626 use crate::git;
27- use crate::models::{Catalog, ModelEntry, Provider, SlashCommand};
27+ use crate::models::{Catalog, ModelEntry, SlashCommand};
2828 use crate::session::{
2929 ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session,
3030 SessionEvent, TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary,
@@ -1093,7 +1093,8 @@ impl ChatView {
10931093 .icon(Lucide::Brain)
10941094 .label(if fast_mode { format!("{} · Fast", reasoning.label()) } else { reasoning.label().to_string() })
10951095 .caret();
1096- let access_mode = if provider == Provider::Pi { crate::claude::PermissionMode::BypassPermissions } else { mode };
1096+ let access_mode =
1097+ if provider.asks_permission() { mode } else { crate::claude::PermissionMode::BypassPermissions };
10971098 let access_button = MetalButton::new("access")
10981099 .ghost()
10991100 .compact()
+1−0src/ui/icons.rsView File
@@ -17,6 +17,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [
1717 ChevronsDownUp,
1818 ChevronsUpDown,
1919 Code,
20+ Feather,
2021 CornerDownRight,
2122 FileCode,
2223 FileDiff,
+16−5src/ui/pickers.rsView File
@@ -24,6 +24,7 @@ pub fn provider_icon(provider: Provider, size: Pixels, p: &Palette) -> Icon {
2424 match provider {
2525 Provider::Claude => Icon::new(IconName::Asterisk).size(size).text_color(rgba(CLAUDE_ORANGE)),
2626 Provider::Pi => Icon::new(Lucide::Pi).size(size).text_color(p.ink),
27+ Provider::Jcode => Icon::new(Lucide::Feather).size(size).text_color(p.ink),
2728 }
2829 }
2930
@@ -115,6 +116,8 @@ pub fn reasoning_menu(
115116 let fast_mode = state.fast_mode;
116117 let levels: Vec<Effort> = match provider {
117118 Provider::Pi if !state.pi_efforts.is_empty() => state.pi_efforts.clone(),
119+ // Most of jcode's providers take no effort setting at all.
120+ Provider::Jcode => model.map(|model| model.efforts.clone()).unwrap_or_default(),
118121 _ => model
119122 .map(|model| model.efforts.clone())
120123 .filter(|levels| !levels.is_empty())
@@ -147,7 +150,7 @@ pub fn reasoning_menu(
147150 if provider == Provider::Pi { "Thinking" } else { "Reasoning" },
148151 p,
149152 ));
150- if provider == Provider::Claude {
153+ if provider != Provider::Pi {
151154 menu = menu.child(row("reasoning-default", Reasoning::Default, Some("The model's own default.")));
152155 }
153156 for (ix, level) in levels.into_iter().enumerate() {
@@ -213,12 +216,13 @@ pub fn access_menu(
213216 cx: &App,
214217 ) -> Div {
215218 let state = session.read(cx);
216- if state.provider == Provider::Pi {
219+ if !state.provider.asks_permission() {
220+ let agent = state.provider.agent_name();
217221 return panel(p).w(px(320.)).child(menu_row(
218- "access-pi",
222+ "access-full",
219223 Some(Icon::new(Lucide::LockOpen)),
220224 "Full access",
221- Some("Pi runs its tools without asking; it has no approval prompts.".into()),
225+ Some(format!("{agent} runs its tools without asking; it has no approval prompts.").into()),
222226 None,
223227 true,
224228 false,
@@ -417,7 +421,7 @@ impl ModelPicker {
417421 .child(provider_icon(model.provider, px(12.), p))
418422 .child(div().truncate().child(match model.provider {
419423 Provider::Claude => instance,
420- Provider::Pi => format!("{} · {instance}", model.detail),
424+ Provider::Pi | Provider::Jcode => format!("{} · {instance}", model.detail),
421425 })),
422426 ),
423427 )
@@ -455,11 +459,18 @@ impl ModelPicker {
455459 .into()
456460 }
457461 (ProbeStatus::Missing, Provider::Claude) => "Claude Code isn't installed.".into(),
462+ (ProbeStatus::Missing, Provider::Jcode) => {
463+ "jcode isn't installed. Install it with `curl -fsSL https://jcode.sh/install | bash` and run `jcode login`."
464+ .into()
465+ }
458466 (ProbeStatus::Failed(error), _) => format!("{label} didn't list its models: {error}").into(),
459467 (ProbeStatus::Ready, Provider::Pi) => {
460468 "Pi has no models configured. Log in to a provider with `pi`.".into()
461469 }
462470 (ProbeStatus::Ready, Provider::Claude) => "Claude Code listed no models.".into(),
471+ (ProbeStatus::Ready, Provider::Jcode) => {
472+ "jcode has no usable models. Log in to a provider with `jcode login`.".into()
473+ }
463474 }
464475 }
465476 },
+18−11src/ui/settings.rsView File
@@ -431,7 +431,7 @@ impl SettingsView {
431431 "Default model for new threads. Following the open thread keeps whatever you picked last.",
432432 h_flex()
433433 .gap(px(6.))
434- .child(model_control("default-model", saved.default_model.clone(), "Follow open thread", model_pick, cx))
434+ .child(model_control("default-model", saved.default_model.clone(), "Follow open thread", false, model_pick, cx))
435435 .child(reasoning_control(
436436 "default-reasoning",
437437 saved.default_reasoning.as_deref().and_then(Reasoning::from_key),
@@ -442,7 +442,7 @@ impl SettingsView {
442442 ),
443443 row(
444444 "Permissions",
445- "Default permissions for new threads. Pi threads always run with full access.",
445+ "Default permissions for new threads. Pi and jcode threads always run with full access.",
446446 self.access_control(&saved, cx),
447447 p,
448448 ),
@@ -485,7 +485,7 @@ impl SettingsView {
485485 "Writes thread titles from the first message. Off keeps the message's first line.",
486486 h_flex()
487487 .gap(px(6.))
488- .child(model_control("text-model", text_model.clone(), "Off", text_pick, cx))
488+ .child(model_control("text-model", text_model.clone(), "Off", true, text_pick, cx))
489489 .when(text_model.is_some(), |this| {
490490 this.child(reasoning_control(
491491 "text-reasoning",
@@ -955,6 +955,7 @@ impl SettingsView {
955955 let default_home = match instance.kind {
956956 Provider::Claude => "~/.claude",
957957 Provider::Pi => "~/.pi/agent",
958+ Provider::Jcode => "~/.jcode",
958959 };
959960 let name = input(&instance.name, instance.kind.label(), window, cx);
960961 let binary = input(&instance.binary, "Look it up on PATH", window, cx);
@@ -1293,6 +1294,7 @@ impl SettingsView {
12931294 match kind {
12941295 Provider::Claude => "Config folder",
12951296 Provider::Pi => "Agent folder",
1297+ Provider::Jcode => "Home folder",
12961298 },
12971299 format!(
12981300 "Sets {} so this instance keeps its own login, settings and sessions.",
@@ -1309,6 +1311,7 @@ impl SettingsView {
13091311 match kind {
13101312 Provider::Claude => "claude",
13111313 Provider::Pi => "pi --mode rpc",
1314+ Provider::Jcode => "jcode (ahead of `api --stdio`)",
13121315 }
13131316 ),
13141317 &form.args,
@@ -1445,6 +1448,8 @@ impl SettingsView {
14451448 match state.kind() {
14461449 Provider::Claude => sources.claude_dirs.push((label.clone(), home)),
14471450 Provider::Pi => sources.pi_dirs.push((label.clone(), home)),
1451+ // jcode keeps no usage logs the Usage page reads.
1452+ Provider::Jcode => {}
14481453 }
14491454 if let Some(usage) = &state.usage {
14501455 limits.push((SharedString::from(label), usage.clone()));
@@ -2130,11 +2135,13 @@ fn stepper(
21302135 }
21312136
21322137 /// A model dropdown across every enabled provider, with a first entry for
2133- /// "no model" (`none_label`: follow the open thread, or off).
2138+ /// "no model" (`none_label`: follow the open thread, or off). `one_off`
2139+ /// leaves out the agents that can't answer a one-off prompt.
21342140 fn model_control(
21352141 id: &'static str,
21362142 current: Option<String>,
21372143 none_label: &'static str,
2144+ one_off: bool,
21382145 pick: impl Fn(Option<String>, &mut Window, &mut App) + 'static,
21392146 cx: &mut Context<SettingsView>,
21402147 ) -> AnyElement {
@@ -2154,6 +2161,7 @@ fn model_control(
21542161 // One menu section per enabled instance: its label, then its models.
21552162 let groups: Vec<(String, Vec<(String, String)>, String)> = catalog
21562163 .enabled()
2164+ .filter(|state| !one_off || state.kind().answers_one_off())
21572165 .map(|state| {
21582166 let models = state
21592167 .models
@@ -2162,7 +2170,7 @@ fn model_control(
21622170 let name = match model.provider {
21632171 Provider::Claude if model.legacy => format!("{} (legacy)", model.name),
21642172 Provider::Claude => model.name.clone(),
2165- Provider::Pi => format!("{} · {}", model.name, model.detail),
2173+ Provider::Pi | Provider::Jcode => format!("{} · {}", model.name, model.detail),
21662174 };
21672175 (model.key(), name)
21682176 })
@@ -2262,10 +2270,7 @@ fn add_provider_menu(
22622270 for kind in Provider::ALL {
22632271 let view = view.clone();
22642272 menu = menu.item(
2265- PopupMenuItem::new(match kind {
2266- Provider::Claude => "Claude Code",
2267- Provider::Pi => "Pi",
2268- })
2273+ PopupMenuItem::new(kind.agent_name())
22692274 .on_click(move |_, window, cx| {
22702275 let _ = view.update(cx, |this, cx| this.add_provider(kind, window, cx));
22712276 }),
@@ -2287,8 +2292,10 @@ fn status_line(state: &InstanceState) -> String {
22872292 match state.kind() {
22882293 Provider::Claude if state.account.is_some() => parts.push("Authenticated".to_string()),
22892294 Provider::Claude => parts.push("Not signed in".to_string()),
2290- Provider::Pi if state.models.is_empty() => parts.push("No models configured".to_string()),
2291- Provider::Pi => parts.push(format!("{} models", state.models.len())),
2295+ Provider::Pi | Provider::Jcode if state.models.is_empty() => {
2296+ parts.push("No models configured".to_string())
2297+ }
2298+ Provider::Pi | Provider::Jcode => parts.push(format!("{} models", state.models.len())),
22922299 }
22932300 parts.extend(state.plan.clone());
22942301 if state.update_available() {
+1−2src/ui/workspace/remote.rsView File
@@ -16,7 +16,6 @@ use gpui_kit::*;
1616 use super::{Project, ThreadSettings, Workspace, hostname, initials, tint_for};
1717 use crate::claude::PermissionMode;
1818 use crate::git::GitInfo;
19- use crate::models::Provider;
2019 use crate::remote::{
2120 ClientMessage, ProjectInfo, ServerMessage, ThreadAction, ThreadSummary,
2221 client::{ClientEvent, ConnectionStatus, RemoteClient},
@@ -610,7 +609,7 @@ impl Workspace {
610609 });
611610 let new = NewSession {
612611 instance: settings.instance,
613- provider: if settings.provider == Provider::Pi { Provider::Pi } else { Provider::Claude },
612+ provider: settings.provider,
614613 project,
615614 model: settings.model,
616615 permission_mode: settings.mode,