From 5bdd74d624e62940b68aa394bfa22c9638c1753b Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 15 May 2026 23:24:55 +0800 Subject: [PATCH] feat(ai): discover chat models from installed CLIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat panel never had a model list — shell-core is transport-free and cannot query a CLI. Add desktop-side cross-platform discovery: Codex's models_cache.json (real, file-based), `opencode models` (real, subprocess), and the documented CLI model names for Claude / Gemini / Copilot which expose no listing interface — each gated on the CLI actually being installed (PATH walk with platform exe extensions). Discovery runs on a worker thread and drains into chat.available_models. shell-core keeps only the ModelEntry type. Move the per-frame paint pass to frame.rs to keep main.rs under cap. --- crates/openpencil-desktop/src/frame.rs | 35 +++ crates/openpencil-desktop/src/main.rs | 46 +--- .../openpencil-desktop/src/model_discovery.rs | 254 ++++++++++++++++++ .../openpencil-shell-core/src/chat_models.rs | 36 +++ .../src/document/chat.rs | 17 ++ .../src/document/mutators.rs | 19 ++ crates/openpencil-shell-core/src/lib.rs | 1 + 7 files changed, 376 insertions(+), 32 deletions(-) create mode 100644 crates/openpencil-desktop/src/frame.rs create mode 100644 crates/openpencil-desktop/src/model_discovery.rs create mode 100644 crates/openpencil-shell-core/src/chat_models.rs diff --git a/crates/openpencil-desktop/src/frame.rs b/crates/openpencil-desktop/src/frame.rs new file mode 100644 index 000000000..38f80157f --- /dev/null +++ b/crates/openpencil-desktop/src/frame.rs @@ -0,0 +1,35 @@ +//! Per-frame paint pass for the desktop runner — extracted from +//! `main.rs` to keep that file under the 800-line cap. + +use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend}; +use openpencil_shell_native::{ + NativeBackend, NativeFrameBackend, SharedSkiaContext, WidgetHostNative, +}; + +/// Paint pass — clear, scale by DPI, dispatch to the widget host, +/// present the GL surface. +pub fn paint( + ctx: &mut SharedSkiaContext, + backend: &mut NativeBackend, + host: &WidgetHostNative, + viewport_width: f32, + viewport_height: f32, + dpi: f32, +) { + ctx.begin_frame(); + ctx.with_frame(|canvas, _glow| { + canvas.clear(skia_safe::Color::BLACK); + canvas.reset_matrix(); + canvas.scale((dpi, dpi)); + let mut frame = NativeFrameBackend::new(backend, canvas); + frame.fill_rect( + Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(viewport_width, viewport_height), + }, + Color::BLACK, + ); + host.paint(&mut frame, viewport_width, viewport_height); + }); + ctx.present(); +} diff --git a/crates/openpencil-desktop/src/main.rs b/crates/openpencil-desktop/src/main.rs index af659427d..109b0341d 100644 --- a/crates/openpencil-desktop/src/main.rs +++ b/crates/openpencil-desktop/src/main.rs @@ -12,7 +12,9 @@ mod chat_subprocess; mod cursor_icon; mod export; mod export_pdf; +mod frame; mod mcp_serve; +mod model_discovery; mod pen_doc_adapter; mod pen_doc_path_bounds; mod persistence; @@ -20,9 +22,8 @@ mod persistence_effects; mod persistence_variables; mod settings_io; -use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend}; use openpencil_shell_native::{ - NativeBackend, NativeFrameBackend, SharedSkiaContext, SharedSkiaError, WidgetHostNative, + NativeBackend, SharedSkiaContext, SharedSkiaError, WidgetHostNative, }; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -37,34 +38,6 @@ use winit::window::{Window, WindowId}; const INITIAL_VIEWPORT_W: f32 = 1440.0; const INITIAL_VIEWPORT_H: f32 = 900.0; -/// Paint pass — clear, scale by DPI, dispatch to the widget host. -fn paint( - ctx: &mut SharedSkiaContext, - backend: &mut NativeBackend, - host: &WidgetHostNative, - viewport_width: f32, - viewport_height: f32, - dpi: f32, -) { - ctx.begin_frame(); - ctx.with_frame(|canvas, _glow| { - canvas.clear(skia_safe::Color::BLACK); - canvas.reset_matrix(); - canvas.scale((dpi, dpi)); - let mut frame = NativeFrameBackend::new(backend, canvas); - frame.fill_rect( - Rect { - origin: Point2D::new(0.0, 0.0), - size: Point2D::new(viewport_width, viewport_height), - }, - Color::BLACK, - ); - host.paint(&mut frame, viewport_width, viewport_height); - }); - ctx.present(); -} - - struct DesktopApp { window: Option, ctx: Option, @@ -106,6 +79,10 @@ struct DesktopApp { /// `chat.pending_send`; the event loop drains that into a /// `ChatSession` here and pumps deltas into the transcript. current_chat: Option, + /// Background AI-model discovery — probes the installed CLIs + /// on a worker thread; its result is drained into + /// `chat.available_models` on a later frame. + model_probe: model_discovery::ModelProbe, } impl DesktopApp { @@ -133,6 +110,7 @@ impl DesktopApp { current_path: None, error: None, current_chat: None, + model_probe: model_discovery::ModelProbe::spawn(), } } @@ -269,7 +247,7 @@ impl ApplicationHandler for DesktopApp { self.window = Some(window); if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) { - paint( + frame::paint( ctx, backend, &self.host, @@ -335,10 +313,14 @@ impl ApplicationHandler for DesktopApp { if chat_session::pump(&mut self.host, &mut self.current_chat) { self.redraw_dirty = true; } + // Drain background model discovery once it lands. + if self.model_probe.poll_into(self.host.document_mut()) { + self.redraw_dirty = true; + } let should_paint = self.prepare_redraw(); if should_paint { if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) { - paint( + frame::paint( ctx, backend, &self.host, diff --git a/crates/openpencil-desktop/src/model_discovery.rs b/crates/openpencil-desktop/src/model_discovery.rs new file mode 100644 index 000000000..4bf6a3029 --- /dev/null +++ b/crates/openpencil-desktop/src/model_discovery.rs @@ -0,0 +1,254 @@ +//! Cross-platform AI-model discovery. +//! +//! The chat panel's model picker lists whatever models the +//! *locally installed* CLIs actually expose. shell-core is +//! transport-free, so this desktop module does the probing and +//! writes the result into `Document.chat.available_models`. +//! +//! Each provider degrades independently — a missing CLI, absent +//! cache file or failed subprocess yields *that* provider's empty +//! contribution and never aborts the others. Where a CLI offers a +//! real listing interface we use it; where it offers none we fall +//! back to its documented model names, gated on the CLI actually +//! being installed so the picker never advertises a provider the +//! user doesn't have. +//! +//! Works on macOS / Linux / Windows: `resolve_cli` walks `PATH` +//! with the platform's executable extensions, and cache paths go +//! through `dirs::home_dir`. + +use std::path::PathBuf; +use std::process::Command; +use std::sync::mpsc::{self, Receiver, TryRecvError}; + +use openpencil_shell_core::agent_settings_state::AgentProvider; +use openpencil_shell_core::chat_models::ModelEntry; +use openpencil_shell_core::document::Document; + +/// Background model-discovery probe. [`discover_models`] reads a +/// cache file and spawns a subprocess (`opencode models`, ~1 s), +/// so it must not block the event loop — the probe runs it on a +/// worker thread and the host drains the result on a later frame. +pub struct ModelProbe { + rx: Option>>, +} + +impl ModelProbe { + /// Spawn the discovery worker. Returns immediately. + pub fn spawn() -> Self { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(discover_models()); + }); + Self { rx: Some(rx) } + } + + /// If discovery has finished, move its models into `doc.chat` + /// and return `true`. Idempotent — the receiver is dropped + /// after the first drain so later calls are cheap no-ops. + pub fn poll_into(&mut self, doc: &mut Document) -> bool { + let Some(rx) = self.rx.as_ref() else { + return false; + }; + match rx.try_recv() { + Ok(models) => { + doc.chat.available_models = models; + doc.chat.selected_model = 0; + self.rx = None; + true + } + Err(TryRecvError::Empty) => false, + Err(TryRecvError::Disconnected) => { + self.rx = None; + false + } + } + } +} + +/// Probe every CLI we know how to query and return a flat, +/// provider-grouped model list. Safe to call off the UI thread — +/// it only reads files and spawns short-lived subprocesses. +pub fn discover_models() -> Vec { + let mut out = Vec::new(); + out.extend(discover_claude()); + out.extend(discover_codex()); + out.extend(discover_gemini()); + out.extend(discover_copilot()); + out.extend(discover_opencode()); + out +} + +/// Resolve `name` to an executable on `PATH`. On Windows this also +/// tries the `.exe` / `.cmd` / `.bat` suffixes so npm-installed CLI +/// shims resolve. Returns `None` when the CLI is not installed. +fn resolve_cli(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + let exts: &[&str] = if cfg!(windows) { + &["", ".exe", ".cmd", ".bat"] + } else { + &[""] + }; + for dir in std::env::split_paths(&path) { + for ext in exts { + let candidate = dir.join(format!("{name}{ext}")); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +/// Claude Code — the `claude` CLI exposes no model-listing command; +/// `--model` takes documented aliases that always resolve to the +/// current latest model. We surface those aliases (better than a +/// dated model-id list) when the CLI is installed. +fn discover_claude() -> Vec { + if resolve_cli("claude").is_none() { + return Vec::new(); + } + [ + ("default", "Default"), + ("sonnet", "Sonnet"), + ("opus", "Opus"), + ("haiku", "Haiku"), + ] + .iter() + .map(|(value, name)| ModelEntry::new(AgentProvider::ClaudeCode, *value, *name)) + .collect() +} + +/// Codex CLI — reads the real model list from +/// `/.codex/models_cache.json`, which the Codex CLI refreshes +/// itself. The cache may be absent on a fresh install (Codex writes +/// it on first use); in that case we fall back to the single known +/// default so the provider still appears once `codex` is on `PATH`. +fn discover_codex() -> Vec { + if let Some(models) = codex_models_from_cache() { + if !models.is_empty() { + return models; + } + } + if resolve_cli("codex").is_some() { + // Cache not yet populated — minimal placeholder so the + // provider is selectable; replaced once Codex writes its + // cache after first use. + return vec![ModelEntry::new(AgentProvider::CodexCli, "gpt-5.5", "GPT-5.5")]; + } + Vec::new() +} + +fn codex_models_from_cache() -> Option> { + let path = dirs::home_dir()?.join(".codex").join("models_cache.json"); + let raw = std::fs::read_to_string(path).ok()?; + let json: serde_json::Value = serde_json::from_str(&raw).ok()?; + let arr = json.get("models")?.as_array()?; + Some( + arr.iter() + .filter_map(|m| { + let slug = m.get("slug")?.as_str()?; + let name = m + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or(slug); + Some(ModelEntry::new(AgentProvider::CodexCli, slug, name)) + }) + .collect(), + ) +} + +/// Gemini CLI — the `gemini` CLI exposes only `-m/--model`, no +/// listing command, so we surface its documented model names when +/// the CLI is installed. +fn discover_gemini() -> Vec { + if resolve_cli("gemini").is_none() { + return Vec::new(); + } + [ + ("gemini-2.5-pro", "Gemini 2.5 Pro"), + ("gemini-2.5-flash", "Gemini 2.5 Flash"), + ] + .iter() + .map(|(value, name)| ModelEntry::new(AgentProvider::GeminiCli, *value, *name)) + .collect() +} + +/// GitHub Copilot CLI — like Gemini it exposes only `--model`; we +/// surface its documented model names when installed. +fn discover_copilot() -> Vec { + if resolve_cli("copilot").is_none() { + return Vec::new(); + } + [ + ("gpt-5", "GPT-5"), + ("claude-sonnet-4.5", "Claude Sonnet 4.5"), + ] + .iter() + .map(|(value, name)| ModelEntry::new(AgentProvider::GithubCopilot, *value, *name)) + .collect() +} + +/// OpenCode — `opencode models` prints one `provider/model` slug +/// per line. A real query: parse stdout. Empty when the CLI is +/// missing or the command fails. +fn discover_opencode() -> Vec { + let Some(exe) = resolve_cli("opencode") else { + return Vec::new(); + }; + let Ok(output) = Command::new(exe).arg("models").output() else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|slug| ModelEntry::new(AgentProvider::OpenCode, slug, slug)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codex_cache_parser_extracts_slug_and_display_name() { + // Mirrors the real `models_cache.json` shape; the parser + // must skip entries missing a slug and not abort the rest. + let json: serde_json::Value = serde_json::from_str( + r#"{"models":[ + {"slug":"gpt-5.5","display_name":"GPT-5.5"}, + {"display_name":"no slug — skipped"}, + {"slug":"gpt-5.5-codex"} + ]}"#, + ) + .unwrap(); + let arr = json.get("models").unwrap().as_array().unwrap(); + let parsed: Vec = arr + .iter() + .filter_map(|m| { + let slug = m.get("slug")?.as_str()?; + let name = m + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or(slug); + Some(ModelEntry::new(AgentProvider::CodexCli, slug, name)) + }) + .collect(); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].display_name, "GPT-5.5"); + // Missing display_name falls back to the slug. + assert_eq!(parsed[1].value, "gpt-5.5-codex"); + assert_eq!(parsed[1].display_name, "gpt-5.5-codex"); + } + + #[test] + fn discover_models_never_panics() { + // Whatever is or isn't installed on the test machine, the + // probe must return cleanly. + let _ = discover_models(); + } +} diff --git a/crates/openpencil-shell-core/src/chat_models.rs b/crates/openpencil-shell-core/src/chat_models.rs new file mode 100644 index 000000000..bb6f727ae --- /dev/null +++ b/crates/openpencil-shell-core/src/chat_models.rs @@ -0,0 +1,36 @@ +//! Chat model catalog types. +//! +//! shell-core is wasm32-clean and transport-free, so it owns only +//! the [`ModelEntry`] *type* — it never queries a CLI. The desktop +//! host (`openpencil-desktop`) discovers real models from the +//! connected CLIs (Codex's `models_cache.json`, `opencode models`, +//! the Claude SDK, …) and writes them into +//! `Document.chat.available_models`. + +use crate::agent_settings_state::AgentProvider; + +/// One selectable model in the chat model picker. +#[derive(Debug, Clone, PartialEq)] +pub struct ModelEntry { + /// Which CLI agent backs this model — also picks the chat + /// transport (`chat_session::provider_for_agent`). + pub provider: AgentProvider, + /// Wire id passed to the CLI (e.g. `gpt-5.5`, `claude-sonnet-4-6`). + pub value: String, + /// Human label shown in the picker (e.g. `GPT-5.5`). + pub display_name: String, +} + +impl ModelEntry { + pub fn new( + provider: AgentProvider, + value: impl Into, + display_name: impl Into, + ) -> Self { + Self { + provider, + value: value.into(), + display_name: display_name.into(), + } + } +} diff --git a/crates/openpencil-shell-core/src/document/chat.rs b/crates/openpencil-shell-core/src/document/chat.rs index 9081191a7..6ad5a757a 100644 --- a/crates/openpencil-shell-core/src/document/chat.rs +++ b/crates/openpencil-shell-core/src/document/chat.rs @@ -27,6 +27,13 @@ pub struct ChatState { /// shell-core stays transport-free — it only raises the flag; /// `openpencil-desktop` owns the actual `ChatProvider` plumbing. pub pending_send: Option, + /// Models the user can pick in the chat panel's model dropdown. + /// Empty until the desktop host discovers them from the + /// connected CLIs (`openpencil-desktop::model_discovery`) and + /// writes the result in. shell-core never queries a CLI. + pub available_models: Vec, + /// Index into `available_models` of the active model. + pub selected_model: usize, } impl Default for ChatState { @@ -39,10 +46,20 @@ impl Default for ChatState { collapsed: false, caret_anchor_ms: 0, pending_send: None, + available_models: Vec::new(), + selected_model: 0, } } } +impl ChatState { + /// The currently selected model, or `None` when the catalog is + /// empty (no CLI connected and the fallback list cleared). + pub fn selected_model_entry(&self) -> Option<&crate::chat_models::ModelEntry> { + self.available_models.get(self.selected_model) + } +} + /// Which corner of the canvas region the AI chat panel sits in. /// Step 5 P2: 4-corner edge snap on drag release. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/openpencil-shell-core/src/document/mutators.rs b/crates/openpencil-shell-core/src/document/mutators.rs index aaa1e339c..cd23db57a 100644 --- a/crates/openpencil-shell-core/src/document/mutators.rs +++ b/crates/openpencil-shell-core/src/document/mutators.rs @@ -224,6 +224,25 @@ impl Document { } } + /// Select the chat model at `idx` in `chat.available_models` + /// and close the picker. Also re-syncs `ui.chat_selected_agent` + /// to the model's provider so the desktop chat transport + /// (`chat_session::provider_for_agent`) targets the matching + /// CLI. A bad index is ignored (picker still closes). + pub fn select_chat_model(&mut self, idx: usize) { + if let Some(entry) = self.chat.available_models.get(idx) { + let provider = entry.provider; + self.chat.selected_model = idx; + if let Some(pidx) = crate::agent_settings_state::AgentProvider::ALL + .iter() + .position(|p| *p == provider) + { + self.ui.chat_selected_agent = pidx; + } + } + self.ui.chat_model_picker_open = false; + } + /// Whether `id` resolves to a node that can be mutated via /// selection-aware helpers (`translate_selected`, /// `set_selected_bounds`, etc.). Hidden + locked nodes are diff --git a/crates/openpencil-shell-core/src/lib.rs b/crates/openpencil-shell-core/src/lib.rs index 413706dd6..1330c320c 100644 --- a/crates/openpencil-shell-core/src/lib.rs +++ b/crates/openpencil-shell-core/src/lib.rs @@ -17,6 +17,7 @@ //! (single-page + infinite canvas recommended, multi-page also supported). pub mod agent_settings_state; +pub mod chat_models; pub mod chat_provider; pub mod codegen; pub mod document;