feat(ai): discover chat models from installed CLIs
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.
This commit is contained in:
parent
76ca5efb21
commit
5bdd74d624
35
crates/openpencil-desktop/src/frame.rs
Normal file
35
crates/openpencil-desktop/src/frame.rs
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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<Window>,
|
||||
ctx: Option<SharedSkiaContext>,
|
||||
|
|
@ -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<chat_session::ChatSession>,
|
||||
/// 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,
|
||||
|
|
|
|||
254
crates/openpencil-desktop/src/model_discovery.rs
Normal file
254
crates/openpencil-desktop/src/model_discovery.rs
Normal file
|
|
@ -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<Receiver<Vec<ModelEntry>>>,
|
||||
}
|
||||
|
||||
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<ModelEntry> {
|
||||
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<PathBuf> {
|
||||
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<ModelEntry> {
|
||||
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
|
||||
/// `<home>/.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<ModelEntry> {
|
||||
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<Vec<ModelEntry>> {
|
||||
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<ModelEntry> {
|
||||
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<ModelEntry> {
|
||||
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<ModelEntry> {
|
||||
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<ModelEntry> = 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();
|
||||
}
|
||||
}
|
||||
36
crates/openpencil-shell-core/src/chat_models.rs
Normal file
36
crates/openpencil-shell-core/src/chat_models.rs
Normal file
|
|
@ -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<String>,
|
||||
display_name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
value: value.into(),
|
||||
display_name: display_name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
/// 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<crate::chat_models::ModelEntry>,
|
||||
/// 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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue