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