@ondeinference / cli / commits / 09e2fa1

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(