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:
Kayshen-X 2026-05-15 23:24:55 +08:00
parent 76ca5efb21
commit 5bdd74d624
7 changed files with 376 additions and 32 deletions

View 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();
}

View file

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

View 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();
}
}

View 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(),
}
}
}

View file

@ -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)]

View file

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

View file

@ -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;