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>
Showing 11 changed files with 638 additions and 64 deletions.
| @@ -2,13 +2,15 @@ | ||
| 2 | 2 | |
| 3 | 3 | A native [T3 Code](https://github.com/pingdotgg/t3code)-style desktop client for coding |
| 4 | 4 | agents, built with [GPUI](https://gpui.rs) and [GPUI Kit](https://github.com/longbridge/gpui-kit). |
| 5 | - 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). | |
| 7 | 7 | |
| 8 | 8 | Every thread runs its own agent process in the background. Claude Code threads talk to the |
| 9 | 9 | `claude` CLI over the stream-json protocol the Claude Agent SDKs use |
| 10 | 10 | (`--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. | |
| 12 | 14 | |
| 13 | 15 | The layout and workflow follow T3 Code. The look follows [MetalUI](https://metalui.dev/), the |
| 14 | 16 | "soft hardware" design system [vectrum.cc](https://vectrum.cc/) is built on: the |
| @@ -30,14 +32,16 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 30 | 32 | - A new thread opens on a start screen, "What should we build in *project*?", with the |
| 31 | 33 | project as a switcher, a way to start without a project, and the branch it starts from. |
| 32 | 34 | - 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. | |
| 36 | 39 | - 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. | |
| 38 | 42 | 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. | |
| 41 | 45 | - Finished turns fold their tool calls and interim messages into one **Worked for 12s** row |
| 42 | 46 | above the answer. Click it to unfold. |
| 43 | 47 | - 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. | ||
| 106 | 110 | Catppuccin (Mocha, Macchiato, Frappé, Latte); GitHub (Dark, Dark Dimmed, Light); One Dark |
| 107 | 111 | and One Light; Nord; Gruvbox Dark and Light; Rosé Pine (Main, Moon, Dawn); Solarized Dark |
| 108 | 112 | 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 | |
| 111 | 115 | folder, launch arguments and environment variables, plus updates and the health check |
| 112 | 116 | interval, and usage hubs (CLIProxyAPI servers, with their management key). |
| 113 | 117 | - **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. | ||
| 120 | 124 | - The `claude` CLI, logged in (`claude` once interactively) |
| 121 | 125 | - Optional: [Pi](https://github.com/earendil-works/pi) for Pi threads |
| 122 | 126 | (`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`) | |
| 123 | 129 | - Optional: agent-browser for the browser panel |
| 124 | 130 | (`npm install -g agent-browser && agent-browser install`) |
| 125 | 131 | - 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 | ||
| 150 | 156 | then `~/.local/bin` and `~/.claude/local`. |
| 151 | 157 | - `VECTRUM_CODE_PI_BIN`: path to the `pi` binary. Otherwise it's looked up on `PATH`, then in |
| 152 | 158 | `~/.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`. | |
| 153 | 161 | |
| 154 | 162 | Favorite models and everything in the settings view are saved in |
| 155 | 163 | `~/.config/vectrum-code/settings.json`. The environment variables above win over the binary |
| @@ -159,11 +167,11 @@ in its `scratch` folder. | ||
| 159 | 167 | |
| 160 | 168 | ## Layout |
| 161 | 169 | |
| 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. | |
| 167 | 175 | - `src/models.rs`: the model catalog across providers, and favorites. |
| 168 | 176 | - `src/git.rs`: branch and change stats, working-tree snapshots, and diffs. |
| 169 | 177 | - `src/settings.rs`: the saved settings file, cached in memory. |
| @@ -188,4 +196,6 @@ in its `scratch` folder. | ||
| 188 | 196 | There is no UI for Claude's |
| 189 | 197 | `AskUserQuestion` (the tool is disabled, so Claude asks in plain text) or for Pi extensions |
| 190 | 198 | 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. | |
| @@ -134,6 +134,21 @@ pub fn agent_prompt(session: &str) -> String { | ||
| 134 | 134 | ) |
| 135 | 135 | } |
| 136 | 136 | |
| 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 | + | |
| 137 | 152 | /// Sets a thread's browser session in an agent's environment, with |
| 138 | 153 | /// `agent-browser`'s folder on PATH, and returns the prompt that tells the |
| 139 | 154 | /// agent about it. `None` when agent-browser isn't installed. |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | //! 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. | |
| 4 | 5 | |
| 5 | 6 | use std::{ |
| 6 | 7 | io::{BufRead, BufReader, Write}, |
| @@ -140,13 +141,14 @@ pub struct LaunchConfig { | ||
| 140 | 141 | /// Which agent, with its binary, home folder, arguments and environment. |
| 141 | 142 | pub instance: ProviderInstance, |
| 142 | 143 | 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. | |
| 144 | 146 | pub model: Option<String>, |
| 145 | 147 | pub permission_mode: PermissionMode, |
| 146 | 148 | /// `None` leaves effort at the model's default. |
| 147 | 149 | 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. | |
| 150 | 152 | pub resume: Option<String>, |
| 151 | 153 | /// The thread's agent-browser session, shared with the browser panel. |
| 152 | 154 | pub browser_session: Option<String>, |
| @@ -170,9 +172,13 @@ impl AgentProcess { | ||
| 170 | 172 | let (binary, mut command) = match config.instance.kind { |
| 171 | 173 | Provider::Claude => claude_command(config)?, |
| 172 | 174 | 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)?, | |
| 173 | 178 | }; |
| 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 | |
| 176 | 182 | && let Some(prompt) = browser::prepare_agent(&mut command, session) |
| 177 | 183 | { |
| 178 | 184 | command.args(["--append-system-prompt", &prompt]); |
| @@ -342,11 +348,22 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 342 | 348 | Ok((binary, command)) |
| 343 | 349 | } |
| 344 | 350 | |
| 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 | + | |
| 345 | 361 | /// The environment variable that moves an agent's home folder. |
| 346 | 362 | pub fn home_var(kind: Provider) -> &'static str { |
| 347 | 363 | match kind { |
| 348 | 364 | Provider::Claude => "CLAUDE_CONFIG_DIR", |
| 349 | 365 | Provider::Pi => "PI_CODING_AGENT_DIR", |
| 366 | + Provider::Jcode => "JCODE_HOME", | |
| 350 | 367 | } |
| 351 | 368 | } |
| 352 | 369 | |
| @@ -355,6 +372,7 @@ pub fn binary_var(kind: Provider) -> &'static str { | ||
| 355 | 372 | match kind { |
| 356 | 373 | Provider::Claude => "VECTRUM_CODE_CLAUDE_BIN", |
| 357 | 374 | Provider::Pi => "VECTRUM_CODE_PI_BIN", |
| 375 | + Provider::Jcode => "VECTRUM_CODE_JCODE_BIN", | |
| 358 | 376 | } |
| 359 | 377 | } |
| 360 | 378 | |
| @@ -372,7 +390,18 @@ pub fn base_command(instance: &ProviderInstance) -> Result<(PathBuf, Command)> { | ||
| 372 | 390 | command.env_remove(key); |
| 373 | 391 | } |
| 374 | 392 | 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); | |
| 376 | 405 | } |
| 377 | 406 | for var in &instance.env { |
| 378 | 407 | if !var.key.trim().is_empty() { |
| @@ -419,6 +448,7 @@ pub fn find_binary(instance: &ProviderInstance) -> Option<PathBuf> { | ||
| 419 | 448 | home.join(".bun/bin/pi"), |
| 420 | 449 | home.join(".volta/bin/pi"), |
| 421 | 450 | ], |
| 451 | + Provider::Jcode => vec![home.join(".local/bin/jcode"), home.join(".cargo/bin/jcode")], | |
| 422 | 452 | }; |
| 423 | 453 | fallbacks.extend([PathBuf::from("/usr/local/bin").join(name), PathBuf::from("/usr/bin").join(name)]); |
| 424 | 454 | // nvm keeps one bin directory per Node version. |
| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | //! pickers and settings read. Each configured provider instance gets a |
| 3 | 3 | //! background health check: Claude's models, account, slash commands and |
| 4 | 4 | //! 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. | |
| 7 | 8 | |
| 8 | 9 | use std::{ |
| 9 | 10 | io::{BufRead, BufReader, Write}, |
| @@ -27,15 +28,26 @@ pub enum Provider { | ||
| 27 | 28 | #[default] |
| 28 | 29 | Claude, |
| 29 | 30 | Pi, |
| 31 | + Jcode, | |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | 34 | impl Provider { |
| 33 | - pub const ALL: [Provider; 2] = [Provider::Claude, Provider::Pi]; | |
| 35 | + pub const ALL: [Provider; 3] = [Provider::Claude, Provider::Pi, Provider::Jcode]; | |
| 34 | 36 | |
| 35 | 37 | pub fn label(self) -> &'static str { |
| 36 | 38 | match self { |
| 37 | 39 | Provider::Claude => "Claude", |
| 38 | 40 | 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", | |
| 39 | 51 | } |
| 40 | 52 | } |
| 41 | 53 | |
| @@ -43,9 +55,21 @@ impl Provider { | ||
| 43 | 55 | match self { |
| 44 | 56 | Provider::Claude => "claude", |
| 45 | 57 | Provider::Pi => "pi", |
| 58 | + Provider::Jcode => "jcode", | |
| 46 | 59 | } |
| 47 | 60 | } |
| 48 | 61 | |
| 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 | + | |
| 49 | 73 | pub fn from_key(key: &str) -> Option<Self> { |
| 50 | 74 | Self::ALL.into_iter().find(|provider| provider.key() == key) |
| 51 | 75 | } |
| @@ -56,8 +80,8 @@ pub struct ModelEntry { | ||
| 56 | 80 | pub provider: Provider, |
| 57 | 81 | /// The provider instance offering it (`claude`, `claude-2`, `pi`). |
| 58 | 82 | 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`. | |
| 61 | 85 | pub id: String, |
| 62 | 86 | /// The concrete model, e.g. `claude-opus-5-5` behind the `opus` alias. |
| 63 | 87 | pub resolved: String, |
| @@ -263,6 +287,7 @@ impl Catalog { | ||
| 263 | 287 | let probe = match config.kind { |
| 264 | 288 | Provider::Claude => probe_claude(&config), |
| 265 | 289 | Provider::Pi => probe_pi(&config), |
| 290 | + Provider::Jcode => probe_jcode(&config), | |
| 266 | 291 | }; |
| 267 | 292 | let latest = if update_checks { latest_version(config.kind) } else { None }; |
| 268 | 293 | (probe, latest) |
| @@ -282,7 +307,7 @@ impl Catalog { | ||
| 282 | 307 | match result { |
| 283 | 308 | Ok(probe) => { |
| 284 | 309 | 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 { | |
| 286 | 311 | state.models = probe.models; |
| 287 | 312 | state.default = probe.default; |
| 288 | 313 | } |
| @@ -300,7 +325,7 @@ impl Catalog { | ||
| 300 | 325 | if state.config.enabled { |
| 301 | 326 | state.status = status; |
| 302 | 327 | } |
| 303 | - if state.config.kind == Provider::Pi { | |
| 328 | + if state.config.kind != Provider::Claude { | |
| 304 | 329 | state.models.clear(); |
| 305 | 330 | } |
| 306 | 331 | } |
| @@ -623,11 +648,157 @@ pub fn pi_model_from(instance: &str, model: &Value) -> Option<ModelEntry> { | ||
| 623 | 648 | }) |
| 624 | 649 | } |
| 625 | 650 | |
| 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). | |
| 627 | 792 | fn latest_version(kind: Provider) -> Option<String> { |
| 628 | 793 | let package = match kind { |
| 629 | 794 | Provider::Claude => "@anthropic-ai/claude-code", |
| 630 | 795 | 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 | + } | |
| 631 | 802 | }; |
| 632 | 803 | let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?; |
| 633 | 804 | 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 | ||
| 648 | 819 | command.args(["install", "-g", "@earendil-works/pi-coding-agent@latest"]); |
| 649 | 820 | Some((command, "npm install -g @earendil-works/pi-coding-agent")) |
| 650 | 821 | } |
| 822 | + Provider::Jcode => { | |
| 823 | + command.args(["update"]); | |
| 824 | + Some((command, "jcode update")) | |
| 825 | + } | |
| 651 | 826 | } |
| 652 | 827 | } |
| 653 | 828 | |
| @@ -687,7 +862,7 @@ fn provider_label(provider: &str) -> String { | ||
| 687 | 862 | |
| 688 | 863 | #[cfg(test)] |
| 689 | 864 | 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}; | |
| 691 | 866 | use serde_json::json; |
| 692 | 867 | |
| 693 | 868 | #[::core::prelude::v1::test] |
| @@ -735,4 +910,25 @@ mod tests { | ||
| 735 | 910 | assert_eq!(first_version("pi v1.1.0\n").as_deref(), Some("1.1.0")); |
| 736 | 911 | assert_eq!(first_version("2.1.296 (Claude Code)").as_deref(), Some("2.1.296")); |
| 737 | 912 | } |
| 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 | + } | |
| 738 | 934 | } |
| @@ -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. | |
| 3 | 3 | |
| 4 | 4 | use std::{ |
| 5 | 5 | collections::{HashMap, HashSet, VecDeque}, |
| @@ -29,6 +29,8 @@ const STDERR_TAIL: usize = 20; | ||
| 29 | 29 | pub const ASK_USER_QUESTION: &str = "AskUserQuestion"; |
| 30 | 30 | /// The API's limit for one base64 image. |
| 31 | 31 | 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")); | |
| 32 | 34 | |
| 33 | 35 | static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); |
| 34 | 36 | |
| @@ -380,6 +382,25 @@ struct StreamingMessage { | ||
| 380 | 382 | blocks: HashMap<u64, usize>, |
| 381 | 383 | } |
| 382 | 384 | |
| 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 | + | |
| 383 | 404 | pub struct Session { |
| 384 | 405 | /// Names the thread's file on disk. |
| 385 | 406 | pub id: String, |
| @@ -402,7 +423,8 @@ pub struct Session { | ||
| 402 | 423 | pub items: Vec<ChatItem>, |
| 403 | 424 | pub status: SessionStatus, |
| 404 | 425 | 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. | |
| 406 | 428 | resume_id: Option<String>, |
| 407 | 429 | /// The model picked for the thread; `None` means the agent's default. |
| 408 | 430 | pub model: Option<String>, |
| @@ -465,6 +487,7 @@ pub struct Session { | ||
| 465 | 487 | streamed_message_ids: HashSet<String>, |
| 466 | 488 | pending_controls: HashMap<String, PendingControl>, |
| 467 | 489 | next_request_id: u64, |
| 490 | + jcode: JcodeLink, | |
| 468 | 491 | _events_task: Task<()>, |
| 469 | 492 | } |
| 470 | 493 | |
| @@ -562,6 +585,7 @@ impl Session { | ||
| 562 | 585 | streamed_message_ids: HashSet::new(), |
| 563 | 586 | pending_controls: HashMap::new(), |
| 564 | 587 | next_request_id: 0, |
| 588 | + jcode: JcodeLink::default(), | |
| 565 | 589 | _events_task: Task::ready(()), |
| 566 | 590 | } |
| 567 | 591 | } |
| @@ -592,7 +616,7 @@ impl Session { | ||
| 592 | 616 | model: self.model.clone(), |
| 593 | 617 | permission_mode: self.permission_mode, |
| 594 | 618 | effort: self.reasoning.effort(), |
| 595 | - resume, | |
| 619 | + resume: resume.clone(), | |
| 596 | 620 | browser_session: Some(browser::session_name(&self.id)), |
| 597 | 621 | }; |
| 598 | 622 | let process = match AgentProcess::spawn(&config, tx) { |
| @@ -630,6 +654,28 @@ impl Session { | ||
| 630 | 654 | self.pi(json!({"type": "get_state"})); |
| 631 | 655 | self.pi(json!({"type": "get_available_thinking_levels"})); |
| 632 | 656 | } |
| 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 | + } | |
| 633 | 679 | } |
| 634 | 680 | |
| 635 | 681 | self._events_task = cx.spawn(async move |this, cx| { |
| @@ -676,6 +722,35 @@ impl Session { | ||
| 676 | 722 | } |
| 677 | 723 | } |
| 678 | 724 | |
| 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 | + | |
| 679 | 754 | // ---- user actions ------------------------------------------------- |
| 680 | 755 | |
| 681 | 756 | /// Sends a message now, even mid-turn, where the agent takes it in as |
| @@ -877,6 +952,21 @@ impl Session { | ||
| 877 | 952 | } |
| 878 | 953 | self.pi(command); |
| 879 | 954 | } |
| 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 | + } | |
| 880 | 970 | } |
| 881 | 971 | self.status = SessionStatus::Working; |
| 882 | 972 | } |
| @@ -1068,6 +1158,7 @@ impl Session { | ||
| 1068 | 1158 | match self.provider { |
| 1069 | 1159 | Provider::Claude => self.control(PendingControl::Interrupt, json!({"subtype": "interrupt"})), |
| 1070 | 1160 | Provider::Pi => self.pi(json!({"type": "abort"})), |
| 1161 | + Provider::Jcode => self.jcode_request("stop the turn", json!({"req": "cancel"})), | |
| 1071 | 1162 | } |
| 1072 | 1163 | cx.notify(); |
| 1073 | 1164 | } |
| @@ -1090,6 +1181,13 @@ impl Session { | ||
| 1090 | 1181 | self.pi(json!({"type": "get_available_thinking_levels"})); |
| 1091 | 1182 | } |
| 1092 | 1183 | } |
| 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 => {} | |
| 1093 | 1191 | } |
| 1094 | 1192 | cx.notify(); |
| 1095 | 1193 | } |
| @@ -1100,8 +1198,8 @@ impl Session { | ||
| 1100 | 1198 | cx.notify(); |
| 1101 | 1199 | return; |
| 1102 | 1200 | } |
| 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() { | |
| 1105 | 1203 | return; |
| 1106 | 1204 | } |
| 1107 | 1205 | self.permission_mode = mode; |
| @@ -1135,6 +1233,15 @@ impl Session { | ||
| 1135 | 1233 | self.pi(json!({"type": "set_thinking_level", "level": effort.as_cli()})); |
| 1136 | 1234 | } |
| 1137 | 1235 | } |
| 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 => {} | |
| 1138 | 1245 | } |
| 1139 | 1246 | cx.notify(); |
| 1140 | 1247 | } |
| @@ -1453,6 +1560,7 @@ impl Session { | ||
| 1453 | 1560 | ProcessEvent::Message(message) => match self.provider { |
| 1454 | 1561 | Provider::Claude => self.handle_message(message, cx), |
| 1455 | 1562 | Provider::Pi => self.handle_pi_message(message, cx), |
| 1563 | + Provider::Jcode => self.handle_jcode_message(message, cx), | |
| 1456 | 1564 | }, |
| 1457 | 1565 | ProcessEvent::Stderr(line) => { |
| 1458 | 1566 | if self.stderr_tail.len() == STDERR_TAIL { |
| @@ -1475,10 +1583,7 @@ impl Session { | ||
| 1475 | 1583 | self.base_snapshot = None; |
| 1476 | 1584 | self.finish_open_tools("The agent exited before the tool finished."); |
| 1477 | 1585 | |
| 1478 | - let agent = match self.provider { | |
| 1479 | - Provider::Claude => "Claude Code", | |
| 1480 | - Provider::Pi => "Pi", | |
| 1481 | - }; | |
| 1586 | + let agent = self.provider.agent_name(); | |
| 1482 | 1587 | let mut text = match code { |
| 1483 | 1588 | Some(code) => format!("{agent} exited with status {code}."), |
| 1484 | 1589 | None => format!("{agent} was terminated."), |
| @@ -2245,6 +2350,191 @@ impl Session { | ||
| 2245 | 2350 | self.updated = SystemTime::now(); |
| 2246 | 2351 | } |
| 2247 | 2352 | |
| 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 | + | |
| 2248 | 2538 | // ---- helpers ----------------------------------------------------- |
| 2249 | 2539 | |
| 2250 | 2540 | fn push_assistant_text(&mut self, text: &str, cx: &mut Context<Self>) -> usize { |
| @@ -3145,6 +3435,16 @@ fn title_from(text: &str) -> String { | ||
| 3145 | 3435 | title |
| 3146 | 3436 | } |
| 3147 | 3437 | |
| 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 | + | |
| 3148 | 3448 | fn tool_result_text(content: &Value) -> String { |
| 3149 | 3449 | match content { |
| 3150 | 3450 | Value::String(text) => text.clone(), |
| @@ -30,6 +30,9 @@ pub fn title(message: &str) -> Option<String> { | ||
| 30 | 30 | let message: String = message.chars().take(2_000).collect(); |
| 31 | 31 | let prompt = format!("{TITLE_PROMPT}\n\n<message>\n{message}\n</message>"); |
| 32 | 32 | |
| 33 | + if !kind.answers_one_off() { | |
| 34 | + return None; | |
| 35 | + } | |
| 33 | 36 | let (_, mut command) = base_command(&instance).ok()?; |
| 34 | 37 | match kind { |
| 35 | 38 | Provider::Claude => { |
| @@ -48,6 +51,7 @@ pub fn title(message: &str) -> Option<String> { | ||
| 48 | 51 | command.args(["--thinking", effort.as_cli()]); |
| 49 | 52 | } |
| 50 | 53 | } |
| 54 | + Provider::Jcode => return None, | |
| 51 | 55 | } |
| 52 | 56 | let output = command |
| 53 | 57 | .arg(prompt) |
| @@ -24,7 +24,7 @@ use gpui_kit::*; | ||
| 24 | 24 | use crate::Interrupt; |
| 25 | 25 | use crate::clock; |
| 26 | 26 | use crate::git; |
| 27 | - use crate::models::{Catalog, ModelEntry, Provider, SlashCommand}; | |
| 27 | + use crate::models::{Catalog, ModelEntry, SlashCommand}; | |
| 28 | 28 | use crate::session::{ |
| 29 | 29 | ChatItem, FinishedTurn, NoticeKind, PermissionDecision, PermissionState, Question, Session, |
| 30 | 30 | SessionEvent, TaskStatus, ToolCall, ToolState, UserQuestions, tool_field, tool_summary, |
| @@ -1093,7 +1093,8 @@ impl ChatView { | ||
| 1093 | 1093 | .icon(Lucide::Brain) |
| 1094 | 1094 | .label(if fast_mode { format!("{} · Fast", reasoning.label()) } else { reasoning.label().to_string() }) |
| 1095 | 1095 | .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 }; | |
| 1097 | 1098 | let access_button = MetalButton::new("access") |
| 1098 | 1099 | .ghost() |
| 1099 | 1100 | .compact() |
| @@ -17,6 +17,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 17 | 17 | ChevronsDownUp, |
| 18 | 18 | ChevronsUpDown, |
| 19 | 19 | Code, |
| 20 | + Feather, | |
| 20 | 21 | CornerDownRight, |
| 21 | 22 | FileCode, |
| 22 | 23 | FileDiff, |
| @@ -24,6 +24,7 @@ pub fn provider_icon(provider: Provider, size: Pixels, p: &Palette) -> Icon { | ||
| 24 | 24 | match provider { |
| 25 | 25 | Provider::Claude => Icon::new(IconName::Asterisk).size(size).text_color(rgba(CLAUDE_ORANGE)), |
| 26 | 26 | 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), | |
| 27 | 28 | } |
| 28 | 29 | } |
| 29 | 30 | |
| @@ -115,6 +116,8 @@ pub fn reasoning_menu( | ||
| 115 | 116 | let fast_mode = state.fast_mode; |
| 116 | 117 | let levels: Vec<Effort> = match provider { |
| 117 | 118 | 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(), | |
| 118 | 121 | _ => model |
| 119 | 122 | .map(|model| model.efforts.clone()) |
| 120 | 123 | .filter(|levels| !levels.is_empty()) |
| @@ -147,7 +150,7 @@ pub fn reasoning_menu( | ||
| 147 | 150 | if provider == Provider::Pi { "Thinking" } else { "Reasoning" }, |
| 148 | 151 | p, |
| 149 | 152 | )); |
| 150 | - if provider == Provider::Claude { | |
| 153 | + if provider != Provider::Pi { | |
| 151 | 154 | menu = menu.child(row("reasoning-default", Reasoning::Default, Some("The model's own default."))); |
| 152 | 155 | } |
| 153 | 156 | for (ix, level) in levels.into_iter().enumerate() { |
| @@ -213,12 +216,13 @@ pub fn access_menu( | ||
| 213 | 216 | cx: &App, |
| 214 | 217 | ) -> Div { |
| 215 | 218 | let state = session.read(cx); |
| 216 | - if state.provider == Provider::Pi { | |
| 219 | + if !state.provider.asks_permission() { | |
| 220 | + let agent = state.provider.agent_name(); | |
| 217 | 221 | return panel(p).w(px(320.)).child(menu_row( |
| 218 | - "access-pi", | |
| 222 | + "access-full", | |
| 219 | 223 | Some(Icon::new(Lucide::LockOpen)), |
| 220 | 224 | "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()), | |
| 222 | 226 | None, |
| 223 | 227 | true, |
| 224 | 228 | false, |
| @@ -417,7 +421,7 @@ impl ModelPicker { | ||
| 417 | 421 | .child(provider_icon(model.provider, px(12.), p)) |
| 418 | 422 | .child(div().truncate().child(match model.provider { |
| 419 | 423 | Provider::Claude => instance, |
| 420 | - Provider::Pi => format!("{} · {instance}", model.detail), | |
| 424 | + Provider::Pi | Provider::Jcode => format!("{} · {instance}", model.detail), | |
| 421 | 425 | })), |
| 422 | 426 | ), |
| 423 | 427 | ) |
| @@ -455,11 +459,18 @@ impl ModelPicker { | ||
| 455 | 459 | .into() |
| 456 | 460 | } |
| 457 | 461 | (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 | + } | |
| 458 | 466 | (ProbeStatus::Failed(error), _) => format!("{label} didn't list its models: {error}").into(), |
| 459 | 467 | (ProbeStatus::Ready, Provider::Pi) => { |
| 460 | 468 | "Pi has no models configured. Log in to a provider with `pi`.".into() |
| 461 | 469 | } |
| 462 | 470 | (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 | + } | |
| 463 | 474 | } |
| 464 | 475 | } |
| 465 | 476 | }, |
| @@ -431,7 +431,7 @@ impl SettingsView { | ||
| 431 | 431 | "Default model for new threads. Following the open thread keeps whatever you picked last.", |
| 432 | 432 | h_flex() |
| 433 | 433 | .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)) | |
| 435 | 435 | .child(reasoning_control( |
| 436 | 436 | "default-reasoning", |
| 437 | 437 | saved.default_reasoning.as_deref().and_then(Reasoning::from_key), |
| @@ -442,7 +442,7 @@ impl SettingsView { | ||
| 442 | 442 | ), |
| 443 | 443 | row( |
| 444 | 444 | "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.", | |
| 446 | 446 | self.access_control(&saved, cx), |
| 447 | 447 | p, |
| 448 | 448 | ), |
| @@ -485,7 +485,7 @@ impl SettingsView { | ||
| 485 | 485 | "Writes thread titles from the first message. Off keeps the message's first line.", |
| 486 | 486 | h_flex() |
| 487 | 487 | .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)) | |
| 489 | 489 | .when(text_model.is_some(), |this| { |
| 490 | 490 | this.child(reasoning_control( |
| 491 | 491 | "text-reasoning", |
| @@ -955,6 +955,7 @@ impl SettingsView { | ||
| 955 | 955 | let default_home = match instance.kind { |
| 956 | 956 | Provider::Claude => "~/.claude", |
| 957 | 957 | Provider::Pi => "~/.pi/agent", |
| 958 | + Provider::Jcode => "~/.jcode", | |
| 958 | 959 | }; |
| 959 | 960 | let name = input(&instance.name, instance.kind.label(), window, cx); |
| 960 | 961 | let binary = input(&instance.binary, "Look it up on PATH", window, cx); |
| @@ -1293,6 +1294,7 @@ impl SettingsView { | ||
| 1293 | 1294 | match kind { |
| 1294 | 1295 | Provider::Claude => "Config folder", |
| 1295 | 1296 | Provider::Pi => "Agent folder", |
| 1297 | + Provider::Jcode => "Home folder", | |
| 1296 | 1298 | }, |
| 1297 | 1299 | format!( |
| 1298 | 1300 | "Sets {} so this instance keeps its own login, settings and sessions.", |
| @@ -1309,6 +1311,7 @@ impl SettingsView { | ||
| 1309 | 1311 | match kind { |
| 1310 | 1312 | Provider::Claude => "claude", |
| 1311 | 1313 | Provider::Pi => "pi --mode rpc", |
| 1314 | + Provider::Jcode => "jcode (ahead of `api --stdio`)", | |
| 1312 | 1315 | } |
| 1313 | 1316 | ), |
| 1314 | 1317 | &form.args, |
| @@ -1445,6 +1448,8 @@ impl SettingsView { | ||
| 1445 | 1448 | match state.kind() { |
| 1446 | 1449 | Provider::Claude => sources.claude_dirs.push((label.clone(), home)), |
| 1447 | 1450 | Provider::Pi => sources.pi_dirs.push((label.clone(), home)), |
| 1451 | + // jcode keeps no usage logs the Usage page reads. | |
| 1452 | + Provider::Jcode => {} | |
| 1448 | 1453 | } |
| 1449 | 1454 | if let Some(usage) = &state.usage { |
| 1450 | 1455 | limits.push((SharedString::from(label), usage.clone())); |
| @@ -2130,11 +2135,13 @@ fn stepper( | ||
| 2130 | 2135 | } |
| 2131 | 2136 | |
| 2132 | 2137 | /// 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. | |
| 2134 | 2140 | fn model_control( |
| 2135 | 2141 | id: &'static str, |
| 2136 | 2142 | current: Option<String>, |
| 2137 | 2143 | none_label: &'static str, |
| 2144 | + one_off: bool, | |
| 2138 | 2145 | pick: impl Fn(Option<String>, &mut Window, &mut App) + 'static, |
| 2139 | 2146 | cx: &mut Context<SettingsView>, |
| 2140 | 2147 | ) -> AnyElement { |
| @@ -2154,6 +2161,7 @@ fn model_control( | ||
| 2154 | 2161 | // One menu section per enabled instance: its label, then its models. |
| 2155 | 2162 | let groups: Vec<(String, Vec<(String, String)>, String)> = catalog |
| 2156 | 2163 | .enabled() |
| 2164 | + .filter(|state| !one_off || state.kind().answers_one_off()) | |
| 2157 | 2165 | .map(|state| { |
| 2158 | 2166 | let models = state |
| 2159 | 2167 | .models |
| @@ -2162,7 +2170,7 @@ fn model_control( | ||
| 2162 | 2170 | let name = match model.provider { |
| 2163 | 2171 | Provider::Claude if model.legacy => format!("{} (legacy)", model.name), |
| 2164 | 2172 | Provider::Claude => model.name.clone(), |
| 2165 | - Provider::Pi => format!("{} · {}", model.name, model.detail), | |
| 2173 | + Provider::Pi | Provider::Jcode => format!("{} · {}", model.name, model.detail), | |
| 2166 | 2174 | }; |
| 2167 | 2175 | (model.key(), name) |
| 2168 | 2176 | }) |
| @@ -2262,10 +2270,7 @@ fn add_provider_menu( | ||
| 2262 | 2270 | for kind in Provider::ALL { |
| 2263 | 2271 | let view = view.clone(); |
| 2264 | 2272 | menu = menu.item( |
| 2265 | - PopupMenuItem::new(match kind { | |
| 2266 | - Provider::Claude => "Claude Code", | |
| 2267 | - Provider::Pi => "Pi", | |
| 2268 | - }) | |
| 2273 | + PopupMenuItem::new(kind.agent_name()) | |
| 2269 | 2274 | .on_click(move |_, window, cx| { |
| 2270 | 2275 | let _ = view.update(cx, |this, cx| this.add_provider(kind, window, cx)); |
| 2271 | 2276 | }), |
| @@ -2287,8 +2292,10 @@ fn status_line(state: &InstanceState) -> String { | ||
| 2287 | 2292 | match state.kind() { |
| 2288 | 2293 | Provider::Claude if state.account.is_some() => parts.push("Authenticated".to_string()), |
| 2289 | 2294 | 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())), | |
| 2292 | 2299 | } |
| 2293 | 2300 | parts.extend(state.plan.clone()); |
| 2294 | 2301 | if state.update_available() { |
| @@ -16,7 +16,6 @@ use gpui_kit::*; | ||
| 16 | 16 | use super::{Project, ThreadSettings, Workspace, hostname, initials, tint_for}; |
| 17 | 17 | use crate::claude::PermissionMode; |
| 18 | 18 | use crate::git::GitInfo; |
| 19 | - use crate::models::Provider; | |
| 20 | 19 | use crate::remote::{ |
| 21 | 20 | ClientMessage, ProjectInfo, ServerMessage, ThreadAction, ThreadSummary, |
| 22 | 21 | client::{ClientEvent, ConnectionStatus, RemoteClient}, |
| @@ -610,7 +609,7 @@ impl Workspace { | ||
| 610 | 609 | }); |
| 611 | 610 | let new = NewSession { |
| 612 | 611 | instance: settings.instance, |
| 613 | - provider: if settings.provider == Provider::Pi { Provider::Pi } else { Provider::Claude }, | |
| 612 | + provider: settings.provider, | |
| 614 | 613 | project, |
| 615 | 614 | model: settings.model, |
| 616 | 615 | permission_mode: settings.mode, |