From d92ae6b57100d65c02f6013737532e3cc520d45d Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 18 Jun 2026 20:03:55 -0500 Subject: [PATCH 01/89] feat: Hugging Face model search and thinking-capability detection Signed-off-by: Logan Nguyen --- docs/configurations.md | 2 + src-tauri/src/config/defaults.rs | 12 + src-tauri/src/lib.rs | 2 + src-tauri/src/models/mod.rs | 503 ++++++++++++++++++++++++++++++- 4 files changed, 514 insertions(+), 5 deletions(-) diff --git a/docs/configurations.md b/docs/configurations.md index 6d6c0d75..3858ba33 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -190,6 +190,8 @@ The table below also lists the baked-in safety limits that govern Thuki's commun | `MAX_HF_API_BODY_BYTES` | `4 MiB` | No | Defense-in-depth bound on attacker-controlled data from a remote service, mirroring `MAX_OLLAMA_TAGS_BODY_BYTES`. | — | The largest Hugging Face API response body (repo file listings) Thuki will accept while resolving a model to download. Larger responses are rejected mid-stream and the request returns an error. | | `HF_API_TIMEOUT_SECS` | `15 s` | No | Protocol cap on a hung remote service so the download UI cannot stall on metadata resolution; 15 s is generous for a small metadata call over the internet. | — | How long Thuki waits for a Hugging Face API metadata call (repo file listing) to respond before giving up. Applies to resolving pasted repo ids and listing a repo's GGUF files, not to the model download itself. | | `HF_BASE_URL` | `https://huggingface.co` | No | Single origin for model metadata and downloads. Provenance comes from the pinned repo revisions in the curated starter registry, and those pins are only meaningful against the canonical Hub; an arbitrary mirror could serve different content under the same revision ids. | — | The Hugging Face origin Thuki uses for all model metadata calls and blob downloads. Every starter in the registry pins a repo at an exact revision and carries a compiled-in sha256 digest checked after download; the digest catches truncation, bit rot, and resume corruption, while the pinned revision on the canonical Hub is what fixes which content is fetched. | +| `HF_SEARCH_LIMIT` | `30` | No | A fixed page size for the in-app model search: the most-downloaded N results cover the discovery need, and cursor pagination beyond it is out of scope until the browse UI requires it. | — | How many GGUF model repos a single in-app Hugging Face search returns, most-downloaded first. | +| `MAX_HF_SEARCH_QUERY_LEN` | `200 bytes` | No | Defense-in-depth bound on attacker-influenced input: the query reaches the fixed Hub host (no SSRF) and is percent-encoded by the client, but an unbounded string is still rejected to cap request size. | — | The longest search string Thuki sends to the Hugging Face model search. A longer query is rejected before any network call. | | `OPENAI_MODELS_TIMEOUT_SECS` | `5 s` | No | Protocol cap on a hung server so the Settings model dropdown cannot stall; the OpenAI-compatible server is local or LAN-hosted in the common case, so 5 s is generous. | — | How long Thuki waits for an OpenAI-compatible server's `/v1/models` listing to respond before giving up. Applies to the Settings model dropdown for that provider, not to chat requests. | | `MAX_SSE_LINE_BYTES` | `1 MiB` | No | Defense-in-depth bound on attacker-controlled stream data. A malicious or broken chat server could otherwise grow a single stream line without limit and exhaust memory. | — | The longest single Server-Sent-Events line Thuki accepts while streaming a chat response from an OpenAI-compatible (`/v1`) server. A stream line exceeding this aborts the response with an error. | diff --git a/src-tauri/src/config/defaults.rs b/src-tauri/src/config/defaults.rs index ed0c8b30..f8dd374f 100644 --- a/src-tauri/src/config/defaults.rs +++ b/src-tauri/src/config/defaults.rs @@ -404,6 +404,18 @@ pub const OPENAI_MODELS_TIMEOUT_SECS: u64 = 5; /// the integrity guarantees that make the curated starter registry safe. pub const HF_BASE_URL: &str = "https://huggingface.co"; +/// Page size for the in-app Hugging Face GGUF model search. Baked-in: a fixed +/// number of most-downloaded results per query is enough for the browser; +/// cursor pagination beyond this is intentionally out of scope until the UI +/// needs it. +pub const HF_SEARCH_LIMIT: usize = 30; + +/// Maximum accepted byte length for a Hugging Face search query before it is +/// sent upstream. Defense-in-depth bound on attacker-influenced input: the +/// query reaches the fixed Hub host (no SSRF) and is percent-encoded by the +/// client, but an unbounded string is still rejected to cap request size. +pub const MAX_HF_SEARCH_QUERY_LEN: usize = 200; + /// Maximum accepted byte length for a model slug passed to `set_active_model`. /// Real Ollama slugs are a handful of characters; 256 is generous while still /// capping adversarial inputs long before any network or database work. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 57f725fe..a20588e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2259,6 +2259,8 @@ pub fn run() { #[cfg(not(coverage))] models::list_hf_repo_ggufs, #[cfg(not(coverage))] + models::search_hf_models, + #[cfg(not(coverage))] models::list_openai_models, #[cfg(not(coverage))] models::cancel_model_download, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 316251bd..29c21c56 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -29,9 +29,10 @@ use tauri::Manager; use crate::config::defaults::{ DEFAULT_OLLAMA_SHOW_REQUEST_TIMEOUT_SECS, DEFAULT_OLLAMA_TAGS_REQUEST_TIMEOUT_SECS, - HF_API_TIMEOUT_SECS, HF_BASE_URL, MAX_HF_API_BODY_BYTES, MAX_MODEL_SLUG_LEN, - MAX_OLLAMA_SHOW_BODY_BYTES, MAX_OLLAMA_TAGS_BODY_BYTES, OPENAI_MODELS_TIMEOUT_SECS, - PROVIDER_ID_BUILTIN, PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OLLAMA, PROVIDER_KIND_OPENAI, + HF_API_TIMEOUT_SECS, HF_BASE_URL, HF_SEARCH_LIMIT, MAX_HF_API_BODY_BYTES, + MAX_HF_SEARCH_QUERY_LEN, MAX_MODEL_SLUG_LEN, MAX_OLLAMA_SHOW_BODY_BYTES, + MAX_OLLAMA_TAGS_BODY_BYTES, OPENAI_MODELS_TIMEOUT_SECS, PROVIDER_ID_BUILTIN, + PROVIDER_KIND_BUILTIN, PROVIDER_KIND_OLLAMA, PROVIDER_KIND_OPENAI, }; use crate::config::AppConfig; @@ -1252,6 +1253,35 @@ pub fn quant_from_filename(file: &str) -> String { .unwrap_or_default() } +/// Marker substrings that flag a GGUF model as emitting explicit reasoning +/// tokens (rendered in the ThinkingBlock UI). There is no machine-readable +/// thinking signal in GGUF metadata or the Hugging Face API, so detection reads +/// the publisher's own naming: an explicit reasoning self-label +/// (`thinking`/`reasoning`/`reasoner`) or a known reasoning-first family. The +/// list is kept narrow to avoid false positives; curated starters set the flag +/// explicitly in the registry and never consult it, and a user override is the +/// authority whenever the guess is wrong. +const THINKING_MARKERS: &[&str] = &[ + "thinking", + "reasoning", + "reasoner", + "deepseek-r1", + "qwq", + "gpt-oss", + "magistral", +]; + +/// Best-effort detection of whether an arbitrary GGUF model is a reasoning +/// model, matching [`THINKING_MARKERS`] case-insensitively against both the +/// repo id and the file name. Returns `false` when nothing matches. +pub fn detect_thinking(repo: &str, file: &str) -> bool { + let repo = repo.to_ascii_lowercase(); + let file = file.to_ascii_lowercase(); + THINKING_MARKERS + .iter() + .any(|marker| repo.contains(marker) || file.contains(marker)) +} + /// A `.gguf` entry in a Hugging Face repo listing, for the paste-a-repo UI. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct HfGgufFile { @@ -1323,7 +1353,8 @@ pub struct MmprojCompanion { /// Pure parse of an HF repo listing into the spec for one target `file`. /// Capability rule for pasted repos: vision = an `mmproj*.gguf` sibling with -/// complete LFS metadata exists; thinking = false (full detection is not yet implemented). +/// complete LFS metadata exists; thinking is derived from the model name by +/// [`detect_thinking`] when the row is recorded in [`repo_installed_model`]. pub fn resolve_listing(body: &[u8], file: &str) -> Result { let info: HfRepoInfo = serde_json::from_slice(body) .map_err(|e| format!("failed to decode Hugging Face API response: {e}"))?; @@ -1481,6 +1512,161 @@ pub async fn fetch_repo_gguf_listing( parse_gguf_listing(&body) } +// ─── Hugging Face model search ─────────────────────────────────────────────── + +/// One repo row from a Hugging Face model search, trimmed to the fields the +/// in-app browser needs to identify, rank, and gate a model. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct HfModelSummary { + /// Repo id, e.g. `unsloth/Qwen3.5-9B-GGUF`; the install target. + pub id: String, + /// Lifetime download count. The search is sorted by it and the UI shows it + /// as a trust signal; `0` when the API omits the field. + pub downloads: u64, + /// True when the repo is access-gated (license click-through or manual + /// approval). Gated repos cannot be fetched anonymously, so the UI can flag + /// them instead of offering a download that would fail. + pub gated: bool, +} + +/// One entry in the Hugging Face `/api/models` search response. Only the fields +/// surfaced by [`HfModelSummary`] are decoded; everything else is ignored so +/// upstream additions cannot break decoding. +#[derive(Deserialize)] +struct HfSearchEntry { + #[serde(default)] + id: String, + #[serde(default)] + downloads: u64, + /// HF reports `gated` as `false` or a strategy string (`"auto"`/`"manual"`); + /// [`deserialize_gated`] normalizes it to a bool. Absent on some rows, so it + /// defaults to `false`. + #[serde(default, deserialize_with = "deserialize_gated")] + gated: bool, +} + +/// Normalizes Hugging Face's polymorphic `gated` field (a bool `false` or a +/// strategy string like `"manual"`) into a plain bool: any string means gated, +/// `true` means gated, everything else (including `null`) means not gated. +fn deserialize_gated<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::Bool(b) => b, + serde_json::Value::String(_) => true, + _ => false, + }) +} + +/// Pure parse of an `/api/models` search body into summary rows. Rows with an +/// empty `id` are dropped rather than surfaced as un-installable blanks. +pub fn parse_search_results(body: &[u8]) -> Result, String> { + let entries: Vec = serde_json::from_slice(body) + .map_err(|e| format!("failed to decode Hugging Face search response: {e}"))?; + Ok(entries + .into_iter() + .filter(|e| !e.id.is_empty()) + .map(|e| HfModelSummary { + id: e.id, + downloads: e.downloads, + gated: e.gated, + }) + .collect()) +} + +/// Validates the query length, runs the Hugging Face GGUF model search against +/// `base_url`, and parses the result. `base_url` is parameterized so tests +/// point at a mock server; production passes [`HF_BASE_URL`]. +pub async fn fetch_hf_search( + client: &reqwest::Client, + base_url: &str, + query: &str, +) -> Result, String> { + let query = query.trim(); + if query.len() > MAX_HF_SEARCH_QUERY_LEN { + return Err(format!( + "search query exceeds maximum length of {MAX_HF_SEARCH_QUERY_LEN} bytes" + )); + } + let body = fetch_hf_search_inner( + client, + base_url, + query, + std::time::Duration::from_secs(HF_API_TIMEOUT_SECS), + MAX_HF_API_BODY_BYTES, + HF_SEARCH_LIMIT, + ) + .await?; + parse_search_results(&body) +} + +/// Innermost search fetcher with timeout, body cap, and result limit +/// configurable so the cap branches are testable. Every query parameter is +/// percent-encoded by `Url::parse_with_params` (no manual string building) so a +/// query cannot smuggle URL syntax, and the host stays fixed to `base_url` so +/// there is no SSRF surface. The body cap is enforced incrementally during the +/// streaming read, mirroring [`fetch_hf_repo_listing_inner`]. +async fn fetch_hf_search_inner( + client: &reqwest::Client, + base_url: &str, + query: &str, + timeout: std::time::Duration, + max_body_bytes: usize, + limit: usize, +) -> Result, String> { + let endpoint = format!("{}/api/models", base_url.trim_end_matches('/')); + let limit = limit.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("library", "gguf"), + ("sort", "downloads"), + ("direction", "-1"), + ("limit", &limit), + ]; + // An empty query browses the most-downloaded GGUF repos; only attach the + // search term when the user actually typed one. + if !query.is_empty() { + params.push(("search", query)); + } + let url = reqwest::Url::parse_with_params(&endpoint, params) + .map_err(|e| format!("failed to build Hugging Face search URL: {e}"))?; + let response = client + .get(url) + .timeout(timeout) + .send() + .await + .map_err(|e| format!("failed to reach Hugging Face: {e}"))?; + + if !response.status().is_success() { + return Err(format!( + "Hugging Face API returned HTTP {}", + response.status().as_u16() + )); + } + + if let Some(declared_len) = response.content_length() { + if declared_len as usize > max_body_bytes { + return Err(format!( + "Hugging Face search response exceeded {max_body_bytes} bytes" + )); + } + } + + let mut stream = response.bytes_stream(); + let mut buf: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("failed to read Hugging Face search body: {e}"))?; + if buf.len() + chunk.len() > max_body_bytes { + return Err(format!( + "Hugging Face search response exceeded {max_body_bytes} bytes" + )); + } + buf.extend_from_slice(&chunk); + } + + Ok(buf) +} + // ─── OpenAI-compatible model listing ───────────────────────────────────────── /// Subset of an OpenAI-compatible `/v1/models` response Thuki consumes. @@ -1643,7 +1829,7 @@ pub fn repo_installed_model( size_bytes: resolved.weights_size_bytes, quant: quant_from_filename(file), vision: resolved.mmproj.is_some(), - thinking: false, + thinking: detect_thinking(repo, file), mmproj_file: resolved.mmproj.as_ref().map(|m| m.file.clone()), mmproj_sha256: resolved.mmproj.as_ref().map(|m| m.sha256.clone()), } @@ -1820,6 +2006,18 @@ pub async fn list_hf_repo_ggufs( fetch_repo_gguf_listing(&client, HF_BASE_URL, &repo).await } +/// Searches Hugging Face for GGUF model repos matching `query`, most-downloaded +/// first. Backs the in-app model browser; an empty query returns the most +/// popular GGUF repos. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub async fn search_hf_models( + query: String, + client: tauri::State<'_, reqwest::Client>, +) -> Result, String> { + fetch_hf_search(&client, HF_BASE_URL, &query).await +} + /// Lists the models served by the configured OpenAI-compatible provider via /// its `/v1/models` endpoint, using the Keychain API key when one is stored. #[cfg_attr(coverage_nightly, coverage(off))] @@ -4197,6 +4395,259 @@ mod tests { assert_eq!(files[0].file, "model-Q4_K_M.gguf"); } + // ── Model library: Hugging Face search ─────────────────────────────────── + + /// Search fixture exercising every `gated` shape (bool, strategy string, + /// absent, null) plus an empty-id row that must be dropped. + fn search_fixture() -> serde_json::Value { + serde_json::json!([ + {"id": "org/alpha-GGUF", "downloads": 1000, "gated": false}, + {"id": "org/beta-GGUF", "downloads": 500, "gated": "manual"}, + {"id": "org/gamma-GGUF"}, + {"id": "org/delta-GGUF", "downloads": 1, "gated": true}, + {"id": "org/epsilon-GGUF", "downloads": 2, "gated": null}, + {"id": "", "downloads": 9} + ]) + } + + #[test] + fn parse_search_results_maps_rows_and_normalizes_gated() { + let body = search_fixture().to_string(); + let rows = parse_search_results(body.as_bytes()).unwrap(); + assert_eq!( + rows, + vec![ + HfModelSummary { + id: "org/alpha-GGUF".to_string(), + downloads: 1000, + gated: false, + }, + HfModelSummary { + id: "org/beta-GGUF".to_string(), + downloads: 500, + gated: true, + }, + HfModelSummary { + id: "org/gamma-GGUF".to_string(), + downloads: 0, + gated: false, + }, + HfModelSummary { + id: "org/delta-GGUF".to_string(), + downloads: 1, + gated: true, + }, + HfModelSummary { + id: "org/epsilon-GGUF".to_string(), + downloads: 2, + gated: false, + }, + ] + ); + } + + #[test] + fn parse_search_results_rejects_invalid_json() { + let err = parse_search_results(b"not json").unwrap_err(); + assert!(err.contains("failed to decode"), "got: {err}"); + } + + #[test] + fn hf_model_summary_serializes_snake_case() { + let v = serde_json::to_value(HfModelSummary { + id: "o/r".to_string(), + downloads: 7, + gated: true, + }) + .unwrap(); + assert_eq!( + v, + serde_json::json!({"id": "o/r", "downloads": 7, "gated": true}) + ); + } + + #[tokio::test] + async fn fetch_hf_search_returns_rows_and_sends_filtered_query() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/api/models") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("library".into(), "gguf".into()), + mockito::Matcher::UrlEncoded("search".into(), "qwen".into()), + mockito::Matcher::UrlEncoded("sort".into(), "downloads".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(search_fixture().to_string()) + .create_async() + .await; + let client = reqwest::Client::new(); + let rows = fetch_hf_search(&client, &server.url(), "qwen") + .await + .unwrap(); + mock.assert_async().await; + assert_eq!(rows.len(), 5); + assert_eq!(rows[0].id, "org/alpha-GGUF"); + } + + #[tokio::test] + async fn fetch_hf_search_omits_blank_query() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("GET", "/api/models") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_body("[]") + .create_async() + .await; + let client = reqwest::Client::new(); + // Whitespace-only query trims to empty and the search param is dropped. + let rows = fetch_hf_search(&client, &server.url(), " ") + .await + .unwrap(); + assert!(rows.is_empty()); + } + + #[tokio::test] + async fn fetch_hf_search_maps_http_error() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("GET", "/api/models") + .match_query(mockito::Matcher::Any) + .with_status(503) + .create_async() + .await; + let client = reqwest::Client::new(); + let err = fetch_hf_search(&client, &server.url(), "q") + .await + .unwrap_err(); + assert!(err.contains("503"), "got: {err}"); + } + + #[tokio::test] + async fn fetch_hf_search_maps_transport_error() { + let client = reqwest::Client::new(); + let err = fetch_hf_search(&client, "http://127.0.0.1:1", "q") + .await + .unwrap_err(); + assert!(err.contains("failed to reach Hugging Face"), "got: {err}"); + } + + #[tokio::test] + async fn fetch_hf_search_rejects_overlong_query() { + let client = reqwest::Client::new(); + let long = "x".repeat(crate::config::defaults::MAX_HF_SEARCH_QUERY_LEN + 1); + let err = fetch_hf_search(&client, "http://127.0.0.1:9", &long) + .await + .unwrap_err(); + assert!(err.contains("maximum length"), "got: {err}"); + } + + #[tokio::test] + async fn fetch_hf_search_inner_rejects_body_over_cap_via_content_length() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("GET", "/api/models") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_body("x".repeat(100)) + .create_async() + .await; + let client = reqwest::Client::new(); + let err = fetch_hf_search_inner( + &client, + &server.url(), + "q", + std::time::Duration::from_secs(5), + 32, + 30, + ) + .await + .unwrap_err(); + assert!(err.contains("exceeded"), "got: {err}"); + } + + #[tokio::test] + async fn fetch_hf_search_inner_rejects_body_over_cap_when_chunked() { + // Chunked response (no Content-Length): the incremental cap must reject. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + use std::io::{Read, Write}; + let mut request_buf = [0u8; 1024]; + let _ = conn.read(&mut request_buf); + let _ = conn.write_all( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\ + 0a\r\n0123456789\r\n\ + 0a\r\n0123456789\r\n\ + 0a\r\n0123456789\r\n\ + 0\r\n\r\n", + ); + }); + let client = reqwest::Client::new(); + let base = format!("http://{addr}"); + let err = fetch_hf_search_inner( + &client, + &base, + "q", + std::time::Duration::from_secs(5), + 20, + 30, + ) + .await + .unwrap_err(); + assert!(err.contains("exceeded"), "got: {err}"); + } + + #[tokio::test] + async fn fetch_hf_search_inner_maps_body_read_error() { + // Headers promise 100 body bytes, then the server hangs up. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + use std::io::{Read, Write}; + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n", + ); + }); + let client = reqwest::Client::new(); + let base = format!("http://{addr}"); + let err = fetch_hf_search_inner( + &client, + &base, + "q", + std::time::Duration::from_secs(5), + 4 * 1024 * 1024, + 30, + ) + .await + .unwrap_err(); + assert!( + err.contains("failed to read Hugging Face search body"), + "got: {err}" + ); + } + + #[tokio::test] + async fn fetch_hf_search_inner_rejects_unparseable_base_url() { + let client = reqwest::Client::new(); + let err = fetch_hf_search_inner( + &client, + "not a url", + "q", + std::time::Duration::from_secs(5), + 4 * 1024 * 1024, + 30, + ) + .await + .unwrap_err(); + assert!(err.contains("failed to build"), "got: {err}"); + } + // ── Model library: repo spec/model mapping ─────────────────────────────── fn sample_resolved(with_mmproj: bool) -> RepoResolved { @@ -4266,6 +4717,48 @@ mod tests { assert_eq!(m.mmproj_sha256, None); } + // ── Capability detection: thinking heuristic ───────────────────────────── + + #[test] + fn detect_thinking_matches_reasoning_self_labels() { + // A repo or file whose own name advertises reasoning. + assert!(detect_thinking("acme/Model-Thinking", "model.gguf")); + assert!(detect_thinking("acme/model", "model-reasoning-Q4_K_M.gguf")); + assert!(detect_thinking("acme/reasoner-7b", "w.gguf")); + } + + #[test] + fn detect_thinking_matches_known_reasoning_families() { + assert!(detect_thinking("deepseek-ai/DeepSeek-R1-GGUF", "x.gguf")); + assert!(detect_thinking("org/QwQ-32B-GGUF", "x.gguf")); + assert!(detect_thinking("ggml-org/gpt-oss-20b-GGUF", "x.gguf")); + assert!(detect_thinking("mistralai/Magistral-Small-GGUF", "x.gguf")); + } + + #[test] + fn detect_thinking_is_case_insensitive() { + assert!(detect_thinking("ORG/GPT-OSS-20B", "MODEL.GGUF")); + } + + #[test] + fn detect_thinking_defaults_false_without_markers() { + assert!(!detect_thinking( + "google/gemma-4-12b-it", + "gemma-4-12b-it-Q4_K_M.gguf" + )); + assert!(!detect_thinking("o/r", "w-Q4_K_M.gguf")); + } + + #[test] + fn repo_installed_model_flags_thinking_from_name() { + let m = repo_installed_model( + "ggml-org/gpt-oss-20b-GGUF", + "gpt-oss-20b-Q4_K_M.gguf", + &sample_resolved(false), + ); + assert!(m.thinking); + } + // ── Model library: delete ──────────────────────────────────────────────── #[test] From da15b51138318770c1deb2f847475e258c5365e5 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 19 Jun 2026 01:18:18 -0500 Subject: [PATCH 02/89] feat: restructure Settings to a premium left sidebar with a running-model footer Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 2 +- src-tauri/tauri.conf.json | 6 +- src/settings/SettingsWindow.test.tsx | 152 +++++++-- src/settings/SettingsWindow.tsx | 178 ++++++----- .../components/RunningModelFooter.test.tsx | 289 ++++++++++++++++++ .../components/RunningModelFooter.tsx | 113 +++++++ .../hooks/useSettingsAutoResize.test.ts | 4 +- src/settings/hooks/useSettingsAutoResize.ts | 18 +- src/styles/settings.module.css | 226 ++++++++++---- 9 files changed, 820 insertions(+), 168 deletions(-) create mode 100644 src/settings/components/RunningModelFooter.test.tsx create mode 100644 src/settings/components/RunningModelFooter.tsx diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a20588e6..8295f7b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -595,7 +595,7 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo /// the OS-default spawn position or previous moves. #[cfg_attr(coverage_nightly, coverage(off))] fn position_settings_window(window: &tauri::WebviewWindow) { - const SETTINGS_WIDTH: f64 = 580.0; + const SETTINGS_WIDTH: f64 = 760.0; // macOS menu bar is ~24 px logical on standard displays; notched MacBooks // push it to ~37 px. 72 px gives a comfortable ~35-48 px visual gap below // the menu bar on all hardware. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9cda66e8..f37deb67 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,11 +28,11 @@ "label": "settings", "title": "Thuki Settings", "url": "index.html#/settings", - "width": 580, + "width": 760, "height": 520, - "minWidth": 580, + "minWidth": 760, "minHeight": 280, - "maxWidth": 580, + "maxWidth": 760, "maxHeight": 700, "resizable": false, "fullscreen": false, diff --git a/src/settings/SettingsWindow.test.tsx b/src/settings/SettingsWindow.test.tsx index 93d2c44a..c092bccb 100644 --- a/src/settings/SettingsWindow.test.tsx +++ b/src/settings/SettingsWindow.test.tsx @@ -85,6 +85,14 @@ function defaultInvoke(cmd: string): unknown { return true; case 'check_screen_recording_permission': return true; + case 'get_model_picker_state': + return { active: null, all: [], displayNames: {}, ollamaReachable: true }; + case 'list_installed_models': + return []; + case 'get_engine_status': + return { state: 'stopped', model_path: '', port: null, error: null }; + case 'get_loaded_model': + return null; case 'get_updater_state': return { last_check_at_unix: null, @@ -116,7 +124,7 @@ describe('SettingsWindow', () => { it('renders the five tab labels after config loads', async () => { render(); await waitFor(() => - expect(screen.getByRole('tab', { name: /AI/ })).toBeInTheDocument(), + expect(screen.getByRole('tab', { name: /Models/ })).toBeInTheDocument(), ); expect(screen.getByRole('tab', { name: /Behavior/ })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /Web/ })).toBeInTheDocument(); @@ -141,10 +149,10 @@ describe('SettingsWindow', () => { ).toBeInTheDocument(); }); - it('starts on the AI tab', async () => { + it('starts on the Models tab', async () => { render(); await waitFor(() => - expect(screen.getByRole('tab', { name: /AI/ })).toHaveAttribute( + expect(screen.getByRole('tab', { name: /Models/ })).toHaveAttribute( 'aria-selected', 'true', ), @@ -171,7 +179,7 @@ describe('SettingsWindow', () => { .spyOn(globalThis, 'requestAnimationFrame') .mockImplementation(() => 0); const { container } = render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); const body = container.querySelector('[role="tabpanel"]')!; expect(body.className).not.toMatch(/bodyScrollable/); @@ -191,9 +199,9 @@ describe('SettingsWindow', () => { it('ArrowRight rotates focus to the next tab', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); - const modelTab = screen.getByRole('tab', { name: /AI/ }); + const modelTab = screen.getByRole('tab', { name: /Models/ }); fireEvent.keyDown(modelTab, { key: 'ArrowRight' }); expect(screen.getByRole('tab', { name: /Behavior/ })).toHaveAttribute( 'aria-selected', @@ -203,9 +211,9 @@ describe('SettingsWindow', () => { it('ArrowLeft wraps to the last tab when starting on the first', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); - const modelTab = screen.getByRole('tab', { name: /AI/ }); + const modelTab = screen.getByRole('tab', { name: /Models/ }); await act(async () => { fireEvent.keyDown(modelTab, { key: 'ArrowLeft' }); await Promise.resolve(); @@ -219,9 +227,9 @@ describe('SettingsWindow', () => { it('non-arrow keys are ignored by the tab key handler', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); - const modelTab = screen.getByRole('tab', { name: /AI/ }); + const modelTab = screen.getByRole('tab', { name: /Models/ }); fireEvent.keyDown(modelTab, { key: 'Enter' }); expect(modelTab).toHaveAttribute('aria-selected', 'true'); }); @@ -276,7 +284,7 @@ describe('SettingsWindow', () => { it('Cmd+, on the document re-focuses the settings window', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); __mockWindow.setFocus.mockClear(); fireEvent.keyDown(document, { key: ',', metaKey: true }); @@ -285,7 +293,7 @@ describe('SettingsWindow', () => { it('Other keystrokes do not trigger setFocus', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); __mockWindow.setFocus.mockClear(); fireEvent.keyDown(document, { key: ',' }); // no Meta @@ -295,7 +303,7 @@ describe('SettingsWindow', () => { it('Cmd+W on the document hides the settings window', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); __mockWindow.hide.mockClear(); fireEvent.keyDown(document, { key: 'w', metaKey: true }); @@ -304,7 +312,7 @@ describe('SettingsWindow', () => { it('the close button hides the window instead of quitting', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); __mockWindow.hide.mockClear(); fireEvent.click(screen.getByRole('button', { name: /Close/ })); expect(__mockWindow.hide).toHaveBeenCalled(); @@ -312,11 +320,11 @@ describe('SettingsWindow', () => { it('mousedown on the chrome triggers startDragging when not on an interactive element', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); __mockWindow.startDragging.mockClear(); // Click on the body container itself (not on a button/input). const root = screen - .getByRole('tab', { name: /AI/ }) + .getByRole('tab', { name: /Models/ }) .closest('[role="tablist"]')!.parentElement!; fireEvent.mouseDown(root, { target: root }); // The root is a div; not in INTERACTIVE_TAGS, so dragging fires. @@ -325,9 +333,9 @@ describe('SettingsWindow', () => { it('mousedown that originates from an interactive element does NOT trigger drag', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); __mockWindow.startDragging.mockClear(); - fireEvent.mouseDown(screen.getByRole('tab', { name: /AI/ })); + fireEvent.mouseDown(screen.getByRole('tab', { name: /Models/ })); expect(__mockWindow.startDragging).not.toHaveBeenCalled(); }); @@ -349,10 +357,10 @@ describe('SettingsWindow', () => { it('mousedown with a non-primary button is ignored (no drag, lets context menus through)', async () => { render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); __mockWindow.startDragging.mockClear(); const root = screen - .getByRole('tab', { name: /AI/ }) + .getByRole('tab', { name: /Models/ }) .closest('[role="tablist"]')!.parentElement!; fireEvent.mouseDown(root, { target: root, button: 2 }); expect(__mockWindow.startDragging).not.toHaveBeenCalled(); @@ -396,7 +404,7 @@ describe('SettingsWindow', () => { await Promise.resolve(); await Promise.resolve(); }); - expect(screen.getByRole('status')).toHaveTextContent('Saved'); + expect(screen.getByText('✓ Saved')).toHaveTextContent('Saved'); // Second save before pill auto-hides — clearTimeout(savedTimerRef.current) fires. fireEvent.click(incBtns()[0]); @@ -406,7 +414,7 @@ describe('SettingsWindow', () => { await Promise.resolve(); await Promise.resolve(); }); - expect(screen.getByRole('status')).toHaveTextContent('Saved'); + expect(screen.getByText('✓ Saved')).toHaveTextContent('Saved'); }); it('unmount with the savedPill timer still pending clears it cleanly', async () => { @@ -460,7 +468,7 @@ describe('SettingsWindow', () => { await Promise.resolve(); }); - expect(screen.getByRole('status')).toHaveTextContent('Saved'); + expect(screen.getByText('✓ Saved')).toHaveTextContent('Saved'); // After SAVED_PILL_DURATION_MS the pill toggles back to invisible. We // don't assert on that visibility here because the underlying class @@ -484,7 +492,7 @@ describe('SettingsWindow', () => { return defaultInvoke(cmd); }); render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); await waitFor(() => expect(screen.getByText(/0\.8\.0 is ready/)).toBeInTheDocument(), ); @@ -545,7 +553,7 @@ describe('SettingsWindow', () => { return defaultInvoke(cmd); }); render(); - await waitFor(() => screen.getByRole('tab', { name: /AI/ })); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); // Allow time for updater state to load await act(async () => { await Promise.resolve(); @@ -554,3 +562,97 @@ describe('SettingsWindow', () => { expect(screen.queryByText(/0\.8\.0 is ready/)).not.toBeInTheDocument(); }); }); + +describe('SettingsWindow left sidebar (Phase 3)', () => { + it('renders the section nav as a vertical sidebar', async () => { + render(); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); + expect(screen.getByRole('tablist')).toHaveAttribute( + 'aria-orientation', + 'vertical', + ); + }); + + it('renders Models as the first section label', async () => { + render(); + await waitFor(() => + expect(screen.getByRole('tab', { name: /Models/ })).toBeInTheDocument(), + ); + }); + + it('ArrowDown rotates focus to the next sidebar section', async () => { + render(); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); + fireEvent.keyDown(screen.getByRole('tab', { name: /Models/ }), { + key: 'ArrowDown', + }); + expect(screen.getByRole('tab', { name: /Behavior/ })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('ArrowUp wraps to the last sidebar section from the first', async () => { + render(); + await waitFor(() => screen.getByRole('tab', { name: /Models/ })); + await act(async () => { + fireEvent.keyDown(screen.getByRole('tab', { name: /Models/ }), { + key: 'ArrowUp', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByRole('tab', { name: /About/ })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('shows the running-model footer with the active built-in model and size', async () => { + const builtinConfig: RawAppConfig = { + ...SAMPLE, + inference: { + ...SAMPLE.inference, + active_provider: 'builtin', + providers: SAMPLE.inference.providers.map((p) => + p.kind === 'builtin' + ? { ...p, model: 'org/Qwen3.5-9B-GGUF:Qwen3.5-9B-Q4_K_M.gguf' } + : p, + ), + }, + }; + invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === 'get_config') return builtinConfig; + if (cmd === 'list_installed_models') { + return [ + { + id: 'org/Qwen3.5-9B-GGUF:Qwen3.5-9B-Q4_K_M.gguf', + display_name: 'Qwen3.5 9B', + size_bytes: 6_600_000_000, + quant: 'Q4_K_M', + }, + ]; + } + if (cmd === 'get_engine_status') { + return { state: 'loaded', model_path: '/x', port: 1, error: null }; + } + return defaultInvoke(cmd); + }); + + render(); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + expect(footer).toHaveTextContent('Qwen3.5 9B'); + expect(footer).toHaveTextContent(/Built-in/); + expect(footer).toHaveTextContent(/6\.6 GB/); + }); + + it('running-model footer shows a placeholder when no model is resolved', async () => { + render(); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + expect(footer).toHaveTextContent(/No model/i); + }); +}); diff --git a/src/settings/SettingsWindow.tsx b/src/settings/SettingsWindow.tsx index f33138b7..91f09469 100644 --- a/src/settings/SettingsWindow.tsx +++ b/src/settings/SettingsWindow.tsx @@ -31,6 +31,7 @@ import { SearchTab } from './tabs/SearchTab'; import { DisplayTab } from './tabs/DisplayTab'; import { AboutTab } from './tabs/AboutTab'; import { SavedPill } from './components'; +import { RunningModelFooter } from './components/RunningModelFooter'; import { WindowControls } from '../components/WindowControls'; import { UpdateBanner } from '../components/UpdateBanner'; import { useUpdater } from '../hooks/useUpdater'; @@ -44,8 +45,8 @@ const TABS: ReadonlyArray<{ }> = [ { id: 'general', - label: 'AI', - // Brain — visual cue that this tab is for the AI itself. + label: 'Models', + // Grid — the model library / management surface. icon: ( - - + + + + ), }, @@ -151,14 +154,17 @@ const SAVED_PILL_DURATION_MS = 1500; /** * Static chrome offset from inner content to total window height: - * window padding-top (8) + WindowControls strip (~28) + tab bar (~70) + * window padding-top (8) + WindowControls strip (~28) * + body padding top+bottom (18 + 24 = 42). + * The section nav now lives in a left sidebar beside the content, so it no + * longer adds vertical chrome (the old top tab bar did). The sidebar's own + * height is seated by the hook's MIN_HEIGHT floor instead. * Empirically measured against the rendered Settings window. If any of * the chrome surfaces change height, update this constant rather than * trying to read `offsetHeight` at runtime — the auto-resize hook fires * before paint settles, so dynamic measurement of chrome would miss. */ -const CHROME_HEIGHT = 148; +const CHROME_HEIGHT = 78; /** Recovery banner height when the corrupt-config marker is shown. */ const BANNER_HEIGHT = 56; @@ -345,81 +351,93 @@ export function SettingsWindow() { /> ) : null} -
- {TABS.map((tab) => { - const active = tab.id === activeTab; - return ( - - ); - })} -
+
+
+
Settings
+
+ {TABS.map((tab) => { + const active = tab.id === activeTab; + return ( + + ); + })} +
+
+ +
-
-
- {activeTab === 'general' ? ( - - ) : null} - {activeTab === 'behavior' ? ( - - ) : null} - {activeTab === 'search' ? ( - - ) : null} - {activeTab === 'display' ? ( - - ) : null} - {activeTab === 'about' ? ( - - ) : null} +
+
+
+ {activeTab === 'general' ? ( + + ) : null} + {activeTab === 'behavior' ? ( + + ) : null} + {activeTab === 'search' ? ( + + ) : null} + {activeTab === 'display' ? ( + + ) : null} + {activeTab === 'about' ? ( + + ) : null} +
+
diff --git a/src/settings/components/RunningModelFooter.test.tsx b/src/settings/components/RunningModelFooter.test.tsx new file mode 100644 index 00000000..34717fff --- /dev/null +++ b/src/settings/components/RunningModelFooter.test.tsx @@ -0,0 +1,289 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { invoke } from '@tauri-apps/api/core'; +import { + emitTauriEvent, + clearEventHandlers, +} from '../../testUtils/mocks/tauri'; + +import { RunningModelFooter } from './RunningModelFooter'; +import type { RawAppConfig, RawProvider } from '../types'; + +const invokeMock = invoke as unknown as ReturnType; + +const BUILTIN: RawProvider = { + id: 'builtin', + kind: 'builtin', + label: 'Built-in', + base_url: '', + model: '', + vision: false, +}; +const OLLAMA: RawProvider = { + id: 'ollama', + kind: 'ollama', + label: 'Ollama', + base_url: 'http://127.0.0.1:11434', + model: '', + vision: false, +}; +const OPENAI: RawProvider = { + id: 'openai', + kind: 'openai', + label: 'LM Studio', + base_url: 'http://127.0.0.1:1234', + model: '', + vision: false, +}; + +function makeConfig( + activeProvider: string, + providers: RawProvider[], +): RawAppConfig { + return { + inference: { + active_provider: activeProvider, + keep_warm_inactivity_minutes: 0, + num_ctx: 16384, + providers, + }, + prompt: { system: '' }, + window: { + overlay_width: 600, + max_chat_height: 648, + max_images: 3, + text_base_px: 15, + text_line_height: 1.5, + text_letter_spacing_px: 0, + text_font_weight: 500, + }, + quote: { + max_display_lines: 4, + max_display_chars: 300, + max_context_length: 4096, + }, + behavior: { auto_replace: false, auto_close: false }, + search: { + searxng_url: '', + reader_url: '', + max_iterations: 3, + top_k_urls: 10, + searxng_max_results: 10, + search_timeout_s: 20, + reader_per_url_timeout_s: 10, + reader_batch_timeout_s: 30, + judge_timeout_s: 30, + router_timeout_s: 45, + }, + debug: { trace_enabled: false }, + }; +} + +const QWEN_ROW = { + id: 'org/Qwen3.5-9B-GGUF:Qwen3.5-9B-Q4_K_M.gguf', + display_name: 'Qwen3.5 9B', + size_bytes: 6_600_000_000, + quant: 'Q4_K_M', +}; + +function mockInvoke(over: Record = {}) { + invokeMock.mockImplementation(async (cmd: string) => { + if (Object.prototype.hasOwnProperty.call(over, cmd)) { + const v = over[cmd]; + if (v instanceof Error) throw v; + return v; + } + switch (cmd) { + case 'list_installed_models': + return []; + case 'get_engine_status': + return { state: 'stopped', model_path: '', port: null, error: null }; + default: + return undefined; + } + }); +} + +beforeEach(() => { + invokeMock.mockReset(); + clearEventHandlers(); + mockInvoke(); +}); + +afterEach(() => { + clearEventHandlers(); +}); + +describe('RunningModelFooter', () => { + it('shows the built-in model name, size, and a live dot when the engine is loaded', async () => { + const builtin = { ...BUILTIN, model: QWEN_ROW.id }; + mockInvoke({ + list_installed_models: [QWEN_ROW], + get_engine_status: { + state: 'loaded', + model_path: '/x', + port: 1, + error: null, + }, + }); + + render( + , + ); + + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + await waitFor(() => expect(footer).toHaveTextContent('Qwen3.5 9B')); + expect(footer).toHaveTextContent('Built-in · 6.6 GB'); + expect(footer.querySelector('[class*="runningModelDot"]')).not.toBeNull(); + // Live dot, not the idle variant. + expect(footer.querySelector('[class*="DotIdle"]')).toBeNull(); + }); + + it('shows a placeholder when the active built-in model is not installed', async () => { + const builtin = { ...BUILTIN, model: 'org/missing:m.gguf' }; + mockInvoke({ list_installed_models: [QWEN_ROW] }); + + render( + , + ); + + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + await waitFor(() => expect(footer).toHaveTextContent(/No model/i)); + }); + + it('shows the Ollama model name and label with an idle dot', async () => { + const ollama = { ...OLLAMA, model: 'llama3.1:8b' }; + render( + , + ); + + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + expect(footer).toHaveTextContent('llama3.1:8b'); + expect(footer).toHaveTextContent('Ollama'); + expect(footer.querySelector('[class*="DotIdle"]')).not.toBeNull(); + }); + + it('shows a placeholder when the active Ollama provider has no model', async () => { + render( + , + ); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + expect(footer).toHaveTextContent(/No model/i); + }); + + it('shows the OpenAI provider model and label', async () => { + const openai = { ...OPENAI, model: 'qwen2.5-coder' }; + render( + , + ); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + expect(footer).toHaveTextContent('qwen2.5-coder'); + expect(footer).toHaveTextContent('LM Studio'); + }); + + it('falls back to a placeholder when the active provider id matches nothing', async () => { + render( + , + ); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + expect(footer).toHaveTextContent(/No model/i); + }); + + it('tolerates a config with no built-in provider', async () => { + const ollama = { ...OLLAMA, model: 'llama3.1:8b' }; + render(); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + expect(footer).toHaveTextContent('llama3.1:8b'); + }); + + it('treats a non-array installed payload as empty', async () => { + const builtin = { ...BUILTIN, model: QWEN_ROW.id }; + mockInvoke({ list_installed_models: null }); + render( + , + ); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + await waitFor(() => expect(footer).toHaveTextContent(/No model/i)); + }); + + it('survives a failed installed-models read', async () => { + const builtin = { ...BUILTIN, model: QWEN_ROW.id }; + mockInvoke({ list_installed_models: new Error('io') }); + render( + , + ); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + await waitFor(() => expect(footer).toHaveTextContent(/No model/i)); + }); + + it('survives a failed engine-status read', async () => { + const builtin = { ...BUILTIN, model: QWEN_ROW.id }; + mockInvoke({ + list_installed_models: [QWEN_ROW], + get_engine_status: new Error('engine down'), + }); + render( + , + ); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + await waitFor(() => expect(footer).toHaveTextContent('Qwen3.5 9B')); + // Engine status unknown -> idle dot. + expect(footer.querySelector('[class*="DotIdle"]')).not.toBeNull(); + }); + + it('reflects a live engine via the engine:status event stream', async () => { + const builtin = { ...BUILTIN, model: QWEN_ROW.id }; + mockInvoke({ list_installed_models: [QWEN_ROW] }); + render( + , + ); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + await waitFor(() => expect(footer).toHaveTextContent('Qwen3.5 9B')); + expect(footer.querySelector('[class*="DotIdle"]')).not.toBeNull(); + + await act(async () => { + emitTauriEvent('engine:status', { + state: 'loaded', + model_path: '/x', + port: 1, + error: null, + }); + }); + expect(footer.querySelector('[class*="DotIdle"]')).toBeNull(); + }); + + it('omits the meta line when the active provider has a model but no label', async () => { + const ollama = { ...OLLAMA, model: 'llama3.1:8b', label: '' }; + render( + , + ); + const footer = await screen.findByRole('status', { + name: /running model/i, + }); + expect(footer).toHaveTextContent('llama3.1:8b'); + expect(footer.querySelector('[class*="runningModelMeta"]')).toBeNull(); + }); +}); diff --git a/src/settings/components/RunningModelFooter.tsx b/src/settings/components/RunningModelFooter.tsx new file mode 100644 index 00000000..fb3a6a6a --- /dev/null +++ b/src/settings/components/RunningModelFooter.tsx @@ -0,0 +1,113 @@ +/** + * "Running model" footer pinned to the bottom of the Settings sidebar. + * + * Always visible, it names the model the active provider will answer with, + * adds a size hint for the built-in engine, and shows a live dot that lights + * when that model is currently resident in memory. + * + * Data sources, kept deliberately small: + * - The active provider, its label, and (for Ollama/OpenAI) its model come + * straight from the config snapshot the parent already owns; the active + * model persists onto the provider's `model` field. + * - The built-in engine's display name + on-disk size come from the manifest + * (`list_installed_models`), refreshed whenever the selected built-in model + * id changes. + * - Liveness for the built-in engine follows `get_engine_status` plus the + * `engine:status` event stream. Ollama/OpenAI residency is not polled here, + * so their dot stays idle. + */ + +import { useEffect, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; + +import styles from '../../styles/settings.module.css'; +import type { RawAppConfig } from '../types'; +import type { EngineStatus, InstalledModel } from '../../types/starter'; + +/** Bytes rendered as decimal gigabytes with one decimal (e.g. "6.6"). */ +function gb(bytes: number): string { + return (bytes / 1e9).toFixed(1); +} + +interface RunningModelFooterProps { + config: RawAppConfig; +} + +export function RunningModelFooter({ config }: RunningModelFooterProps) { + const [installed, setInstalled] = useState([]); + const [engineState, setEngineState] = + useState('stopped'); + + const providers = config.inference.providers; + const active = providers.find( + (p) => p.id === config.inference.active_provider, + ); + const kind = active?.kind ?? 'ollama'; + const builtinModelId = + providers.find((p) => p.kind === 'builtin')?.model ?? ''; + + // Manifest read seeds the built-in size/name; re-runs when the selected + // built-in model id changes (a download/delete/switch lifts a new config). + useEffect(() => { + void invoke('list_installed_models') + .then((rows) => setInstalled(Array.isArray(rows) ? rows : [])) + .catch(() => setInstalled([])); + }, [builtinModelId]); + + // Engine lifecycle drives the live dot for the built-in engine. Seed from + // the current snapshot (the backend only emits on transitions) then follow + // the event stream. + useEffect(() => { + invoke('get_engine_status') + .then((status) => setEngineState(status.state)) + .catch(() => { + // Keep the stopped default; the event stream corrects it. + }); + const unlisten = listen('engine:status', (e) => { + setEngineState(e.payload.state); + }); + return () => { + void unlisten.then((fn) => fn()); + }; + }, []); + + let name: string | null; + let meta: string | null; + if (kind === 'builtin') { + const row = installed.find((m) => m.id === builtinModelId); + name = row ? row.display_name : null; + meta = row ? `Built-in · ${gb(row.size_bytes)} GB` : null; + } else { + name = active && active.model !== '' ? active.model : null; + meta = active ? active.label : null; + } + + const live = kind === 'builtin' && engineState === 'loaded'; + + return ( +
+
Running
+ {name ? ( + <> +
+ + {name} +
+ {meta ?
{meta}
: null} + + ) : ( +
No model selected
+ )} +
+ ); +} diff --git a/src/settings/hooks/useSettingsAutoResize.test.ts b/src/settings/hooks/useSettingsAutoResize.test.ts index 06563ac4..ba6885dd 100644 --- a/src/settings/hooks/useSettingsAutoResize.test.ts +++ b/src/settings/hooks/useSettingsAutoResize.test.ts @@ -5,9 +5,9 @@ import { useState } from 'react'; import { __mockWindow } from '../../testUtils/mocks/tauri-window'; import { useSettingsAutoResize } from './useSettingsAutoResize'; -const SETTINGS_WIDTH = 580; +const SETTINGS_WIDTH = 760; const ANIMATE_MS = 220; -const MIN_HEIGHT = 280; +const MIN_HEIGHT = 440; const MAX_HEIGHT = 700; const CHROME = 148; diff --git a/src/settings/hooks/useSettingsAutoResize.ts b/src/settings/hooks/useSettingsAutoResize.ts index 75ebb695..7d4b1600 100644 --- a/src/settings/hooks/useSettingsAutoResize.ts +++ b/src/settings/hooks/useSettingsAutoResize.ts @@ -33,16 +33,26 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window'; const ANIMATE_MS = 220; -/** Hard floor: settings panel below this is unusable on macOS. */ -const MIN_HEIGHT = 280; +/** + * Hard floor. The window hugs the active section's content height, but the + * left sidebar (group label + five items + the pinned Running-model footer) + * is taller than a light section like Behavior. This floor guarantees the + * window is always tall enough to show the whole sidebar with the footer + * clearly separated from the last item, so short sections never clip it. + */ +const MIN_HEIGHT = 440; /** * Hard ceiling: keeps the panel comfortably small even on a 13" laptop. * Tabs whose natural content exceeds this (Web's full timeouts list) * scroll inside `.body` rather than push the window taller. */ const MAX_HEIGHT = 700; -/** Settings is intentionally a fixed-width column. */ -const SETTINGS_WIDTH = 580; +/** + * Settings is a fixed width. Wide enough that the 172px left sidebar leaves + * the content column the room the old single-column layout had (and that the + * dense Models/Discover list needs). + */ +const SETTINGS_WIDTH = 760; /** Sub-pixel ResizeObserver chatter is dropped below this threshold. */ const NEGLIGIBLE_DELTA_PX = 4; diff --git a/src/styles/settings.module.css b/src/styles/settings.module.css index bb45add9..48bac8ee 100644 --- a/src/styles/settings.module.css +++ b/src/styles/settings.module.css @@ -25,6 +25,31 @@ * NSPanel-converted main window can render past its bounds cleanly. * Vocabulary still mirrors .morphing-container in App.css. */ .window { + /* ─── Premium token layer (Phase 3 model-settings redesign) ─────────── + * Scoped to the Settings window so every settings surface (sidebar, + * Models segmented panes, reskinned standard tabs) reads one set of + * values. Mirrors the locked design tokens. */ + --base: #100e0d; + --rail: #0b0a09; + --elev-1: rgba(255, 255, 255, 0.03); + --elev-2: rgba(255, 255, 255, 0.055); + --hair: rgba(255, 255, 255, 0.075); + --hair-soft: rgba(255, 255, 255, 0.045); + --t1: #eceae7; + --t2: rgba(236, 234, 231, 0.54); + --t3: rgba(236, 234, 231, 0.34); + --accent: #ff8d5c; + --accent-soft: rgba(255, 141, 92, 0.14); + --vis: #7fd1a6; + --vis-bg: rgba(127, 209, 166, 0.1); + --rea: #b9a4f0; + --rea-bg: rgba(185, 164, 240, 0.1); + --ok: #79c08e; + --tight: #e6b56b; + --radius-card: 10px; + --radius-control: 8px; + --radius-pill: 999px; + position: fixed; inset: 0; display: flex; @@ -33,24 +58,18 @@ * space above the traffic-light dots — mirrors the chat overlay's * outer `pt-2` padding so both panels feel the same vertically. */ padding-top: 8px; - background: - radial-gradient( - ellipse 70% 40% at 50% 0%, - rgba(255, 141, 92, 0.08) 0%, - transparent 65% - ), - var(--color-surface-base); - color: var(--color-text-primary); + /* Premium flat base (warm off-black), elevation comes from light overlays + * on the surfaces above it, not a muddy radial wash. */ + background: var(--base); + color: var(--t1); font-family: var(--font-sans); -webkit-font-smoothing: antialiased; font-size: 13px; user-select: none; - border: 1px solid var(--color-surface-border); + border: 1px solid var(--hair); border-top-color: rgba(255, 141, 92, 0.2); border-radius: 10px; overflow: hidden; - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); } /* Glowing 1px hairline at the very top edge — matches morphing-container. */ @@ -107,69 +126,171 @@ gap: 6px; } -/* ─── Top bar (horizontal icon tabs, CodexBar layout) ───────────────────── */ +/* ─── Left sidebar (section nav + Running-model footer) ─────────────────── */ -.tabBar { +/* This codebase has no global box-sizing reset, so the rail boxes opt into + * border-box here: otherwise a width:100% item plus its padding renders + * wider than the rail and overflows into the content pane, where it gets + * clipped at the divider (the active pill "cutoff"). Scoped to the rail so + * the content pane keeps its existing content-box sizing. */ +.stage, +.main, +.side, +.side * { + box-sizing: border-box; +} + +/* The window stays a vertical column [WindowControls][banners][stage]; the + * stage is the horizontal split between the section rail and the active + * section's content. */ +.stage { + flex: 1; display: flex; - justify-content: center; - align-items: flex-end; - gap: 4px; - padding: 12px 20px 10px; - flex-shrink: 0; + min-height: 0; +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.side { + width: 172px; + flex: none; + display: flex; + flex-direction: column; + padding: 12px 10px; + min-height: 0; + background: var(--rail); + border-right: 1px solid var(--hair-soft); +} + +.sideGroup { + margin: 6px 8px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--t3); } -.tab { +.sideTabs { display: flex; flex-direction: column; + gap: 2px; +} + +.sideItem { + position: relative; + display: flex; align-items: center; - gap: 6px; - padding: 8px 14px 8px; + gap: 11px; + width: 100%; + padding: 8px 10px; border: none; + border-radius: 8px; background: transparent; - color: rgba(240, 240, 242, 0.5); - cursor: pointer; + color: var(--t2); font-family: inherit; - border-radius: 10px; - min-width: 72px; + font-size: 13px; + font-weight: 500; + text-align: left; + cursor: pointer; transition: - color 180ms ease, - background 180ms ease; + color 160ms ease, + background 160ms ease; } -.tab:hover:not(.tabActive) { - color: var(--color-text-primary); - background: rgba(255, 255, 255, 0.025); +.sideItem:hover:not(.sideItemActive) { + color: var(--t1); + background: var(--elev-1); } -.tab:focus-visible { +.sideItem:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(255, 141, 92, 0.32); } -.tab svg { - width: 22px; - height: 22px; - display: block; +.sideItemActive { + color: var(--t1); + background: var(--elev-2); +} +.sideItemIcon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; +} +.sideItemIcon svg { + width: 16px; + height: 16px; stroke-width: 1.6; + opacity: 0.9; } -.tabLabel { - font-size: 11px; - font-weight: 500; - letter-spacing: 0.01em; +/* Active section: the icon carries the accent. One active signal (orange + * icon + stronger fill + bright label), no separate rail bar that read as + * clutter against the filled pill. */ +.sideItemActive .sideItemIcon { + color: var(--accent); +} +.sideItemActive .sideItemIcon svg { + opacity: 1; +} +.sideItemLabel { color: inherit; } -.tabActive { - color: var(--color-primary); - background: rgba(0, 0, 0, 0.28); - box-shadow: inset 0 0 0 1px rgba(255, 141, 92, 0.1); +.sideSpacer { + flex: 1; } -.tabActive svg { - color: var(--color-primary); + +/* Running-model footer (always visible, pinned to the sidebar bottom). The + * spacer above pushes it down when there is room; this margin guarantees a + * gap from the last section item even when the spacer collapses. */ +.runningModel { + margin-top: 14px; + padding: 9px 10px; + border: 1px solid var(--hair-soft); + border-radius: var(--radius-card); + background: var(--elev-1); } -/* Decorative wrapper kept for backward-compat with structure; in the - * top-bar layout the icon sits inline with the label, no chrome of its - * own. */ -.tabIcon { - display: inline-flex; +.runningModelEyebrow { + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--t3); +} +.runningModelName { + display: flex; align-items: center; - justify-content: center; + gap: 6px; + margin-top: 4px; + font-size: 12px; + font-weight: 580; + color: var(--t1); +} +.runningModelDot { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 7px var(--accent); +} +.runningModelDotIdle { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + /* The active/selected model, just not resident yet: accent (no glow), never + * grey, which reads as "disabled". The glow distinguishes the live state. */ + background: var(--accent); + opacity: 0.85; +} +.runningModelMeta { + margin-top: 3px; + font-size: 10.5px; + color: var(--t3); } /* ─── Body (scrolling content) ──────────────────────────────────────────── */ @@ -1355,8 +1476,7 @@ } @media (prefers-reduced-motion: reduce) { - .tab, - .tabIcon, + .sideItem, .input, .textarea, .button, From 699d50eca2363ab39980c89b8874379a8beeaa1c Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 19 Jun 2026 01:26:26 -0500 Subject: [PATCH 03/89] feat: add the Models segmented Library/Discover/Providers control Signed-off-by: Logan Nguyen --- .../tabs/models/ModelsSegmented.test.tsx | 68 +++++++++++++++++++ src/settings/tabs/models/ModelsSegmented.tsx | 60 ++++++++++++++++ src/styles/settings.module.css | 39 +++++++++++ 3 files changed, 167 insertions(+) create mode 100644 src/settings/tabs/models/ModelsSegmented.test.tsx create mode 100644 src/settings/tabs/models/ModelsSegmented.tsx diff --git a/src/settings/tabs/models/ModelsSegmented.test.tsx b/src/settings/tabs/models/ModelsSegmented.test.tsx new file mode 100644 index 00000000..8ef0d9bb --- /dev/null +++ b/src/settings/tabs/models/ModelsSegmented.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ModelsSegmented } from './ModelsSegmented'; + +describe('ModelsSegmented', () => { + it('renders the three model views', () => { + render( {}} />); + expect(screen.getByRole('tab', { name: 'Library' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Discover' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Providers' })).toBeInTheDocument(); + }); + + it('marks the active view as selected', () => { + render( {}} />); + expect(screen.getByRole('tab', { name: 'Discover' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByRole('tab', { name: 'Library' })).toHaveAttribute( + 'aria-selected', + 'false', + ); + }); + + it('calls onChange with the clicked view', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('tab', { name: 'Library' })); + expect(onChange).toHaveBeenCalledWith('library'); + }); + + it('ArrowRight selects the next view', () => { + const onChange = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole('tab', { name: 'Library' }), { + key: 'ArrowRight', + }); + expect(onChange).toHaveBeenCalledWith('discover'); + }); + + it('ArrowRight wraps from the last view to the first', () => { + const onChange = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole('tab', { name: 'Providers' }), { + key: 'ArrowRight', + }); + expect(onChange).toHaveBeenCalledWith('library'); + }); + + it('ArrowLeft wraps from the first view to the last', () => { + const onChange = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole('tab', { name: 'Library' }), { + key: 'ArrowLeft', + }); + expect(onChange).toHaveBeenCalledWith('providers'); + }); + + it('ignores non-arrow keys', () => { + const onChange = vi.fn(); + render(); + fireEvent.keyDown(screen.getByRole('tab', { name: 'Library' }), { + key: 'Enter', + }); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/settings/tabs/models/ModelsSegmented.tsx b/src/settings/tabs/models/ModelsSegmented.tsx new file mode 100644 index 00000000..2d2deeb6 --- /dev/null +++ b/src/settings/tabs/models/ModelsSegmented.tsx @@ -0,0 +1,60 @@ +/** + * Segmented control that switches the Models surface between its three + * sub-views. Rendered at the top of the Models section; the chosen view + * swaps the pane below it. + * + * A nested tablist (the left sidebar is the outer one): the views are + * mutually exclusive panes, so tab semantics + roving arrow keys are the + * right pattern. Labelled distinctly so queries never collide with the + * sidebar's section tabs. + */ + +import styles from '../../../styles/settings.module.css'; + +export type ModelsSubview = 'library' | 'discover' | 'providers'; + +const VIEWS: ReadonlyArray<{ id: ModelsSubview; label: string }> = [ + { id: 'library', label: 'Library' }, + { id: 'discover', label: 'Discover' }, + { id: 'providers', label: 'Providers' }, +]; + +interface ModelsSegmentedProps { + value: ModelsSubview; + onChange: (next: ModelsSubview) => void; +} + +export function ModelsSegmented({ value, onChange }: ModelsSegmentedProps) { + return ( +
+ {VIEWS.map((view) => { + const active = view.id === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/styles/settings.module.css b/src/styles/settings.module.css index 48bac8ee..629aa4f8 100644 --- a/src/styles/settings.module.css +++ b/src/styles/settings.module.css @@ -293,6 +293,45 @@ color: var(--t3); } +/* ─── Models surface (segmented Library / Discover / Providers) ──────────── */ + +.seg { + display: inline-flex; + box-sizing: border-box; + padding: 3px; + border: 1px solid var(--hair-soft); + border-radius: 9px; + background: var(--elev-1); +} +.segItem { + box-sizing: border-box; + padding: 6px 14px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--t2); + font-family: inherit; + font-size: 12px; + font-weight: 540; + cursor: pointer; + transition: + color 140ms ease, + background 140ms ease; +} +.segItem:hover:not(.segItemActive) { + color: var(--t1); +} +.segItem:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--accent-soft); +} +/* Active view: filled accent pill with dark text (the one accent fill on the + * surface, matching the locked design). */ +.segItemActive { + color: #16110d; + background: var(--accent); +} + /* ─── Body (scrolling content) ──────────────────────────────────────────── */ .body { From afad60958e248ce95eb7e7994e2288d272f7822f Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 19 Jun 2026 01:31:36 -0500 Subject: [PATCH 04/89] feat: wire the Models segmented control into the Model tab Signed-off-by: Logan Nguyen --- src/settings/SettingsWindow.test.tsx | 9 +- src/settings/tabs/ModelTab.tsx | 843 ++++++++++++++------------- src/settings/tabs/tabs.test.tsx | 34 +- src/styles/settings.module.css | 15 + 4 files changed, 490 insertions(+), 411 deletions(-) diff --git a/src/settings/SettingsWindow.test.tsx b/src/settings/SettingsWindow.test.tsx index c092bccb..2093d097 100644 --- a/src/settings/SettingsWindow.test.tsx +++ b/src/settings/SettingsWindow.test.tsx @@ -567,10 +567,11 @@ describe('SettingsWindow left sidebar (Phase 3)', () => { it('renders the section nav as a vertical sidebar', async () => { render(); await waitFor(() => screen.getByRole('tab', { name: /Models/ })); - expect(screen.getByRole('tablist')).toHaveAttribute( - 'aria-orientation', - 'vertical', - ); + // Scope to the sidebar: the Models pane also renders a (horizontal) + // segmented tablist for Library/Discover/Providers. + expect( + screen.getByRole('tablist', { name: 'Settings sections' }), + ).toHaveAttribute('aria-orientation', 'vertical'); }); it('renders Models as the first section label', async () => { diff --git a/src/settings/tabs/ModelTab.tsx b/src/settings/tabs/ModelTab.tsx index 1849072f..9b7b7134 100644 --- a/src/settings/tabs/ModelTab.tsx +++ b/src/settings/tabs/ModelTab.tsx @@ -19,6 +19,7 @@ import { BuiltinProviderCard, OpenAiProviderCard, } from './ProviderCards'; +import { ModelsSegmented, type ModelsSubview } from './models/ModelsSegmented'; import { useDebouncedSave } from '../hooks/useDebouncedSave'; import { useModelSelection } from '../../hooks/useModelSelection'; import { isNonLocalUrl } from '../../utils/isNonLocalUrl'; @@ -86,6 +87,9 @@ const CTX_TICKS = [ ]; export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { + // Which of the three Models sub-views is showing. Providers is the default + // (the active provider + generation controls, the most-used surface). + const [view, setView] = useState('providers'); const [inactivityMin, setInactivityMin] = useState( config.inference.keep_warm_inactivity_minutes, ); @@ -285,455 +289,482 @@ export function ModelTab({ config, resyncToken, onSaved }: ModelTabProps) { return ( <> -
-
- - +
+ +
+ + {view === 'library' ? ( +
+ Your installed models will appear here.
+ ) : null} -
- - - { - ollamaUrlFocusedRef.current = true; - }} - onChange={(e) => setOllamaUrl(e.target.value)} - onBlur={() => { - ollamaUrlFocusedRef.current = false; - commitOllamaUrl(); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); - }} - /> - - {isNonLocalUrl(ollamaUrl) && ( -

- This points Thuki at a non-local Ollama server. You are - responsible for securing it: prefer a VPN/Tailscale or SSH tunnel - over exposing the port directly. -

- )} - {/* get_model_picker_state is scoped to the ACTIVE provider, so this - inventory only describes Ollama while Ollama is active. Hide the - row otherwise to avoid listing another provider's models here. */} - {activeKind === 'ollama' ? ( - - {availableModels.length > 0 ? ( - void setActiveModel(m)} - ariaLabel="Active Ollama model" - /> - ) : ( - No models installed - )} - - ) : null} + {view === 'discover' ? ( +
+ Browse and download Hugging Face models here.
+ ) : null} - {/* The OpenAI-compatible provider KIND is gated behind a - compile-time, dev-only flag (off in shipped builds): both the - management card and the "add a server" affordance are the only UI - paths to create or manage one, so hiding them keeps the kind out of - reach of end users. The shared /v1 backend stays live for the - built-in engine regardless. */} - {openaiProviderEnabled ? ( - openaiProvider ? ( + {view === 'providers' ? ( + <> +
- +
- ) : ( - - ) - ) : null} -
- {/* Unified residency control: one Keep Warm knob bound to +
+ + + { + ollamaUrlFocusedRef.current = true; + }} + onChange={(e) => setOllamaUrl(e.target.value)} + onBlur={() => { + ollamaUrlFocusedRef.current = false; + commitOllamaUrl(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') + (e.target as HTMLInputElement).blur(); + }} + /> + + {isNonLocalUrl(ollamaUrl) && ( +

+ This points Thuki at a non-local Ollama server. You are + responsible for securing it: prefer a VPN/Tailscale or SSH + tunnel over exposing the port directly. +

+ )} + {/* get_model_picker_state is scoped to the ACTIVE provider, so this + inventory only describes Ollama while Ollama is active. Hide the + row otherwise to avoid listing another provider's models here. */} + {activeKind === 'ollama' ? ( + + {availableModels.length > 0 ? ( + void setActiveModel(m)} + ariaLabel="Active Ollama model" + /> + ) : ( + + No models installed + + )} + + ) : null} +
+ + {/* The OpenAI-compatible provider KIND is gated behind a + compile-time, dev-only flag (off in shipped builds): both the + management card and the "add a server" affordance are the only UI + paths to create or manage one, so hiding them keeps the kind out of + reach of end users. The shared /v1 backend stays live for the + built-in engine regardless. */} + {openaiProviderEnabled ? ( + openaiProvider ? ( +
+ + +
+ ) : ( + + ) + ) : null} +
+ + {/* Unified residency control: one Keep Warm knob bound to keep_warm_inactivity_minutes, shown for both local providers (built-in engine and Ollama) and hidden for OpenAI (Thuki does not manage a remote server's residency). The status row branches by kind: the built-in engine reports its sidecar lifecycle, Ollama reports the model resident in VRAM. */} - {activeKind === 'builtin' || activeKind === 'ollama' ? ( -
- {/* Row 1: label + [?] on left | Release after [N] min on right */} -
-
- - Keep active model in memory - - - - -
-
- - Release after - - { - minFocusedRef.current = true; - }} - onChange={(e) => { - const n = parseInt(e.target.value, 10); - if (Number.isNaN(n)) { - setRawMin(e.target.value); - } else { - const clamped = Math.max(-1, Math.min(1440, n)); - setRawMin(String(clamped)); - setInactivityMin(clamped); - } - }} - onBlur={() => { - minFocusedRef.current = false; - if (Number.isNaN(parseInt(rawMin, 10))) { - setRawMin('0'); - setInactivityMin(0); - } - }} - /> - min -
-
- - {/* Row 2: residency status on left | Unload now on right. */} - {activeKind === 'builtin' ? ( -
- - Engine: {engineState} - - -
- ) : ( -
-
- {loadedModel !== null ? ( -
-
- ) : ( - - No model loaded + {activeKind === 'builtin' || activeKind === 'ollama' ? ( +
+ {/* Row 1: label + [?] on left | Release after [N] min on right */} +
+
+ + Keep active model in memory - )} + + + +
+
+ + Release after + + { + minFocusedRef.current = true; + }} + onChange={(e) => { + const n = parseInt(e.target.value, 10); + if (Number.isNaN(n)) { + setRawMin(e.target.value); + } else { + const clamped = Math.max(-1, Math.min(1440, n)); + setRawMin(String(clamped)); + setInactivityMin(clamped); + } + }} + onBlur={() => { + minFocusedRef.current = false; + if (Number.isNaN(parseInt(rawMin, 10))) { + setRawMin('0'); + setInactivityMin(0); + } + }} + /> + min +
- -
- )} -
- ) : null} + Unload now + +
+ ) : ( +
+
+ {loadedModel !== null ? ( +
+
+ ) : ( + + No model loaded + + )} +
-
-
- {/* Label row: "Context window" left + editable token chip right */} -
- Context window -
+ +
+ )} +
+ ) : null} + +
+
+ {/* Label row: "Context window" left + editable token chip right */} +
+ Context window +
+ setCtxChip(e.target.value)} + onBlur={() => { + const n = parseInt(ctxChip, 10); + if (!Number.isNaN(n) && n >= CTX_MIN) { + // Clamp upper bound so the UI mirrors the backend + // BOUNDS_NUM_CTX cap and the slider stays in sync. + commitCtx(Math.min(n, CTX_MAX)); + } else { + setCtxChip(String(numCtx)); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') + (e.target as HTMLInputElement).blur(); + }} + /> + tokens +
+
+ + {/* Log-scale slider — fill percentage tracked via CSS custom property */} setCtxChip(e.target.value)} - onBlur={() => { - const n = parseInt(ctxChip, 10); - if (!Number.isNaN(n) && n >= CTX_MIN) { - // Clamp upper bound so the UI mirrors the backend - // BOUNDS_NUM_CTX cap and the slider stays in sync. - commitCtx(Math.min(n, CTX_MAX)); - } else { - setCtxChip(String(numCtx)); - } + aria-valuemin={CTX_MIN} + aria-valuemax={CTX_MAX} + aria-valuenow={numCtx} + aria-valuetext={`${numCtx} tokens`} + onChange={(e) => { + ctxDraggingRef.current = true; + const pos = Number(e.target.value); + setCtxPos(pos); + setCtxChip(String(posToCtx(pos))); + }} + onMouseUp={() => { + ctxDraggingRef.current = false; + commitCtx(posToCtx(ctxPos)); }} - onKeyDown={(e) => { - if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + onTouchEnd={() => { + ctxDraggingRef.current = false; + commitCtx(posToCtx(ctxPos)); + }} + onKeyUp={() => { + if (!ctxDraggingRef.current) commitCtx(posToCtx(ctxPos)); }} /> - tokens -
-
- - {/* Log-scale slider — fill percentage tracked via CSS custom property */} - { - ctxDraggingRef.current = true; - const pos = Number(e.target.value); - setCtxPos(pos); - setCtxChip(String(posToCtx(pos))); - }} - onMouseUp={() => { - ctxDraggingRef.current = false; - commitCtx(posToCtx(ctxPos)); - }} - onTouchEnd={() => { - ctxDraggingRef.current = false; - commitCtx(posToCtx(ctxPos)); - }} - onKeyUp={() => { - if (!ctxDraggingRef.current) commitCtx(posToCtx(ctxPos)); - }} - /> - - - {activeKind === 'builtin' && - (engineState === 'starting' || engineState === 'stopping') ? ( -
- Applying… the engine restarts with the new context on your next - message. -
- ) : null} + -
- ~{ctxTurns.toLocaleString()} turns of context - {' · '} - {activeKind === 'builtin' - ? 'Passed to the engine as --ctx-size at start; changing it restarts the engine.' - : activeKind === 'openai' - ? 'Informational only; your server controls the actual context.' - : "Ollama caps to your model's trained maximum."} -
+ {activeKind === 'builtin' && + (engineState === 'starting' || engineState === 'stopping') ? ( +
+ Applying… the engine restarts with the new context on your + next message. +
+ ) : null} + +
+ ~{ctxTurns.toLocaleString()} turns of context + {' · '} + {activeKind === 'builtin' + ? 'Passed to the engine as --ctx-size at start; changing it restarts the engine.' + : activeKind === 'openai' + ? 'Informational only; your server controls the actual context.' + : "Ollama caps to your model's trained maximum."} +
-
- - - The KV cache scales linearly with context length, so doubling the - context roughly doubles its memory footprint (model weights stay - the same). Benchmark with your hardware before pushing it high.{' '} - - -
-
- - -
- ( - <> -