Integrate gresiq SDK and add app/model management UI
Seto Elkahfi committed
Apr 18, 2026 at 14:13 UTC
09e2fa15ff6bd61a4f6d4c7d81905484d489c1fc
6 files changed
+788
-185
Cargo.lock
+59
-5
@@ -740,6 +740,7 @@ dependencies = [
740
"tokio",
741
"tokio-rustls",
742
"tower-service",
743
+ "webpki-roots",
744
]
745
746
[[package]]
@@ -1146,8 +1147,9 @@ dependencies = [
1147
"log",
1148
"ratatui",
1149
"smbcloud-auth-sdk",
1149
- "smbcloud-model",
1150
- "smbcloud-network",
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)",
1153
"tokio",
1154
]
1155
@@ -1464,6 +1466,7 @@ dependencies = [
1466
"wasm-bindgen",
1467
"wasm-bindgen-futures",
1468
"web-sys",
1469
+ "webpki-roots",
1470
]
1471
1472
[[package]]
@@ -1763,12 +1766,41 @@ dependencies = [
1766
"serde",
1767
"serde_json",
1768
"sha2",
1766
- "smbcloud-model",
1767
- "smbcloud-network",
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)",
1771
"url-builder",
1772
"uuid",
1773
]
1774
1775
+[[package]]
1776
+name = "smbcloud-gresiq-sdk"
1777
+version = "0.1.0"
1778
+dependencies = [
1779
+ "log",
1780
+ "reqwest",
1781
+ "serde",
1782
+ "serde_json",
1783
+ "smbcloud-network 0.3.35",
1784
+ "thiserror 2.0.18",
1785
+ "tokio",
1786
+]
1787
+
1788
+[[package]]
1789
+name = "smbcloud-model"
1790
+version = "0.3.35"
1791
+dependencies = [
1792
+ "chrono",
1793
+ "log",
1794
+ "serde",
1795
+ "serde_json",
1796
+ "serde_repr",
1797
+ "spinners",
1798
+ "strum 0.27.2",
1799
+ "strum_macros 0.27.2",
1800
+ "thiserror 2.0.18",
1801
+ "tsync",
1802
+]
1803
+
1804
[[package]]
1805
name = "smbcloud-model"
1806
version = "0.3.35"
@@ -1787,6 +1819,19 @@ dependencies = [
1819
"tsync",
1820
]
1821
1822
+[[package]]
1823
+name = "smbcloud-network"
1824
+version = "0.3.35"
1825
+dependencies = [
1826
+ "clap 4.6.0",
1827
+ "log",
1828
+ "reqwest",
1829
+ "serde",
1830
+ "serde_json",
1831
+ "smbcloud-model 0.3.35",
1832
+ "wasm-bindgen",
1833
+]
1834
+
1835
[[package]]
1836
name = "smbcloud-network"
1837
version = "0.3.35"
@@ -1798,7 +1843,7 @@ dependencies = [
1843
"reqwest",
1844
"serde",
1845
"serde_json",
1801
- "smbcloud-model",
1846
+ "smbcloud-model 0.3.35 (registry+https://github.com/rust-lang/crates.io-index)",
1847
"wasm-bindgen",
1848
]
1849
@@ -2507,6 +2552,15 @@ dependencies = [
2552
"wasm-bindgen",
2553
]
2554
2555
+[[package]]
2556
+name = "webpki-roots"
2557
+version = "1.0.7"
2558
+source = "registry+https://github.com/rust-lang/crates.io-index"
2559
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
2560
+dependencies = [
2561
+ "rustls-pki-types",
2562
+]
2563
+
2564
[[package]]
2565
name = "winapi"
2566
version = "0.3.9"
Cargo.toml
+2
@@ -31,3 +31,5 @@ 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" }
35
+smbcloud-gresiq-sdk = { path = "../smbcloud-cli/crates/smbcloud-gresiq-sdk" }
src/app.rs
+368
-70
@@ -13,19 +13,26 @@ use {
13
tokio::sync::mpsc,
14
};
15
16
-// app credentials — Onde is a tenant in smbCloud Auth
16
+// ui.rs imports OndeApp and OndeModel from here instead of depending on the SDK directly.
17
+pub use smbcloud_gresiq_sdk::{OndeApp, OndeModel};
18
18
-const APP_ID: &str = "e1098adf-9859-43bf-ae98-514cf66252a5";
19
-const APP_SECRET: &str = "5bc60b32-c072-40ce-a91f-b289fdee075d";
19
+// Onde is a tenant in smbCloud Auth. These identify this CLI to the backend.
20
+pub(crate) const ONDE_APP_ID: &str = "e1098adf-9859-43bf-ae98-514cf66252a5";
21
+pub(crate) const ONDE_APP_SECRET: &str = "5bc60b32-c072-40ce-a91f-b289fdee075d";
22
23
fn credentials() -> ClientCredentials<'static> {
24
ClientCredentials {
23
- app_id: APP_ID,
24
- app_secret: APP_SECRET,
25
+ app_id: ONDE_APP_ID,
26
+ app_secret: ONDE_APP_SECRET,
27
}
28
}
29
28
-// types
30
+#[derive(Debug, Clone, PartialEq)]
31
+pub enum Screen {
32
+ Auth,
33
+ Apps,
34
+ Models,
35
+}
36
37
#[derive(Debug, Clone, PartialEq)]
38
pub enum Mode {
@@ -54,22 +61,13 @@ pub struct Status {
61
62
impl Status {
63
pub fn neutral(msg: impl Into<String>) -> Self {
57
- Self {
58
- tone: StatusTone::Neutral,
59
- message: msg.into(),
60
- }
64
+ Self { tone: StatusTone::Neutral, message: msg.into() }
65
}
66
pub fn success(msg: impl Into<String>) -> Self {
63
- Self {
64
- tone: StatusTone::Success,
65
- message: msg.into(),
66
- }
67
+ Self { tone: StatusTone::Success, message: msg.into() }
68
}
69
pub fn error(msg: impl Into<String>) -> Self {
69
- Self {
70
- tone: StatusTone::Error,
71
- message: msg.into(),
72
- }
70
+ Self { tone: StatusTone::Error, message: msg.into() }
71
}
72
}
73
@@ -79,16 +77,26 @@ pub struct Profile {
77
}
78
79
pub enum AuthEvent {
80
+ // auth
81
SignupOk(String),
82
SigninOk(Profile),
83
ProfileOk(Profile),
84
SignedOut,
85
Failed(String),
86
+ // apps
87
+ AppsLoaded(Vec<OndeApp>),
88
+ AppsLoadFailed(String),
89
+ AppCreated(OndeApp),
90
+ AppCreateFailed(String),
91
+ // models
92
+ ModelsLoaded(Vec<OndeModel>),
93
+ ModelsLoadFailed(String),
94
+ ModelAssigned { app_index: usize, model_id: String },
95
+ ModelAssignFailed(String),
96
}
97
89
-// app state
90
-
98
pub struct App {
99
+ // auth
100
pub mode: Mode,
101
pub email: String,
102
pub password: String,
@@ -97,6 +105,23 @@ pub struct App {
105
pub busy: bool,
106
pub profile: Option<Profile>,
107
pub should_quit: bool,
108
+ // navigation
109
+ pub screen: Screen,
110
+ // apps list
111
+ pub apps: Vec<OndeApp>,
112
+ pub apps_cursor: usize,
113
+ pub apps_offset: usize,
114
+ pub apps_loaded: bool,
115
+ // models list
116
+ pub models: Vec<OndeModel>,
117
+ pub models_cursor: usize,
118
+ pub models_offset: usize,
119
+ pub models_loaded: bool,
120
+ // inline create form
121
+ pub creating_app: bool,
122
+ pub new_app_name: String,
123
+ // which app we're picking a model for
124
+ pub assigning_for_app_index: Option<usize>,
125
}
126
127
impl App {
@@ -110,6 +135,18 @@ impl App {
135
busy: false,
136
profile: None,
137
should_quit: false,
138
+ screen: Screen::Auth,
139
+ apps: Vec::new(),
140
+ apps_cursor: 0,
141
+ apps_offset: 0,
142
+ apps_loaded: false,
143
+ models: Vec::new(),
144
+ models_cursor: 0,
145
+ models_offset: 0,
146
+ models_loaded: false,
147
+ creating_app: false,
148
+ new_app_name: String::new(),
149
+ assigning_for_app_index: None,
150
}
151
}
152
@@ -133,6 +170,7 @@ impl App {
170
pub fn apply(&mut self, event: AuthEvent) {
171
self.busy = false;
172
match event {
173
+ // auth
174
AuthEvent::SignupOk(message) => {
175
self.password.clear();
176
self.status = Status::success(message);
@@ -140,44 +178,104 @@ impl App {
178
AuthEvent::SigninOk(profile) => {
179
self.password.clear();
180
self.profile = Some(profile);
181
+ self.screen = Screen::Apps;
182
self.status = Status::success("You're in.");
183
}
184
AuthEvent::ProfileOk(profile) => {
185
self.profile = Some(profile);
186
+ self.screen = Screen::Apps;
187
self.status = Status::success("Still signed in.");
188
}
189
AuthEvent::SignedOut => {
190
token::clear();
191
self.profile = None;
192
+ self.screen = Screen::Auth;
193
+ self.apps.clear();
194
+ self.apps_loaded = false;
195
+ self.apps_cursor = 0;
196
+ self.apps_offset = 0;
197
+ self.models.clear();
198
+ self.models_loaded = false;
199
+ self.models_cursor = 0;
200
+ self.models_offset = 0;
201
+ self.creating_app = false;
202
+ self.new_app_name.clear();
203
+ self.assigning_for_app_index = None;
204
self.status = Status::neutral("Signed out.");
205
}
206
AuthEvent::Failed(message) => {
207
self.status = Status::error(message);
208
}
209
+ // apps
210
+ AuthEvent::AppsLoaded(apps) => {
211
+ self.busy = false;
212
+ self.apps = apps;
213
+ self.apps_loaded = true;
214
+ self.status = Status::success("Apps loaded.");
215
+ }
216
+ AuthEvent::AppsLoadFailed(msg) => {
217
+ self.busy = false;
218
+ self.status = Status::error(msg);
219
+ }
220
+ AuthEvent::AppCreated(app) => {
221
+ self.busy = false;
222
+ self.apps.insert(0, app);
223
+ self.creating_app = false;
224
+ self.new_app_name.clear();
225
+ self.status = Status::success("App created.");
226
+ }
227
+ AuthEvent::AppCreateFailed(msg) => {
228
+ self.busy = false;
229
+ self.status = Status::error(msg);
230
+ }
231
+ // models
232
+ AuthEvent::ModelsLoaded(models) => {
233
+ self.busy = false;
234
+ self.models = models;
235
+ self.models_loaded = true;
236
+ self.status = Status::neutral("Choose a model.");
237
+ }
238
+ AuthEvent::ModelsLoadFailed(msg) => {
239
+ self.busy = false;
240
+ self.status = Status::error(msg);
241
+ }
242
+ AuthEvent::ModelAssigned { app_index, model_id } => {
243
+ self.busy = false;
244
+ self.screen = Screen::Apps;
245
+ self.assigning_for_app_index = None;
246
+ if let Some(onde_app) = self.apps.get_mut(app_index) {
247
+ onde_app.current_model_id = Some(model_id);
248
+ }
249
+ self.status = Status::success("Model assigned.");
250
+ }
251
+ AuthEvent::ModelAssignFailed(msg) => {
252
+ self.busy = false;
253
+ self.status = Status::error(msg);
254
+ }
255
}
256
}
257
}
258
161
-// event loop
162
-
259
pub async fn run(terminal: &mut DefaultTerminal) -> Result<()> {
260
let mut app = App::new();
261
let (tx, mut rx) = mpsc::unbounded_channel::<AuthEvent>();
262
let mut events = EventStream::new();
263
168
- // pick up where we left off if there's a saved token
264
+ // pick up where we left off if there is a saved token
265
if let Some(saved_token) = token::load() {
266
app.busy = true;
267
app.status = Status::neutral("Restoring session…");
172
- let tx = tx.clone();
268
+ let tx2 = tx.clone();
269
tokio::spawn(async move {
270
match me_with_client(Environment::Production, credentials(), &saved_token).await {
271
Ok(user) => {
176
- let _ = tx.send(AuthEvent::ProfileOk(Profile { email: user.email }));
272
+ let _ = tx2.send(AuthEvent::ProfileOk(Profile { email: user.email }));
273
}
274
Err(_) => {
275
token::clear();
180
- let _ = tx.send(AuthEvent::Failed("Session expired. Sign in again.".into()));
276
+ let _ = tx2.send(AuthEvent::Failed(
277
+ "Session expired. Sign in again.".into(),
278
+ ));
279
}
280
}
281
});
@@ -199,6 +297,14 @@ pub async fn run(terminal: &mut DefaultTerminal) -> Result<()> {
297
maybe = rx.recv() => {
298
if let Some(event) = maybe {
299
app.apply(event);
300
+ // kick off apps loading whenever we land on the Apps screen fresh
301
+ if app.profile.is_some()
302
+ && app.screen == Screen::Apps
303
+ && !app.apps_loaded
304
+ && !app.busy
305
+ {
306
+ trigger_load_apps(&mut app, tx.clone());
307
+ }
308
}
309
}
310
}
@@ -211,7 +317,26 @@ pub async fn run(terminal: &mut DefaultTerminal) -> Result<()> {
317
Ok(())
318
}
319
214
-// key handling
320
+fn trigger_load_apps(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
321
+ if app.busy {
322
+ return;
323
+ }
324
+ let token = token::load().unwrap_or_default();
325
+ app.busy = true;
326
+ app.status = Status::neutral("Loading apps…");
327
+ tokio::spawn(async move {
328
+ match crate::gresiq::load_apps(&token).await {
329
+ Ok(apps) => {
330
+ let _ = tx.send(AuthEvent::AppsLoaded(apps));
331
+ }
332
+ Err(e) => {
333
+ let _ = tx.send(AuthEvent::AppsLoadFailed(e.to_string()));
334
+ }
335
+ }
336
+ });
337
+}
338
+
339
+const MAX_VISIBLE: usize = 8;
340
341
fn handle_key(
342
app: &mut App,
@@ -220,72 +345,244 @@ fn handle_key(
345
) {
346
use KeyCode::*;
347
223
- // don't process input while a request is in flight — only quit
348
+ // while a request is in flight only Ctrl+C is allowed
349
if app.busy {
225
- if matches!(
226
- (key.code, key.modifiers),
227
- (Char('c'), KeyModifiers::CONTROL)
228
- ) {
350
+ if matches!((key.code, key.modifiers), (Char('c'), KeyModifiers::CONTROL)) {
351
app.should_quit = true;
352
}
353
return;
354
}
355
356
+ match app.screen {
357
+ Screen::Auth => handle_key_auth(app, key, tx),
358
+ Screen::Apps => handle_key_apps(app, key, tx),
359
+ Screen::Models => handle_key_models(app, key, tx),
360
+ }
361
+}
362
+
363
+fn handle_key_auth(
364
+ app: &mut App,
365
+ key: crossterm::event::KeyEvent,
366
+ tx: mpsc::UnboundedSender<AuthEvent>,
367
+) {
368
+ use KeyCode::*;
369
+
370
match (key.code, key.modifiers) {
235
- // Quit
371
(Char('c'), KeyModifiers::CONTROL) | (Esc, _) => {
372
app.should_quit = true;
373
}
239
-
240
- // Tab: cycle focus between fields (only on auth form)
241
- (Tab, _) if app.profile.is_none() => {
374
+ (Tab, _) => {
375
app.focus = match app.focus {
376
Focus::Email => Focus::Password,
377
Focus::Password => Focus::Email,
378
};
379
}
247
-
248
- // Mode switching
249
- (Char('l'), KeyModifiers::CONTROL) if app.profile.is_none() => {
380
+ (Char('l'), KeyModifiers::CONTROL) => {
381
app.switch_mode(Mode::Signin);
382
}
252
- (Char('n'), KeyModifiers::CONTROL) if app.profile.is_none() => {
383
+ (Char('n'), KeyModifiers::CONTROL) => {
384
app.switch_mode(Mode::Signup);
385
}
255
-
256
- // Enter: submit form or sign out
386
(Enter, _) => {
258
- if app.profile.is_some() {
387
+ submit(app, tx);
388
+ }
389
+ (Backspace, _) => match app.focus {
390
+ Focus::Email => { app.email.pop(); }
391
+ Focus::Password => { app.password.pop(); }
392
+ },
393
+ (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => match app.focus {
394
+ Focus::Email => app.email.push(c),
395
+ Focus::Password => app.password.push(c),
396
+ },
397
+ _ => {}
398
+ }
399
+}
400
+
401
+fn handle_key_apps(
402
+ app: &mut App,
403
+ key: crossterm::event::KeyEvent,
404
+ tx: mpsc::UnboundedSender<AuthEvent>,
405
+) {
406
+ use KeyCode::*;
407
+
408
+ if app.creating_app {
409
+ match (key.code, key.modifiers) {
410
+ (Esc, _) => {
411
+ app.creating_app = false;
412
+ app.new_app_name.clear();
413
+ app.status = app.idle_status();
414
+ }
415
+ (Enter, _) => {
416
+ submit_create_app(app, tx);
417
+ }
418
+ (Backspace, _) => {
419
+ app.new_app_name.pop();
420
+ }
421
+ (Char('c'), KeyModifiers::CONTROL) => {
422
+ app.should_quit = true;
423
+ }
424
+ (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
425
+ app.new_app_name.push(c);
426
+ }
427
+ _ => {}
428
+ }
429
+ } else {
430
+ match (key.code, key.modifiers) {
431
+ (Up, _) | (Char('k'), KeyModifiers::NONE) => {
432
+ app.apps_cursor = app.apps_cursor.saturating_sub(1);
433
+ clamp_apps_scroll(app, MAX_VISIBLE);
434
+ }
435
+ (Down, _) | (Char('j'), KeyModifiers::NONE) => {
436
+ if app.apps_cursor + 1 < app.apps.len() {
437
+ app.apps_cursor += 1;
438
+ }
439
+ clamp_apps_scroll(app, MAX_VISIBLE);
440
+ }
441
+ (Enter, _) => {
442
+ open_model_picker(app, tx);
443
+ }
444
+ (Char('n'), KeyModifiers::NONE) => {
445
+ app.creating_app = true;
446
+ app.status = Status::neutral("Type a name and press Enter.");
447
+ }
448
+ (Char('s'), KeyModifiers::NONE) | (Char('s'), KeyModifiers::CONTROL) => {
449
sign_out(app, tx);
260
- } else {
261
- submit(app, tx);
450
}
451
+ (Esc, _) | (Char('c'), KeyModifiers::CONTROL) => {
452
+ app.should_quit = true;
453
+ }
454
+ _ => {}
455
}
456
+ }
457
+}
458
265
- // Backspace
266
- (Backspace, _) if app.profile.is_none() => match app.focus {
267
- Focus::Email => {
268
- app.email.pop();
459
+fn handle_key_models(
460
+ app: &mut App,
461
+ key: crossterm::event::KeyEvent,
462
+ tx: mpsc::UnboundedSender<AuthEvent>,
463
+) {
464
+ use KeyCode::*;
465
+
466
+ match (key.code, key.modifiers) {
467
+ (Up, _) | (Char('k'), KeyModifiers::NONE) => {
468
+ app.models_cursor = app.models_cursor.saturating_sub(1);
469
+ clamp_models_scroll(app, MAX_VISIBLE);
470
+ }
471
+ (Down, _) | (Char('j'), KeyModifiers::NONE) => {
472
+ if app.models_cursor + 1 < app.models.len() {
473
+ app.models_cursor += 1;
474
}
270
- Focus::Password => {
271
- app.password.pop();
475
+ clamp_models_scroll(app, MAX_VISIBLE);
476
+ }
477
+ (Enter, _) => {
478
+ submit_assign_model(app, tx);
479
+ }
480
+ (Esc, _) => {
481
+ app.screen = Screen::Apps;
482
+ app.assigning_for_app_index = None;
483
+ app.status = Status::neutral("Back to apps.");
484
+ }
485
+ (Char('c'), KeyModifiers::CONTROL) => {
486
+ app.should_quit = true;
487
+ }
488
+ _ => {}
489
+ }
490
+}
491
+
492
+fn clamp_apps_scroll(app: &mut App, max_visible: usize) {
493
+ if app.apps_cursor < app.apps_offset {
494
+ app.apps_offset = app.apps_cursor;
495
+ } else if app.apps_cursor >= app.apps_offset + max_visible {
496
+ app.apps_offset = app.apps_cursor + 1 - max_visible;
497
+ }
498
+}
499
+
500
+fn clamp_models_scroll(app: &mut App, max_visible: usize) {
501
+ if app.models_cursor < app.models_offset {
502
+ app.models_offset = app.models_cursor;
503
+ } else if app.models_cursor >= app.models_offset + max_visible {
504
+ app.models_offset = app.models_cursor + 1 - max_visible;
505
+ }
506
+}
507
+
508
+fn open_model_picker(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
509
+ if app.apps.is_empty() || app.busy {
510
+ return;
511
+ }
512
+ app.assigning_for_app_index = Some(app.apps_cursor);
513
+ app.screen = Screen::Models;
514
+ app.models_cursor = 0;
515
+ app.models_offset = 0;
516
+ if !app.models_loaded {
517
+ let token = token::load().unwrap_or_default();
518
+ app.busy = true;
519
+ app.status = Status::neutral("Loading models…");
520
+ tokio::spawn(async move {
521
+ match crate::gresiq::load_models(&token).await {
522
+ Ok(models) => {
523
+ let _ = tx.send(AuthEvent::ModelsLoaded(models));
524
+ }
525
+ Err(e) => {
526
+ let _ = tx.send(AuthEvent::ModelsLoadFailed(e.to_string()));
527
+ }
528
}
273
- },
529
+ });
530
+ } else {
531
+ app.status = Status::neutral("Choose a model.");
532
+ }
533
+}
534
275
- // Regular character input
276
- (Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) if app.profile.is_none() => {
277
- match app.focus {
278
- Focus::Email => app.email.push(c),
279
- Focus::Password => app.password.push(c),
535
+fn submit_create_app(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
536
+ let name = app.new_app_name.trim().to_string();
537
+ if name.is_empty() {
538
+ return;
539
+ }
540
+ let token = token::load().unwrap_or_default();
541
+ app.busy = true;
542
+ app.status = Status::neutral(format!("Creating “{name}”…"));
543
+ tokio::spawn(async move {
544
+ match crate::gresiq::create_app(&token, &name).await {
545
+ Ok(created) => {
546
+ let _ = tx.send(AuthEvent::AppCreated(created));
547
+ }
548
+ Err(e) => {
549
+ let _ = tx.send(AuthEvent::AppCreateFailed(e.to_string()));
550
}
551
}
552
+ });
553
+}
554
283
- _ => {}
555
+fn submit_assign_model(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
556
+ if app.busy {
557
+ return;
558
}
559
+ let Some(app_index) = app.assigning_for_app_index else {
560
+ return;
561
+ };
562
+ let Some(onde_app) = app.apps.get(app_index) else {
563
+ return;
564
+ };
565
+ let Some(model) = app.models.get(app.models_cursor) else {
566
+ return;
567
+ };
568
+ let token = token::load().unwrap_or_default();
569
+ let onde_app_id = onde_app.id.clone();
570
+ let model_id = model.id.clone();
571
+ let model_name = model.name.clone().unwrap_or_else(|| model_id.clone());
572
+ app.busy = true;
573
+ app.status = Status::neutral(format!("Assigning {model_name}…"));
574
+ tokio::spawn(async move {
575
+ match crate::gresiq::assign_model(&token, &onde_app_id, &model_id).await {
576
+ Ok(()) => {
577
+ let _ = tx.send(AuthEvent::ModelAssigned { app_index, model_id });
578
+ }
579
+ Err(e) => {
580
+ let _ = tx.send(AuthEvent::ModelAssignFailed(e.to_string()));
581
+ }
582
+ }
583
+ });
584
}
585
287
-// auth actions
288
-
586
fn submit(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
587
let email = app.email.trim().to_string();
588
let password = app.password.clone();
@@ -306,8 +603,13 @@ fn submit(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
603
tokio::spawn(async move {
604
let event = match mode {
605
Mode::Signup => {
309
- match signup_with_client(Environment::Production, credentials(), email, password)
310
- .await
606
+ match signup_with_client(
607
+ Environment::Production,
608
+ credentials(),
609
+ email,
610
+ password,
611
+ )
612
+ .await
613
{
614
Ok(result) => AuthEvent::SignupOk(result.message),
615
Err(e) => AuthEvent::Failed(extract_error(&e)),
@@ -326,7 +628,6 @@ fn submit(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
628
if let Err(e) = token::save(&access_token) {
629
log::warn!("Could not persist token: {e}");
630
}
329
- // Fetch profile; fall back to the email typed by the user
631
let profile_email = match me_with_client(
632
Environment::Production,
633
credentials(),
@@ -337,16 +638,14 @@ fn submit(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
638
Ok(user) => user.email,
639
Err(_) => email,
640
};
340
- AuthEvent::SigninOk(Profile {
341
- email: profile_email,
342
- })
641
+ AuthEvent::SigninOk(Profile { email: profile_email })
642
}
643
Ok(AccountStatus::Incomplete { .. }) => AuthEvent::Failed(
644
"Check your email first — we sent a confirmation link when you signed up."
645
.into(),
646
),
647
Ok(AccountStatus::NotFound) => {
349
- AuthEvent::Failed("That email isn't in our system.".into())
648
+ AuthEvent::Failed("That email isn’t in our system.".into())
649
}
650
Err(e) => AuthEvent::Failed(extract_error(&e)),
651
}
@@ -362,9 +661,8 @@ fn sign_out(app: &mut App, tx: mpsc::UnboundedSender<AuthEvent>) {
661
app.status = Status::neutral("Signing out…");
662
663
tokio::spawn(async move {
365
- if let Some(token) = saved_token {
366
- // fire and forget — if the server call fails we still clear locally
367
- let _ = logout_with_client(Environment::Production, credentials(), token).await;
664
+ if let Some(t) = saved_token {
665
+ let _ = logout_with_client(Environment::Production, credentials(), t).await;
666
}
667
let _ = tx.send(AuthEvent::SignedOut);
668
});
src/gresiq.rs
new
+54
@@ -0,0 +1,54 @@
1
+//! Onde credentials baked in. Wraps `smbcloud_gresiq_sdk` so callers only
2
+//! need to pass the user's access token.
3
+use smbcloud_gresiq_sdk::{Environment, OndeApp, OndeModel};
4
+
5
+pub async fn load_apps(token: &str) -> anyhow::Result<Vec<OndeApp>> {
6
+ smbcloud_gresiq_sdk::list_apps(
7
+ &Environment::Production,
8
+ crate::app::ONDE_APP_ID,
9
+ crate::app::ONDE_APP_SECRET,
10
+ token,
11
+ )
12
+ .await
13
+ .map_err(|e| anyhow::anyhow!("{e}"))
14
+}
15
+
16
+pub async fn create_app(token: &str, name: &str) -> anyhow::Result<OndeApp> {
17
+ smbcloud_gresiq_sdk::create_app(
18
+ &Environment::Production,
19
+ crate::app::ONDE_APP_ID,
20
+ crate::app::ONDE_APP_SECRET,
21
+ token,
22
+ name,
23
+ )
24
+ .await
25
+ .map_err(|e| anyhow::anyhow!("{e}"))
26
+}
27
+
28
+pub async fn load_models(token: &str) -> anyhow::Result<Vec<OndeModel>> {
29
+ smbcloud_gresiq_sdk::list_models(
30
+ &Environment::Production,
31
+ crate::app::ONDE_APP_ID,
32
+ crate::app::ONDE_APP_SECRET,
33
+ token,
34
+ )
35
+ .await
36
+ .map_err(|e| anyhow::anyhow!("{e}"))
37
+}
38
+
39
+pub async fn assign_model(
40
+ token: &str,
41
+ onde_app_id: &str,
42
+ model_id: &str,
43
+) -> anyhow::Result<()> {
44
+ smbcloud_gresiq_sdk::assign_model(
45
+ &Environment::Production,
46
+ crate::app::ONDE_APP_ID,
47
+ crate::app::ONDE_APP_SECRET,
48
+ token,
49
+ onde_app_id,
50
+ model_id,
51
+ )
52
+ .await
53
+ .map_err(|e| anyhow::anyhow!("{e}"))
54
+}
src/main.rs
+1
@@ -1,4 +1,5 @@
1
mod app;
2
+mod gresiq;
3
mod token;
4
mod ui;
5
src/ui.rs
+304
-110
@@ -1,15 +1,15 @@
1
use {
2
- crate::app::{App, Focus, Mode, Profile, StatusTone},
2
+ crate::app::{App, Focus, Mode, OndeApp, Screen, StatusTone},
3
ratatui::{
4
Frame,
5
- layout::{Alignment, Constraint, Layout},
5
+ layout::{Alignment, Constraint, Layout, Rect},
6
style::{Color, Style, Stylize},
7
text::{Line, Span},
8
widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap},
9
},
10
};
11
12
-// theme — colours pulled directly from globals.css
12
+// colours from globals.css
13
14
const C_BG: Color = Color::Rgb(0, 0, 0);
15
const C_SURFACE: Color = Color::Rgb(13, 20, 16);
@@ -21,18 +21,15 @@ const C_INK: Color = Color::Rgb(216, 229, 222);
21
const C_DANGER: Color = Color::Rgb(255, 95, 86);
22
const C_LINE: Color = Color::Rgb(35, 50, 42);
23
24
-// render
25
-
24
pub fn render(frame: &mut Frame, app: &App) {
25
let area = frame.area();
26
29
- // Full-screen obsidian background
27
frame.render_widget(Block::new().style(Style::new().bg(C_BG)), area);
28
29
let layout = Layout::vertical([
33
- Constraint::Length(3), // header
34
- Constraint::Min(0), // card
35
- Constraint::Length(1), // footer
30
+ Constraint::Length(3),
31
+ Constraint::Min(0),
32
+ Constraint::Length(1),
33
])
34
.split(area);
35
@@ -41,9 +38,7 @@ pub fn render(frame: &mut Frame, app: &App) {
38
render_footer(frame, app, layout[2]);
39
}
40
44
-// header
45
-
46
-fn render_header(frame: &mut Frame, area: ratatui::layout::Rect) {
41
+fn render_header(frame: &mut Frame, area: Rect) {
42
let text = vec![
43
Line::from(vec![
44
Span::styled("◆ onde", Style::new().fg(C_NEON).bold()),
@@ -58,10 +53,7 @@ fn render_header(frame: &mut Frame, area: ratatui::layout::Rect) {
53
frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
54
}
55
61
-// card
62
-
63
-fn render_card(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
64
- // Centre the card horizontally (max 64 wide, minimum terminal width − 4)
56
+fn render_card(frame: &mut Frame, app: &App, area: Rect) {
57
let card_width = 64_u16.min(area.width.saturating_sub(4));
58
let h_pad = area.width.saturating_sub(card_width) / 2;
59
@@ -84,33 +76,33 @@ fn render_card(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
76
let inner = card.inner(card_area);
77
frame.render_widget(card, card_area);
78
87
- if let Some(profile) = &app.profile {
88
- render_session(frame, app, profile, inner);
89
- } else {
90
- render_form(frame, app, inner);
79
+ match app.screen {
80
+ Screen::Auth => render_form(frame, app, inner),
81
+ Screen::Apps => render_apps(frame, app, inner),
82
+ Screen::Models => render_models(frame, app, inner),
83
}
84
}
85
86
// auth form
87
96
-fn render_form(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
88
+fn render_form(frame: &mut Frame, app: &App, area: Rect) {
89
let rows = Layout::vertical([
98
- Constraint::Length(1), // tabs
99
- Constraint::Length(1), // spacer
100
- Constraint::Length(1), // headline
101
- Constraint::Length(2), // description
102
- Constraint::Length(1), // spacer
103
- Constraint::Length(1), // status
104
- Constraint::Length(1), // spacer
105
- Constraint::Length(1), // email label
106
- Constraint::Length(3), // email input
107
- Constraint::Length(1), // spacer
108
- Constraint::Length(1), // password label
109
- Constraint::Length(3), // password input
110
- Constraint::Length(1), // spacer
111
- Constraint::Length(1), // primary button
112
- Constraint::Length(1), // secondary hint
113
- Constraint::Min(0), // remainder
90
+ Constraint::Length(1),
91
+ Constraint::Length(1),
92
+ Constraint::Length(1),
93
+ Constraint::Length(2),
94
+ Constraint::Length(1),
95
+ Constraint::Length(1),
96
+ Constraint::Length(1),
97
+ Constraint::Length(1),
98
+ Constraint::Length(3),
99
+ Constraint::Length(1),
100
+ Constraint::Length(1),
101
+ Constraint::Length(3),
102
+ Constraint::Length(1),
103
+ Constraint::Length(1),
104
+ Constraint::Length(1),
105
+ Constraint::Min(0),
106
])
107
.split(area);
108
@@ -137,36 +129,19 @@ fn render_form(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
129
130
render_status(frame, app, rows[5]);
131
140
- // Email
132
frame.render_widget(
133
Paragraph::new("Email").style(Style::new().fg(C_MUTED)),
134
rows[7],
135
);
145
- render_input(
146
- frame,
147
- app,
148
- &app.email,
149
- Focus::Email,
150
- "name@company.com",
151
- rows[8],
152
- );
136
+ render_input(frame, app, &app.email, Focus::Email, "name@company.com", rows[8]);
137
154
- // Password
138
frame.render_widget(
139
Paragraph::new("Password").style(Style::new().fg(C_MUTED)),
140
rows[10],
141
);
142
let masked = "•".repeat(app.password.len());
160
- render_input(
161
- frame,
162
- app,
163
- &masked,
164
- Focus::Password,
165
- "Minimum 8 characters",
166
- rows[11],
167
- );
143
+ render_input(frame, app, &masked, Focus::Password, "Minimum 8 characters", rows[11]);
144
169
- // Buttons
145
let (primary_label, secondary_label) = match app.mode {
146
Mode::Signup => ("[ Create account ]", "I already have an account Ctrl+L"),
147
Mode::Signin => ("[ Sign in ]", "Create a new account Ctrl+N"),
@@ -185,13 +160,11 @@ fn render_form(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
160
);
161
}
162
188
-// mode tabs
189
-
190
-fn render_tabs(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
163
+fn render_tabs(frame: &mut Frame, app: &App, area: Rect) {
164
let cols = Layout::horizontal([
192
- Constraint::Length(19), // signup tab
193
- Constraint::Length(1), // gap
194
- Constraint::Length(11), // signin tab
165
+ Constraint::Length(19),
166
+ Constraint::Length(1),
167
+ Constraint::Length(11),
168
Constraint::Min(0),
169
])
170
.split(area);
@@ -200,32 +173,22 @@ fn render_tabs(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
173
let inactive = Style::new().fg(C_MUTED).bg(C_SURFACE_STRONG);
174
175
frame.render_widget(
203
- Paragraph::new(" Create account ").style(if app.mode == Mode::Signup {
204
- active
205
- } else {
206
- inactive
207
- }),
176
+ Paragraph::new(" Create account ").style(if app.mode == Mode::Signup { active } else { inactive }),
177
cols[0],
178
);
179
frame.render_widget(
211
- Paragraph::new(" Sign in ").style(if app.mode == Mode::Signin {
212
- active
213
- } else {
214
- inactive
215
- }),
180
+ Paragraph::new(" Sign in ").style(if app.mode == Mode::Signin { active } else { inactive }),
181
cols[2],
182
);
183
}
184
220
-// input field
221
-
185
fn render_input(
186
frame: &mut Frame,
187
app: &App,
188
value: &str,
189
field: Focus,
190
placeholder: &str,
228
- area: ratatui::layout::Rect,
191
+ area: Rect,
192
) {
193
let is_focused = app.focus == field;
194
@@ -259,9 +222,24 @@ fn render_input(
222
}
223
}
224
262
-// status bar
225
+// new-app input — always focused, no focus state needed
226
+fn render_create_input(frame: &mut Frame, value: &str, area: Rect) {
227
+ let block = Block::new()
228
+ .borders(Borders::ALL)
229
+ .border_type(BorderType::Rounded)
230
+ .border_style(Style::new().fg(C_NEON))
231
+ .style(Style::new().bg(C_SURFACE_STRONG));
232
+
233
+ let inner = block.inner(area);
234
+ frame.render_widget(block, area);
235
+ frame.render_widget(Paragraph::new(value).style(Style::new().fg(C_TEXT)), inner);
236
+
237
+ let cursor_x = (inner.x + value.chars().count() as u16)
238
+ .min(inner.x + inner.width.saturating_sub(1));
239
+ frame.set_cursor_position((cursor_x, inner.y));
240
+}
241
264
-fn render_status(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
242
+fn render_status(frame: &mut Frame, app: &App, area: Rect) {
243
let (icon, color) = match app.status.tone {
244
StatusTone::Neutral => ("●", C_MUTED),
245
StatusTone::Success => ("✓", C_NEON),
@@ -278,58 +256,244 @@ fn render_status(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
256
frame.render_widget(Paragraph::new(line), area);
257
}
258
281
-// session view — shown after sign-in
259
+// apps screen
260
283
-fn render_session(frame: &mut Frame, app: &App, profile: &Profile, area: ratatui::layout::Rect) {
284
- let rows = Layout::vertical([
285
- Constraint::Length(1), // signed-in badge
261
+fn render_apps(frame: &mut Frame, app: &App, area: Rect) {
262
+ let email = app.profile.as_ref().map(|p| p.email.as_str()).unwrap_or("");
263
+
264
+ let top = Layout::vertical([
265
+ Constraint::Length(1), // profile badge
266
Constraint::Length(1), // spacer
287
- Constraint::Length(1), // email
267
+ Constraint::Length(1), // status
268
Constraint::Length(1), // spacer
289
- Constraint::Length(2), // beta message
269
+ Constraint::Length(1), // column header
270
+ Constraint::Length(1), // divider
271
+ Constraint::Min(0), // rest (list + optional form + hint)
272
+ ])
273
+ .split(area);
274
+
275
+ frame.render_widget(
276
+ Paragraph::new(Line::from(vec![
277
+ Span::styled("✓ ", Style::new().fg(C_NEON)),
278
+ Span::styled(email, Style::new().fg(C_TEXT).bold()),
279
+ ])),
280
+ top[0],
281
+ );
282
+
283
+ render_status(frame, app, top[2]);
284
+
285
+ frame.render_widget(
286
+ Paragraph::new(" Name Status Model")
287
+ .style(Style::new().fg(C_MUTED)),
288
+ top[4],
289
+ );
290
+
291
+ let divider = "─".repeat(area.width as usize);
292
+ frame.render_widget(
293
+ Paragraph::new(divider).style(Style::new().fg(C_LINE)),
294
+ top[5],
295
+ );
296
+
297
+ let rest = top[6];
298
+ if app.creating_app {
299
+ let bottom = Layout::vertical([
300
+ Constraint::Min(0), // list
301
+ Constraint::Length(1), // spacer
302
+ Constraint::Length(1), // "New app name:" label
303
+ Constraint::Length(3), // input
304
+ Constraint::Length(1), // hint
305
+ ])
306
+ .split(rest);
307
+
308
+ render_apps_list(frame, app, bottom[0]);
309
+
310
+ frame.render_widget(
311
+ Paragraph::new("New app name:").style(Style::new().fg(C_MUTED)),
312
+ bottom[2],
313
+ );
314
+ render_create_input(frame, &app.new_app_name, bottom[3]);
315
+ frame.render_widget(
316
+ Paragraph::new("Enter · create Esc · cancel")
317
+ .style(Style::new().fg(C_MUTED)),
318
+ bottom[4],
319
+ );
320
+ } else {
321
+ let bottom = Layout::vertical([
322
+ Constraint::Min(0), // list
323
+ Constraint::Length(1), // hint
324
+ ])
325
+ .split(rest);
326
+
327
+ render_apps_list(frame, app, bottom[0]);
328
+ frame.render_widget(
329
+ Paragraph::new("n · new app Enter · assign model s · sign out")
330
+ .style(Style::new().fg(C_MUTED)),
331
+ bottom[1],
332
+ );
333
+ }
334
+}
335
+
336
+fn render_apps_list(frame: &mut Frame, app: &App, area: Rect) {
337
+ if app.apps.is_empty() {
338
+ if app.busy {
339
+ frame.render_widget(
340
+ Paragraph::new(" Loading…").style(Style::new().fg(C_MUTED)),
341
+ area,
342
+ );
343
+ } else if app.apps_loaded {
344
+ frame.render_widget(
345
+ Paragraph::new(" No apps yet. Press n to create one.")
346
+ .style(Style::new().fg(C_MUTED)),
347
+ area,
348
+ );
349
+ }
350
+ return;
351
+ }
352
+
353
+ let max_rows = area.height as usize;
354
+ for (list_idx, onde_app) in app
355
+ .apps
356
+ .iter()
357
+ .enumerate()
358
+ .skip(app.apps_offset)
359
+ .take(max_rows)
360
+ {
361
+ let row_y = area.y + (list_idx - app.apps_offset) as u16;
362
+ let row_area = Rect::new(area.x, row_y, area.width, 1);
363
+
364
+ let is_selected = list_idx == app.apps_cursor;
365
+ let prefix = if is_selected { "▶ " } else { " " };
366
+
367
+ let model_name = resolve_model_name(app, onde_app);
368
+ let status_str = onde_app.status.as_deref().unwrap_or("–");
369
+ let name_str = &onde_app.name;
370
+
371
+ let line = format!(
372
+ "{}{:<22} {:<8} {}",
373
+ prefix, name_str, status_str, model_name
374
+ );
375
+
376
+ let style = if is_selected {
377
+ Style::new().fg(C_NEON)
378
+ } else {
379
+ Style::new().fg(C_TEXT)
380
+ };
381
+
382
+ frame.render_widget(Paragraph::new(line).style(style), row_area);
383
+ }
384
+}
385
+
386
+// Falls back to "–" if the app has no model assigned or the model isn’t in
387
+// the loaded list yet (models load lazily on first visit to the picker).
388
+fn resolve_model_name<'a>(app: &'a App, onde_app: &'a OndeApp) -> &'a str {
389
+ onde_app
390
+ .current_model_id
391
+ .as_deref()
392
+ .and_then(|id| app.models.iter().find(|m| m.id == id))
393
+ .and_then(|m| m.name.as_deref())
394
+ .unwrap_or("–")
395
+}
396
+
397
+// models screen
398
+
399
+fn render_models(frame: &mut Frame, app: &App, area: Rect) {
400
+ let app_name: String = app
401
+ .assigning_for_app_index
402
+ .and_then(|i| app.apps.get(i))
403
+ .map(|a| format!("Assign model — {}", a.name))
404
+ .unwrap_or_else(|| "Models".to_string());
405
+
406
+ let rows = Layout::vertical([
407
+ Constraint::Length(1), // heading
408
Constraint::Length(1), // spacer
291
- Constraint::Length(1), // status
409
+ Constraint::Length(1), // column header
410
+ Constraint::Length(1), // divider
411
+ Constraint::Min(0), // models list
412
Constraint::Length(1), // spacer
293
- Constraint::Length(1), // sign-out button
294
- Constraint::Min(0),
413
+ Constraint::Length(1), // status
414
])
415
.split(area);
416
417
frame.render_widget(
299
- Paragraph::new("✓ Signed in").style(Style::new().fg(C_NEON).bold()),
418
+ Paragraph::new(app_name.as_str()).style(Style::new().fg(C_INK).bold()),
419
rows[0],
420
);
421
+
422
frame.render_widget(
303
- Paragraph::new(profile.email.as_str()).style(Style::new().fg(C_TEXT).bold()),
423
+ Paragraph::new(" Name Family Size Format")
424
+ .style(Style::new().fg(C_MUTED)),
425
rows[2],
426
);
427
+
428
+ let divider = "─".repeat(area.width as usize);
429
frame.render_widget(
307
- Paragraph::new(
308
- "We are in private beta. Thanks for your interest.\nEmail work@setoelkahfi.se for questions.",
309
- )
310
- .style(Style::new().fg(C_MUTED))
311
- .wrap(Wrap { trim: true }),
312
- rows[4],
430
+ Paragraph::new(divider).style(Style::new().fg(C_LINE)),
431
+ rows[3],
432
);
433
+
434
+ // models list
435
+ let list_area = rows[4];
436
+ if app.models.is_empty() {
437
+ if app.busy {
438
+ frame.render_widget(
439
+ Paragraph::new(" Loading…").style(Style::new().fg(C_MUTED)),
440
+ list_area,
441
+ );
442
+ }
443
+ } else {
444
+ let max_rows = list_area.height as usize;
445
+ for (list_idx, model) in app
446
+ .models
447
+ .iter()
448
+ .enumerate()
449
+ .skip(app.models_offset)
450
+ .take(max_rows)
451
+ {
452
+ let row_y = list_area.y + (list_idx - app.models_offset) as u16;
453
+ let row_area = Rect::new(list_area.x, row_y, list_area.width, 1);
454
+
455
+ let is_selected = list_idx == app.models_cursor;
456
+ let prefix = if is_selected { "▶ " } else { " " };
457
+
458
+ let name = model.name.as_deref().unwrap_or("–");
459
+ let family = model.family.as_deref().unwrap_or("–");
460
+ let size = model
461
+ .approx_size_bytes
462
+ .map(fmt_bytes)
463
+ .unwrap_or_else(|| "–".to_string());
464
+ let format = model.format.as_deref().unwrap_or("–");
465
+
466
+ let line = format!(
467
+ "{}{:<22} {:<8} {:<10} {}",
468
+ prefix, name, family, size, format
469
+ );
470
+
471
+ let style = if is_selected {
472
+ Style::new().fg(C_NEON)
473
+ } else {
474
+ Style::new().fg(C_TEXT)
475
+ };
476
+
477
+ frame.render_widget(Paragraph::new(line).style(style), row_area);
478
+ }
479
+ }
480
+
481
render_status(frame, app, rows[6]);
315
- frame.render_widget(
316
- Paragraph::new("[ Sign out ] Enter").style(Style::new().fg(C_MUTED)),
317
- rows[8],
318
- );
482
}
483
321
-// footer key hints
322
-
323
-fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
324
- let keys: Vec<Span> = if app.profile.is_some() {
325
- vec![
326
- Span::styled("Enter", Style::new().fg(C_NEON)),
327
- Span::styled(" · sign out ", Style::new().fg(C_MUTED)),
328
- Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
329
- Span::styled(" · quit", Style::new().fg(C_MUTED)),
330
- ]
484
+fn fmt_bytes(bytes: i64) -> String {
485
+ if bytes >= 1_000_000_000 {
486
+ format!("{:.1}GB", bytes as f64 / 1e9)
487
+ } else if bytes >= 1_000_000 {
488
+ format!("{:.0}MB", bytes as f64 / 1e6)
489
} else {
332
- vec![
490
+ format!("{:.0}KB", bytes as f64 / 1e3)
491
+ }
492
+}
493
+
494
+fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
495
+ let keys: Vec<Span> = match app.screen {
496
+ Screen::Auth => vec![
497
Span::styled("Tab", Style::new().fg(C_NEON)),
498
Span::styled(" · next field ", Style::new().fg(C_MUTED)),
499
Span::styled("Enter", Style::new().fg(C_NEON)),
@@ -340,7 +504,37 @@ fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
504
Span::styled(" · new account ", Style::new().fg(C_MUTED)),
505
Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
506
Span::styled(" · quit", Style::new().fg(C_MUTED)),
343
- ]
507
+ ],
508
+ Screen::Apps if app.creating_app => vec![
509
+ Span::styled("Enter", Style::new().fg(C_NEON)),
510
+ Span::styled(" · create ", Style::new().fg(C_MUTED)),
511
+ Span::styled("Esc", Style::new().fg(C_NEON)),
512
+ Span::styled(" · cancel ", Style::new().fg(C_MUTED)),
513
+ Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
514
+ Span::styled(" · quit", Style::new().fg(C_MUTED)),
515
+ ],
516
+ Screen::Apps => vec![
517
+ Span::styled("↑↓", Style::new().fg(C_NEON)),
518
+ Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
519
+ Span::styled("Enter", Style::new().fg(C_NEON)),
520
+ Span::styled(" · assign model ", Style::new().fg(C_MUTED)),
521
+ Span::styled("n", Style::new().fg(C_NEON)),
522
+ Span::styled(" · new ", Style::new().fg(C_MUTED)),
523
+ Span::styled("s", Style::new().fg(C_NEON)),
524
+ Span::styled(" · sign out ", Style::new().fg(C_MUTED)),
525
+ Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
526
+ Span::styled(" · quit", Style::new().fg(C_MUTED)),
527
+ ],
528
+ Screen::Models => vec![
529
+ Span::styled("↑↓", Style::new().fg(C_NEON)),
530
+ Span::styled(" · navigate ", Style::new().fg(C_MUTED)),
531
+ Span::styled("Enter", Style::new().fg(C_NEON)),
532
+ Span::styled(" · assign ", Style::new().fg(C_MUTED)),
533
+ Span::styled("Esc", Style::new().fg(C_NEON)),
534
+ Span::styled(" · back ", Style::new().fg(C_MUTED)),
535
+ Span::styled("Ctrl+C", Style::new().fg(C_NEON)),
536
+ Span::styled(" · quit", Style::new().fg(C_MUTED)),
537
+ ],
538
};
539
540
frame.render_widget(