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