@ondeinference / cli / commits / 395f87d

Add app detail screen with rename functionality

Seto Elkahfi committed Apr 18, 2026 at 16:58 UTC 395f87d03acd641464f2e79858797164c870c9f4
5 files changed +289 -57
Cargo.lock
+8 -43
@@ -1137,7 +1137,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1137
1138 [[package]]
1139 name = "onde-cli"
1140 -version = "0.1.0"
1140 +version = "0.1.1"
1141 dependencies = [
1142 "anyhow",
1143 "crossterm",
@@ -1148,8 +1148,8 @@ dependencies = [
1148 "ratatui",
1149 "smbcloud-auth-sdk",
1150 "smbcloud-gresiq-sdk",
1151 - "smbcloud-model 0.3.35 (registry+https://github.com/rust-lang/crates.io-index)",
1152 - "smbcloud-network 0.3.35 (registry+https://github.com/rust-lang/crates.io-index)",
1151 + "smbcloud-model",
1152 + "smbcloud-network",
1153 "tokio",
1154 ]
1155
@@ -1758,29 +1758,27 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1758 [[package]]
1759 name = "smbcloud-auth-sdk"
1760 version = "0.3.34"
1761 -source = "registry+https://github.com/rust-lang/crates.io-index"
1762 -checksum = "69ab2f5faa2a3c4588f073ce98e70ac74cfdbbab593c3e2f7d3cebf0b16de72a"
1761 dependencies = [
1762 "base64",
1763 "reqwest",
1764 "serde",
1765 "serde_json",
1766 "sha2",
1769 - "smbcloud-model 0.3.35 (registry+https://github.com/rust-lang/crates.io-index)",
1770 - "smbcloud-network 0.3.35 (registry+https://github.com/rust-lang/crates.io-index)",
1767 + "smbcloud-model",
1768 + "smbcloud-network",
1769 "url-builder",
1770 "uuid",
1771 ]
1772
1773 [[package]]
1774 name = "smbcloud-gresiq-sdk"
1777 -version = "0.1.0"
1775 +version = "0.3.34"
1776 dependencies = [
1777 "log",
1778 "reqwest",
1779 "serde",
1780 "serde_json",
1783 - "smbcloud-network 0.3.35",
1781 + "smbcloud-network",
1782 "thiserror 2.0.18",
1783 "tokio",
1784 ]
@@ -1801,24 +1799,6 @@ dependencies = [
1799 "tsync",
1800 ]
1801
1804 -[[package]]
1805 -name = "smbcloud-model"
1806 -version = "0.3.35"
1807 -source = "registry+https://github.com/rust-lang/crates.io-index"
1808 -checksum = "eeba5e1371b12e9573e6eba49ea99ea990ec1b6befe7bfcafaf41b43e1cf331d"
1809 -dependencies = [
1810 - "chrono",
1811 - "log",
1812 - "serde",
1813 - "serde_json",
1814 - "serde_repr",
1815 - "spinners",
1816 - "strum 0.27.2",
1817 - "strum_macros 0.27.2",
1818 - "thiserror 2.0.18",
1819 - "tsync",
1820 -]
1821 -
1802 [[package]]
1803 name = "smbcloud-network"
1804 version = "0.3.35"
@@ -1828,22 +1808,7 @@ dependencies = [
1808 "reqwest",
1809 "serde",
1810 "serde_json",
1831 - "smbcloud-model 0.3.35",
1832 - "wasm-bindgen",
1833 -]
1834 -
1835 -[[package]]
1836 -name = "smbcloud-network"
1837 -version = "0.3.35"
1838 -source = "registry+https://github.com/rust-lang/crates.io-index"
1839 -checksum = "1c236db19d4cea01cf48b584838fcc898745514c8e01c1c0cad0cba976cabefb"
1840 -dependencies = [
1841 - "clap 4.6.0",
1842 - "log",
1843 - "reqwest",
1844 - "serde",
1845 - "serde_json",
1846 - "smbcloud-model 0.3.35 (registry+https://github.com/rust-lang/crates.io-index)",
1811 + "smbcloud-model",
1812 "wasm-bindgen",
1813 ]
1814
Cargo.toml
+10 -8
@@ -1,6 +1,6 @@
1 [package]
2 name = "onde-cli"
3 -version = "0.1.0"
3 +version = "0.1.1"
4 edition = "2024"
5 description = "Terminal UI for signing up, signing in, and managing your Onde Inference account."
6 license = "MIT OR Apache-2.0"
@@ -25,11 +25,13 @@ log = "0.4"
25 ratatui = "0.29"
26 libc = "0.2"
27 tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
28 -smbcloud-auth-sdk = { version = "0.3" }
29 -# smbcloud-auth-sdk = { path = "../smbcloud-cli/crates/smbcloud-auth-sdk" }
30 -smbcloud-model = { version = "0.3" }
31 -# smbcloud-model = { path = "../smbcloud-cli/crates/smbcloud-model" }
32 -smbcloud-network = { version = "0.3" }
33 -# smbcloud-network = { path = "../smbcloud-cli/crates/smbcloud-network" }
34 -# smbcloud-gresiq-sdk = { version = "0.1" }
28 +smbcloud-auth-sdk = { path = "../smbcloud-cli/crates/smbcloud-auth-sdk" }
29 +smbcloud-model = { path = "../smbcloud-cli/crates/smbcloud-model" }
30 +smbcloud-network = { path = "../smbcloud-cli/crates/smbcloud-network" }
31 smbcloud-gresiq-sdk = { path = "../smbcloud-cli/crates/smbcloud-gresiq-sdk" }
32 +
33 +# To publish to crates.io, switch back to version specs and remove path deps:
34 +# smbcloud-auth-sdk = { version = "0.3" }
35 +# smbcloud-model = { version = "0.3" }
36 +# smbcloud-network = { version = "0.3" }
37 +# smbcloud-gresiq-sdk = { version = "0.1" }
src/app.rs
+118 -4
@@ -31,6 +31,7 @@ fn credentials() -> ClientCredentials<'static> {
31 pub enum Screen {
32 Auth,
33 Apps,
34 + AppDetail,
35 Models,
36 }
37
@@ -88,6 +89,8 @@ pub enum AuthEvent {
89 AppsLoadFailed(String),
90 AppCreated(OndeApp),
91 AppCreateFailed(String),
92 + AppRenamedOk { app_index: usize, new_name: String },
93 + AppRenameFailed(String),
94 // models
95 ModelsLoaded(Vec<OndeModel>),
96 ModelsLoadFailed(String),
@@ -120,6 +123,9 @@ pub struct App {
123 // inline create form
124 pub creating_app: bool,
125 pub new_app_name: String,
126 + // rename form (used on AppDetail screen)
127 + pub renaming_app: bool,
128 + pub rename_input: String,
129 // which app we're picking a model for
130 pub assigning_for_app_index: Option<usize>,
131 }
@@ -146,6 +152,8 @@ impl App {
152 models_loaded: false,
153 creating_app: false,
154 new_app_name: String::new(),
155 + renaming_app: false,
156 + rename_input: String::new(),
157 assigning_for_app_index: None,
158 }
159 }
@@ -200,6 +208,8 @@ impl App {
208 self.models_offset = 0;
209 self.creating_app = false;
210 self.new_app_name.clear();
211 + self.renaming_app = false;
212 + self.rename_input.clear();
213 self.assigning_for_app_index = None;
214 self.status = Status::neutral("Signed out.");
215 }
@@ -228,6 +238,19 @@ impl App {
238 self.busy = false;
239 self.status = Status::error(msg);
240 }
241 + AuthEvent::AppRenamedOk { app_index, new_name } => {
242 + self.busy = false;
243 + self.renaming_app = false;
244 + self.rename_input.clear();
245 + if let Some(onde_app) = self.apps.get_mut(app_index) {
246 + onde_app.name = new_name;
247 + }
248 + self.status = Status::success("App renamed.");
249 + }
250 + AuthEvent::AppRenameFailed(msg) => {
251 + self.busy = false;
252 + self.status = Status::error(msg);
253 + }
254 // models
255 AuthEvent::ModelsLoaded(models) => {
256 self.busy = false;
@@ -241,7 +264,7 @@ impl App {
264 }
265 AuthEvent::ModelAssigned { app_index, model_id } => {
266 self.busy = false;
244 - self.screen = Screen::Apps;
267 + self.screen = Screen::AppDetail;
268 self.assigning_for_app_index = None;
269 if let Some(onde_app) = self.apps.get_mut(app_index) {
270 onde_app.current_model_id = Some(model_id);
@@ -356,6 +379,7 @@ fn handle_key(
379 match app.screen {
380 Screen::Auth => handle_key_auth(app, key, tx),
381 Screen::Apps => handle_key_apps(app, key, tx),
382 + Screen::AppDetail => handle_key_app_detail(app, key, tx),
383 Screen::Models => handle_key_models(app, key, tx),
384 }
385 }
@@ -439,7 +463,15 @@ fn handle_key_apps(
463 clamp_apps_scroll(app, MAX_VISIBLE);
464 }
465 (Enter, _) => {
442 - open_model_picker(app, tx);
466 + if !app.apps.is_empty() {
467 + app.screen = Screen::AppDetail;
468 + app.renaming_app = false;
469 + app.rename_input.clear();
470 + let app_name = app.apps.get(app.apps_cursor)
471 + .map(|a| a.name.as_str())
472 + .unwrap_or("app");
473 + app.status = Status::neutral(format!("{app_name} — m · model r · rename Esc · back"));
474 + }
475 }
476 (Char('n'), KeyModifiers::NONE) => {
477 app.creating_app = true;
@@ -456,6 +488,88 @@ fn handle_key_apps(
488 }
489 }
490
491 +fn handle_key_app_detail(
492 + app: &mut App,
493 + key: crossterm::event::KeyEvent,
494 + tx: mpsc::UnboundedSender<AuthEvent>,
495 +) {
496 + use KeyCode::*;
497 +
498 + if app.renaming_app {
499 + match (key.code, key.modifiers) {
500 + (Esc, _) => {
501 + app.renaming_app = false;
502 + app.rename_input.clear();
503 + app.status = Status::neutral("Rename cancelled.");
504 + }
505 + (Enter, _) => {
506 + submit_rename_app(app, tx);
507 + }
508 + (Backspace, _) => {
509 + app.rename_input.pop();
510 + }
511 + (Char('c'), KeyModifiers::CONTROL) => {
512 + app.should_quit = true;
513 + }
514 + (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
515 + app.rename_input.push(c);
516 + }
517 + _ => {}
518 + }
519 + } else {
520 + match (key.code, key.modifiers) {
521 + (Esc, _) => {
522 + app.screen = Screen::Apps;
523 + app.status = Status::neutral("Back to apps.");
524 + }
525 + (Char('m'), KeyModifiers::NONE) => {
526 + open_model_picker(app, tx);
527 + }
528 + (Char('r'), KeyModifiers::NONE) => {
529 + let current_name = app.apps.get(app.apps_cursor)
530 + .map(|a| a.name.clone())
531 + .unwrap_or_default();
532 + app.rename_input = current_name;
533 + app.renaming_app = true;
534 + app.status = Status::neutral("Edit the name and press Enter.");
535 + }
536 + (Char('s'), KeyModifiers::NONE) => {
537 + sign_out(app, tx);
538 + }
539 + (Char('c'), KeyModifiers::CONTROL) => {
540 + app.should_quit = true;
541 + }
542 + _ => {}
543 + }
544 + }
545 +}
546 +
547 +fn submit_rename_app(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
548 + let name = app.rename_input.trim().to_string();
549 + if name.is_empty() {
550 + app.status = Status::error("Name cannot be empty.");
551 + return;
552 + }
553 + let Some(onde_app) = app.apps.get(app.apps_cursor) else {
554 + return;
555 + };
556 + let token = token::load().unwrap_or_default();
557 + let onde_app_id = onde_app.id.clone();
558 + let app_index = app.apps_cursor;
559 + app.busy = true;
560 + app.status = Status::neutral(format!("Renaming to \"{name}\"\u{2026}"));
561 + tokio::spawn(async move {
562 + match crate::gresiq::rename_app(&token, &onde_app_id, &name).await {
563 + Ok(_) => {
564 + let _ = tx.send(AuthEvent::AppRenamedOk { app_index, new_name: name });
565 + }
566 + Err(e) => {
567 + let _ = tx.send(AuthEvent::AppRenameFailed(e.to_string()));
568 + }
569 + }
570 + });
571 +}
572 +
573 fn handle_key_models(
574 app: &mut App,
575 key: crossterm::event::KeyEvent,
@@ -478,9 +592,9 @@ fn handle_key_models(
592 submit_assign_model(app, tx);
593 }
594 (Esc, _) => {
481 - app.screen = Screen::Apps;
595 + app.screen = Screen::AppDetail;
596 app.assigning_for_app_index = None;
483 - app.status = Status::neutral("Back to apps.");
597 + app.status = Status::neutral("Back to app.");
598 }
599 (Char('c'), KeyModifiers::CONTROL) => {
600 app.should_quit = true;
src/gresiq.rs
+13
@@ -52,3 +52,16 @@ pub async fn assign_model(
52 .await
53 .map_err(|e| anyhow::anyhow!("{e}"))
54 }
55 +
56 +pub async fn rename_app(token: &str, onde_app_id: &str, new_name: &str) -> anyhow::Result<OndeApp> {
57 + smbcloud_gresiq_sdk::rename_app(
58 + &Environment::Production,
59 + crate::app::ONDE_APP_ID,
60 + crate::app::ONDE_APP_SECRET,
61 + token,
62 + onde_app_id,
63 + new_name,
64 + )
65 + .await
66 + .map_err(|e| anyhow::anyhow!("{e}"))
67 +}
src/ui.rs
+140 -2
@@ -79,6 +79,7 @@ fn render_card(frame: &mut Frame, app: &App, area: Rect) {
79 match app.screen {
80 Screen::Auth => render_form(frame, app, inner),
81 Screen::Apps => render_apps(frame, app, inner),
82 + Screen::AppDetail => render_app_detail(frame, app, inner),
83 Screen::Models => render_models(frame, app, inner),
84 }
85 }
@@ -326,7 +327,7 @@ fn render_apps(frame: &mut Frame, app: &App, area: Rect) {
327
328 render_apps_list(frame, app, bottom[0]);
329 frame.render_widget(
329 - Paragraph::new("n · new app Enter · assign model s · sign out")
330 + Paragraph::new("n · new Enter · open s · sign out")
331 .style(Style::new().fg(C_MUTED)),
332 bottom[1],
333 );
@@ -394,6 +395,125 @@ fn resolve_model_name<'a>(app: &'a App, onde_app: &'a OndeApp) -> &'a str {
395 .unwrap_or("–")
396 }
397
398 +// app detail screen
399 +
400 +fn render_app_detail(frame: &mut Frame, app: &App, area: Rect) {
401 + let Some(onde_app) = app.apps.get(app.apps_cursor) else {
402 + return;
403 + };
404 +
405 + let model_name = resolve_model_name(app, onde_app);
406 + let app_id = onde_app.id.as_str();
407 + let app_secret = onde_app.app_secret.as_deref().unwrap_or("–");
408 + let status_str = onde_app.status.as_deref().unwrap_or("–");
409 +
410 + if app.renaming_app {
411 + let rows = Layout::vertical([
412 + Constraint::Length(1), // app name heading
413 + Constraint::Length(1), // spacer
414 + Constraint::Length(1), // status row
415 + Constraint::Length(1), // spacer
416 + Constraint::Length(1), // App ID label
417 + Constraint::Length(1), // App ID value
418 + Constraint::Length(1), // spacer
419 + Constraint::Length(1), // App Secret label
420 + Constraint::Length(1), // App Secret value
421 + Constraint::Length(1), // spacer
422 + Constraint::Length(1), // Model label
423 + Constraint::Length(1), // Model value
424 + Constraint::Length(1), // spacer
425 + Constraint::Length(1), // rename label
426 + Constraint::Length(3), // rename input
427 + Constraint::Length(1), // rename hint
428 + Constraint::Min(0),
429 + ])
430 + .split(area);
431 +
432 + render_app_detail_header(frame, onde_app, status_str, rows[0]);
433 + render_status(frame, app, rows[2]);
434 + render_detail_field(frame, "App ID", app_id, rows[4], rows[5]);
435 + render_detail_field(frame, "App Secret", app_secret, rows[7], rows[8]);
436 + render_detail_field(frame, "Model", model_name, rows[10], rows[11]);
437 +
438 + frame.render_widget(
439 + Paragraph::new("New name:").style(Style::new().fg(C_MUTED)),
440 + rows[13],
441 + );
442 + render_rename_input(frame, &app.rename_input, rows[14]);
443 + frame.render_widget(
444 + Paragraph::new("Enter · save Esc · cancel")
445 + .style(Style::new().fg(C_MUTED)),
446 + rows[15],
447 + );
448 + } else {
449 + let rows = Layout::vertical([
450 + Constraint::Length(1), // app name heading
451 + Constraint::Length(1), // spacer
452 + Constraint::Length(1), // status row
453 + Constraint::Length(1), // spacer
454 + Constraint::Length(1), // App ID label
455 + Constraint::Length(1), // App ID value
456 + Constraint::Length(1), // spacer
457 + Constraint::Length(1), // App Secret label
458 + Constraint::Length(1), // App Secret value
459 + Constraint::Length(1), // spacer
460 + Constraint::Length(1), // Model label
461 + Constraint::Length(1), // Model value
462 + Constraint::Min(0), // spacer
463 + Constraint::Length(1), // hint
464 + ])
465 + .split(area);
466 +
467 + render_app_detail_header(frame, onde_app, status_str, rows[0]);
468 + render_status(frame, app, rows[2]);
469 + render_detail_field(frame, "App ID", app_id, rows[4], rows[5]);
470 + render_detail_field(frame, "App Secret", app_secret, rows[7], rows[8]);
471 + render_detail_field(frame, "Model", model_name, rows[10], rows[11]);
472 +
473 + frame.render_widget(
474 + Paragraph::new("m · assign model r · rename s · sign out Esc · back")
475 + .style(Style::new().fg(C_MUTED)),
476 + rows[13],
477 + );
478 + }
479 +}
480 +
481 +fn render_app_detail_header(frame: &mut Frame, onde_app: &OndeApp, status_str: &str, area: Rect) {
482 + let line = Line::from(vec![
483 + Span::styled(&onde_app.name, Style::new().fg(C_INK).bold()),
484 + Span::styled(" ", Style::new()),
485 + Span::styled(status_str, Style::new().fg(C_MUTED)),
486 + ]);
487 + frame.render_widget(Paragraph::new(line), area);
488 +}
489 +
490 +fn render_detail_field(frame: &mut Frame, label: &str, value: &str, label_area: Rect, value_area: Rect) {
491 + frame.render_widget(
492 + Paragraph::new(label).style(Style::new().fg(C_MUTED)),
493 + label_area,
494 + );
495 + frame.render_widget(
496 + Paragraph::new(value).style(Style::new().fg(C_TEXT).bold()),
497 + value_area,
498 + );
499 +}
500 +
501 +fn render_rename_input(frame: &mut Frame, value: &str, area: Rect) {
502 + let block = Block::new()
503 + .borders(Borders::ALL)
504 + .border_type(BorderType::Rounded)
505 + .border_style(Style::new().fg(C_NEON))
506 + .style(Style::new().bg(C_SURFACE_STRONG));
507 +
508 + let inner = block.inner(area);
509 + frame.render_widget(block, area);
510 + frame.render_widget(Paragraph::new(value).style(Style::new().fg(C_TEXT)), inner);
511 +
512 + let cursor_x = (inner.x + value.chars().count() as u16)
513 + .min(inner.x + inner.width.saturating_sub(1));
514 + frame.set_cursor_position((cursor_x, inner.y));
515 +}
516 +
517 // models screen
518
519 fn render_models(frame: &mut Frame, app: &App, area: Rect) {
@@ -517,7 +637,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
637 Span::styled("↑↓", Style::new().fg(C_NEON)),
638 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
639 Span::styled("Enter", Style::new().fg(C_NEON)),
520 - Span::styled(" · assign model ", Style::new().fg(C_MUTED)),
640 + Span::styled(" · open ", Style::new().fg(C_MUTED)),
641 Span::styled("n", Style::new().fg(C_NEON)),
642 Span::styled(" · new ", Style::new().fg(C_MUTED)),
643 Span::styled("s", Style::new().fg(C_NEON)),
@@ -525,6 +645,24 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
645 Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
646 Span::styled(" · quit", Style::new().fg(C_MUTED)),
647 ],
648 + Screen::AppDetail if app.renaming_app => vec![
649 + Span::styled("Enter", Style::new().fg(C_NEON)),
650 + Span::styled(" · save ", Style::new().fg(C_MUTED)),
651 + Span::styled("Esc", Style::new().fg(C_NEON)),
652 + Span::styled(" · cancel ", Style::new().fg(C_MUTED)),
653 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
654 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
655 + ],
656 + Screen::AppDetail => vec![
657 + Span::styled("m", Style::new().fg(C_NEON)),
658 + Span::styled(" · assign model ", Style::new().fg(C_MUTED)),
659 + Span::styled("r", Style::new().fg(C_NEON)),
660 + Span::styled(" · rename ", Style::new().fg(C_MUTED)),
661 + Span::styled("Esc", Style::new().fg(C_NEON)),
662 + Span::styled(" · back ", Style::new().fg(C_MUTED)),
663 + Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
664 + Span::styled(" · quit", Style::new().fg(C_MUTED)),
665 + ],
666 Screen::Models => vec![
667 Span::styled("↑↓", Style::new().fg(C_NEON)),
668 Span::styled(" · navigate ", Style::new().fg(C_MUTED)),