@ondeinference / cli / commits / a20fe81

Fix CI formatting and address review feedback

- Run cargo fmt to fix the CI Format check failures - Hold the freshly registered model by id/name in dedicated fields instead of a models_cursor index, so assignment can't drift onto a different row if the models list is ever reloaded while the publish screen is open - Fix parameter-class detection to require a digit boundary, so a size like 3B can no longer falsely match inside 13B or 43B - Key the apps-list loading indicator off apps_loaded instead of busy, since busy can flip back to false from an unrelated event before the apps fetch actually finishes - Add regression tests for the size-detection fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

keypair34 committed Jul 12, 2026 at 07:49 UTC a20fe81cbd68a0b2f0b7f6423eb6509b4c945081
2 files changed +106 -16
src/app.rs
+94 -10
@@ -307,6 +307,11 @@ pub struct App {
307 pub rename_input: String,
308 // which app we're picking a model for
309 pub assigning_for_app_index: Option<usize>,
310 + // the model just registered from GgufDetail, pending assignment on the
311 + // PublishModel screen. Held by id/name directly (not a models_cursor
312 + // index) so a later models reload can never shift it onto a different row.
313 + pub publish_model_id: Option<String>,
314 + pub publish_model_name: Option<String>,
315 // local HF cache downloads merged with remote catalog
316 pub downloads: Vec<crate::hf::MergedModel>,
317 pub downloads_cursor: usize,
@@ -397,6 +402,8 @@ impl App {
402 renaming_app: false,
403 rename_input: String::new(),
404 assigning_for_app_index: None,
405 + publish_model_id: None,
406 + publish_model_name: None,
407 downloads: Vec::new(),
408 downloads_cursor: 0,
409 downloads_offset: 0,
@@ -610,13 +617,18 @@ impl App {
617 .clone()
618 .or_else(|| model.hf_repo_id.clone())
619 .unwrap_or_else(|| "Model".to_string());
620 + // Held by id/name directly rather than a models_cursor index,
621 + // so this can't drift onto a different row if app.models is
622 + // ever reloaded/reordered while this screen is open.
623 + self.publish_model_id = Some(model.id.clone());
624 + self.publish_model_name = Some(model_name.clone());
625 self.models.push(model);
614 - self.models_cursor = self.models.len() - 1;
626 self.screen = Screen::PublishModel;
627 self.apps_cursor = 0;
628 self.apps_offset = 0;
618 - self.status =
619 - Status::success(format!("{model_name} registered. Pick an app to assign it to."));
629 + self.status = Status::success(format!(
630 + "{model_name} registered. Pick an app to assign it to."
631 + ));
632 }
633 AuthEvent::ModelRegisterFailed(msg) => {
634 self.busy = false;
@@ -1763,7 +1775,10 @@ fn trigger_publish_model(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1775 }
1776 let gguf_file = gguf.file_name.clone();
1777 let approx_size_bytes = std::fs::metadata(&gguf.path).ok().map(|m| m.len() as i64);
1766 - let base_model_id = app.current_project.as_ref().map(|p| p.base_model_id.clone());
1778 + let base_model_id = app
1779 + .current_project
1780 + .as_ref()
1781 + .map(|p| p.base_model_id.clone());
1782 let (base_name, family, parameter_class) = derive_base_model_meta(base_model_id.as_deref());
1783 let display_name = format!("{base_name} (Fine-tuned)");
1784 let token = token::load().unwrap_or_default();
@@ -1798,6 +1813,24 @@ fn trigger_publish_model(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1813 });
1814 }
1815
1816 +/// True if `size` (e.g. "3B") appears in `haystack` as its own token, not as
1817 +/// the tail of a larger number -- "3B" is a plain substring of "13B"/"43B",
1818 +/// so a naive `.contains()` would misdetect those as "3B".
1819 +fn contains_size_token(haystack: &str, size: &str) -> bool {
1820 + let mut start = 0;
1821 + while let Some(idx) = haystack[start..].find(size) {
1822 + let abs_idx = start + idx;
1823 + let preceded_by_digit = haystack.as_bytes()[..abs_idx]
1824 + .last()
1825 + .is_some_and(u8::is_ascii_digit);
1826 + if !preceded_by_digit {
1827 + return true;
1828 + }
1829 + start = abs_idx + 1;
1830 + }
1831 + false
1832 +}
1833 +
1834 /// Best-effort display name / family / parameter class for a fine-tune,
1835 /// derived from the base model it was trained from. Falls back to generic
1836 /// values when the base model isn't one of the officially supported ones.
@@ -1817,6 +1850,9 @@ fn derive_base_model_meta(base_model_id: Option<&str>) -> (String, String, Strin
1850 });
1851
1852 let haystack = format!("{base_model_id} {base_name}").to_lowercase();
1853 + // Checked in this order (most-specific first) because "qwen2.5-coder"
1854 + // also contains the substring "qwen2.5" — reordering these would make
1855 + // coder models misclassified as plain qwen2.5.
1856 let family = if haystack.contains("qwen3") {
1857 "qwen3"
1858 } else if haystack.contains("qwen2.5-coder") || haystack.contains("qwen2_5-coder") {
@@ -1832,7 +1868,7 @@ fn derive_base_model_meta(base_model_id: Option<&str>) -> (String, String, Strin
1868 let haystack_upper = haystack.to_uppercase();
1869 let parameter_class = SIZES
1870 .iter()
1835 - .find(|s| haystack_upper.contains(*s))
1871 + .find(|s| contains_size_token(&haystack_upper, s))
1872 .map(|s| s.to_string())
1873 .unwrap_or_else(|| "unknown".to_string());
1874
@@ -1858,10 +1894,11 @@ fn handle_key_publish_model(
1894 clamp_apps_scroll(app, MAX_VISIBLE);
1895 }
1896 (Enter, _) if !app.apps.is_empty() => {
1861 - app.assigning_for_app_index = Some(app.apps_cursor);
1862 - submit_assign_model(app, tx);
1897 + submit_assign_published_model(app, tx);
1898 }
1899 (Esc, _) => {
1900 + app.publish_model_id = None;
1901 + app.publish_model_name = None;
1902 app.screen = Screen::GgufDetail;
1903 app.status = Status::neutral("Model registered — assign it later from Apps.");
1904 }
@@ -1872,6 +1909,47 @@ fn handle_key_publish_model(
1909 }
1910 }
1911
1912 +/// Assigns the model held in `app.publish_model_id` to the app currently
1913 +/// selected in the picker. Reads the target model by id, not by indexing
1914 +/// into `app.models` with a cursor, so it can't silently assign the wrong
1915 +/// row if the models list is ever reloaded or reordered while this screen
1916 +/// is open.
1917 +fn submit_assign_published_model(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
1918 + if app.busy {
1919 + return;
1920 + }
1921 + let Some(model_id) = app.publish_model_id.clone() else {
1922 + return;
1923 + };
1924 + let Some(onde_app) = app.apps.get(app.apps_cursor) else {
1925 + return;
1926 + };
1927 + let token = token::load().unwrap_or_default();
1928 + let onde_app_id = onde_app.id.clone();
1929 + let app_index = app.apps_cursor;
1930 + let model_name = app
1931 + .publish_model_name
1932 + .clone()
1933 + .unwrap_or_else(|| model_id.clone());
1934 + app.busy = true;
1935 + app.publish_model_id = None;
1936 + app.publish_model_name = None;
1937 + app.status = Status::neutral(format!("Assigning {model_name}…"));
1938 + tokio::spawn(async move {
1939 + match crate::gresiq::assign_model(&token, &onde_app_id, &model_id).await {
1940 + Ok(()) => {
1941 + let _ = tx.send(AuthEvent::ModelAssigned {
1942 + app_index,
1943 + model_id,
1944 + });
1945 + }
1946 + Err(e) => {
1947 + let _ = tx.send(AuthEvent::ModelAssignFailed(e.to_string()));
1948 + }
1949 + }
1950 + });
1951 +}
1952 +
1953 fn format_adapter_size(bytes: u64) -> String {
1954 if bytes >= 1_000_000_000 {
1955 format!("{:.1}GB", bytes as f64 / 1e9)
@@ -3009,9 +3087,8 @@ mod tests {
3087
3088 #[test]
3089 fn test_derive_base_model_meta_known_qwen25_coder() {
3012 - let (_, family, parameter_class) = derive_base_model_meta(Some(
3013 - "bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF",
3014 - ));
3090 + let (_, family, parameter_class) =
3091 + derive_base_model_meta(Some("bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF"));
3092 assert_eq!(family, "qwen2.5-coder");
3093 assert_eq!(parameter_class, "1.5B");
3094 }
@@ -3031,4 +3108,11 @@ mod tests {
3108 assert_eq!(parameter_class, "unknown");
3109 assert_eq!(name, "Custom model");
3110 }
3111 +
3112 + #[test]
3113 + fn test_derive_base_model_meta_larger_size_not_shadowed_by_shorter_substring() {
3114 + // "13B" contains the substring "3B" -- this must not resolve to "3B".
3115 + let (_, _, parameter_class) = derive_base_model_meta(Some("someone/Mystery-13B-GGUF"));
3116 + assert_eq!(parameter_class, "unknown");
3117 + }
3118 }
src/ui.rs
+12 -6
@@ -395,12 +395,16 @@ fn render_apps(frame: &mut Frame, app: &App, area: Rect) {
395
396 fn render_apps_list(frame: &mut Frame, app: &App, area: Rect) {
397 if app.apps.is_empty() {
398 - if app.busy {
398 + // Keyed on apps_loaded rather than busy: busy can flip back to
399 + // false from an unrelated concurrent event (e.g. a model
400 + // registration finishing before the apps fetch does) while the
401 + // apps list is still genuinely in flight.
402 + if !app.apps_loaded {
403 frame.render_widget(
404 Paragraph::new(" Loading…").style(Style::new().fg(C_MUTED)),
405 area,
406 );
403 - } else if app.apps_loaded {
407 + } else {
408 frame.render_widget(
409 Paragraph::new(" No apps yet. Press n to create one.")
410 .style(Style::new().fg(C_MUTED)),
@@ -466,9 +470,8 @@ fn resolve_model_name<'a>(app: &'a App, onde_app: &'a OndeApp) -> &'a str {
470
471 fn render_publish_model(frame: &mut Frame, app: &App, area: Rect) {
472 let model_name = app
469 - .models
470 - .get(app.models_cursor)
471 - .and_then(|m| m.name.clone())
473 + .publish_model_name
474 + .clone()
475 .unwrap_or_else(|| "model".to_string());
476 let heading = format!("Assign {model_name} to app");
477
@@ -2661,7 +2664,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
2664 }
2665 if uploaded {
2666 keys.push(Span::styled("a", Style::new().fg(C_NEON)));
2664 - keys.push(Span::styled(" · assign to app ", Style::new().fg(C_MUTED)));
2667 + keys.push(Span::styled(
2668 + " · assign to app ",
2669 + Style::new().fg(C_MUTED),
2670 + ));
2671 }
2672 keys.push(Span::styled("c", Style::new().fg(C_NEON)));
2673 keys.push(Span::styled(" · chat ", Style::new().fg(C_MUTED)));