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, |