1use anyhow::{Context, Result};
16use crossterm::{
17 ExecutableCommand,
18 event::{self, Event, KeyCode, KeyModifiers},
19 terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
20};
21use ratatui::{
22 Terminal,
23 backend::CrosstermBackend,
24 layout::{Constraint, Layout},
25 style::{Color, Modifier, Style},
26 text::{Line, Span},
27 widgets::{Block, Borders, Paragraph, Wrap},
28};
29use robonix_atlas::client::AtlasClient;
30use robonix_atlas::pb as atlas_pb;
31use std::cell::RefCell;
32use std::io;
33use std::rc::Rc;
34use tokio_stream::StreamExt;
35use uuid::Uuid;
36
37const CONSUMER_ID: &str = "rbnx-cli/chat";
38const LIAISON_CONTRACT_ID: &str = "robonix/system/liaison/submit";
42
43const MIC_CONTRACT: &str = "robonix/primitive/audio/mic";
48const SPEAKER_CONTRACT: &str = "robonix/primitive/audio/speaker";
49
50struct ChatMessage {
51 role: Role,
52 text: String,
53}
54
55enum Role {
56 User,
57 Agent,
58 ToolCall,
59 Status,
60 Voice,
61}
62
63const DEFAULT_LIAISON_FALLBACK: &str = "http://127.0.0.1:50081";
64
65#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
75struct ChatConfig {
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 mic_cap_id: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 mic_device_id: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 speaker_cap_id: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 speaker_device_id: Option<String>,
84}
85
86fn chat_config_path() -> Option<std::path::PathBuf> {
87 dirs::home_dir().map(|h| h.join(".robonix").join("chat.yaml"))
88}
89
90fn load_chat_config() -> ChatConfig {
91 let Some(p) = chat_config_path() else {
92 return ChatConfig::default();
93 };
94 let Ok(text) = std::fs::read_to_string(&p) else {
95 return ChatConfig::default();
96 };
97 serde_yaml::from_str(&text).unwrap_or_default()
98}
99
100fn save_chat_config(cfg: &ChatConfig) -> Result<()> {
101 let p = chat_config_path().context("no home dir")?;
102 if let Some(parent) = p.parent() {
103 std::fs::create_dir_all(parent)?;
104 }
105 std::fs::write(&p, serde_yaml::to_string(cfg)?)?;
106 Ok(())
107}
108
109pub async fn execute(server: &str) -> Result<()> {
110 let atlas_endpoint = if server.starts_with("http") {
111 server.to_string()
112 } else {
113 format!("http://{server}")
114 };
115
116 let liaison_endpoint = if let Ok(ep) = std::env::var("ROBONIX_LIAISON_ENDPOINT") {
117 log::info!("using ROBONIX_LIAISON_ENDPOINT={ep}");
118 if ep.starts_with("http") {
119 ep
120 } else {
121 format!("http://{ep}")
122 }
123 } else {
124 discover_liaison(&atlas_endpoint).await.unwrap_or_else(|e| {
125 log::warn!(
126 "liaison discovery timed out ({e:#}), falling back to {DEFAULT_LIAISON_FALLBACK}"
127 );
128 DEFAULT_LIAISON_FALLBACK.to_string()
129 })
130 };
131 let liaison_endpoint = localhost_to_ipv4_loopback(&liaison_endpoint);
132
133 let mut stdout = io::stdout();
134 stdout.execute(EnterAlternateScreen)?;
135 terminal::enable_raw_mode()?;
136
137 let backend = CrosstermBackend::new(stdout);
138 let mut terminal = Terminal::new(backend)?;
139
140 let (chat_cfg, audio_warnings) =
146 match ensure_audio_devices(&atlas_endpoint, &mut terminal).await {
147 Ok(v) => v,
148 Err(e) => {
149 terminal::disable_raw_mode()?;
150 terminal.backend_mut().execute(LeaveAlternateScreen)?;
151 return Err(e);
152 }
153 };
154
155 let result = run_tui(
156 &mut terminal,
157 &atlas_endpoint,
158 &liaison_endpoint,
159 chat_cfg,
160 &audio_warnings,
161 )
162 .await;
163
164 terminal::disable_raw_mode()?;
165 terminal.backend_mut().execute(LeaveAlternateScreen)?;
166
167 result
168}
169
170async fn discover_liaison(atlas_endpoint: &str) -> Result<String> {
172 const RETRY_INTERVAL_MS: u64 = 2_000;
173 const TIMEOUT_SECS: u64 = 60;
174
175 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(TIMEOUT_SECS);
176 let mut attempt = 0u32;
177
178 loop {
179 attempt += 1;
180 match try_discover_once(atlas_endpoint, LIAISON_CONTRACT_ID).await {
181 Ok(ep) => return Ok(ep),
182 Err(e) => {
183 if std::time::Instant::now() >= deadline {
184 anyhow::bail!("liaison not found in Atlas after {TIMEOUT_SECS}s: {e:#}");
185 }
186 if attempt == 1 {
187 eprintln!("[chat] waiting for Liaison to register in Atlas ({e:#})…");
188 }
189 tokio::time::sleep(std::time::Duration::from_millis(RETRY_INTERVAL_MS)).await;
190 }
191 }
192 }
193}
194
195async fn try_discover_once(atlas_endpoint: &str, contract_id: &str) -> Result<String> {
196 let mut atlas = AtlasClient::connect(atlas_endpoint).await?;
197 let transport = atlas_pb::Transport::Grpc;
198 let providers = atlas.query_capabilities("", contract_id, transport).await?;
199 for provider in &providers {
200 let has = provider
201 .capabilities
202 .iter()
203 .any(|i| i.contract_id == contract_id && i.transport == transport as i32);
204 if !has {
205 continue;
206 }
207 let (_, endpoint, _) = atlas
208 .connect_capability(CONSUMER_ID, &provider.id, contract_id, transport)
209 .await?;
210 if endpoint.is_empty() {
211 continue;
212 }
213 let uri = if endpoint.starts_with("http") {
214 endpoint
215 } else {
216 format!("http://{endpoint}")
217 };
218 return Ok(localhost_to_ipv4_loopback(&uri));
219 }
220 anyhow::bail!("no {contract_id} capability found in Atlas registry")
221}
222
223#[derive(Clone, Copy)]
241#[allow(dead_code)]
242enum PickMode {
243 FirstRun,
244 Reconfigure,
245}
246
247async fn ensure_audio_devices(
251 atlas_endpoint: &str,
252 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
253) -> Result<(ChatConfig, Vec<String>)> {
254 pick_audio_settings(atlas_endpoint, terminal, PickMode::FirstRun).await
255}
256
257async fn pick_audio_settings(
258 atlas_endpoint: &str,
259 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
260 mode: PickMode,
261) -> Result<(ChatConfig, Vec<String>)> {
262 let mut cfg = load_chat_config();
263 let mut warnings: Vec<String> = Vec::new();
264
265 let env_set = |key: &str| std::env::var(key).ok().filter(|s| !s.is_empty()).is_some();
266 let always = matches!(mode, PickMode::Reconfigure);
267 let mut need_mic = always || (!env_set("ROBONIX_CHAT_MIC_NODE") && cfg.mic_cap_id.is_none());
268 let mut need_speaker =
269 always || (!env_set("ROBONIX_CHAT_SPEAKER_NODE") && cfg.speaker_cap_id.is_none());
270
271 let mut atlas = match AtlasClient::connect(atlas_endpoint).await {
272 Ok(c) => c,
273 Err(e) => {
274 warnings.push(format!(
275 "audio device pick skipped — atlas unreachable at {atlas_endpoint}: {e:#}. \
276 Text mode still works; voice (Ctrl+V) will fail until atlas is up."
277 ));
278 return Ok((cfg, warnings));
279 }
280 };
281
282 if !need_mic
287 && let Some(pin) = cfg.mic_cap_id.as_deref()
288 && !pin_exists_in_atlas(&mut atlas, pin, MIC_CONTRACT).await
289 {
290 warnings.push(format!(
291 "mic pin '{pin}' not in atlas (stale config) — re-prompting"
292 ));
293 cfg.mic_cap_id = None;
294 cfg.mic_device_id = None;
295 need_mic = true;
296 }
297 if !need_speaker
298 && let Some(pin) = cfg.speaker_cap_id.as_deref()
299 && !pin_exists_in_atlas(&mut atlas, pin, SPEAKER_CONTRACT).await
300 {
301 warnings.push(format!(
302 "speaker pin '{pin}' not in atlas (stale config) — re-prompting"
303 ));
304 cfg.speaker_cap_id = None;
305 cfg.speaker_device_id = None;
306 need_speaker = true;
307 }
308 if !need_mic && !need_speaker {
309 return Ok((cfg, warnings));
310 }
311
312 if need_mic {
313 let saved_cap = cfg.mic_cap_id.clone();
314 let saved_dev = cfg.mic_device_id.clone();
315 match try_pick(
316 &mut atlas,
317 terminal,
318 "mic",
319 MIC_CONTRACT,
320 "input",
321 saved_cap.as_deref(),
322 saved_dev.as_deref(),
323 mode,
324 )
325 .await
326 {
327 Ok(Some((provider_id, device_id))) => {
328 cfg.mic_cap_id = Some(provider_id);
329 cfg.mic_device_id = Some(device_id);
330 }
331 Ok(None) => warnings.push(
332 "no mic provider in atlas — voice input disabled (text mode unaffected)".into(),
333 ),
334 Err(e) => warnings.push(format!("mic pick: {e:#}")),
335 }
336 }
337 if need_speaker {
338 let saved_cap = cfg.speaker_cap_id.clone();
339 let saved_dev = cfg.speaker_device_id.clone();
340 match try_pick(
341 &mut atlas,
342 terminal,
343 "speaker",
344 SPEAKER_CONTRACT,
345 "output",
346 saved_cap.as_deref(),
347 saved_dev.as_deref(),
348 mode,
349 )
350 .await
351 {
352 Ok(Some((provider_id, device_id))) => {
353 cfg.speaker_cap_id = Some(provider_id);
354 cfg.speaker_device_id = Some(device_id);
355 }
356 Ok(None) => warnings.push(
357 "no speaker provider in atlas — voice playback disabled (text mode unaffected)"
358 .into(),
359 ),
360 Err(e) => warnings.push(format!("speaker pick: {e:#}")),
361 }
362 }
363 if let Err(e) = save_chat_config(&cfg) {
364 log::warn!("could not save chat config: {e:#}");
365 }
366 Ok((cfg, warnings))
367}
368
369async fn pin_exists_in_atlas(atlas: &mut AtlasClient, pin: &str, contract: &str) -> bool {
375 let Ok(providers) = atlas
376 .query_capabilities("", contract, atlas_pb::Transport::Grpc)
377 .await
378 else {
379 return true;
382 };
383 providers.iter().any(|r| r.id == pin || r.namespace == pin)
384}
385
386#[allow(clippy::too_many_arguments)]
390async fn try_pick(
391 atlas: &mut AtlasClient,
392 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
393 label: &str,
394 contract: &str,
395 kind: &str,
396 saved_cap_id: Option<&str>,
397 saved_device_id: Option<&str>,
398 mode: PickMode,
399) -> Result<Option<(String, String)>> {
400 let providers = atlas
401 .query_capabilities("", contract, atlas_pb::Transport::Grpc)
402 .await?;
403 if providers.is_empty() {
404 return Ok(None);
405 }
406
407 let provider_id = match (mode, saved_cap_id) {
413 (PickMode::FirstRun, Some(s)) if providers.iter().any(|p| p.id == s) => s.to_string(),
414 (PickMode::FirstRun, _) if providers.len() == 1 => {
415 let id = providers[0].id.clone();
416 flash_picker_message(terminal, &format!("auto-selected {label}: {id}"))?;
417 id
418 }
419 _ => match pick_tui(terminal, label, contract, &providers)? {
420 Some(s) => s,
421 None => return Ok(None),
422 },
423 };
424
425 let device_id = pick_device_for_cap(
428 atlas,
429 terminal,
430 &provider_id,
431 label,
432 kind,
433 saved_device_id,
434 mode,
435 )
436 .await?;
437
438 if !device_id.is_empty()
440 && let Err(e) = call_select_device(atlas, &provider_id, kind, &device_id).await
441 {
442 log::warn!("SelectAudioDevice on {provider_id} ({kind}={device_id}) failed: {e:#}");
443 }
444
445 Ok(Some((provider_id, device_id)))
446}
447
448async fn pick_device_for_cap(
452 atlas: &mut AtlasClient,
453 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
454 provider_id: &str,
455 label: &str,
456 kind: &str,
457 saved_device_id: Option<&str>,
458 mode: PickMode,
459) -> Result<String> {
460 use crate::pb::contracts::robonix_primitive_audio_list_devices_client::RobonixPrimitiveAudioListDevicesClient;
461
462 const LIST_CONTRACT: &str = "robonix/primitive/audio/list_devices";
463 let reconf = matches!(mode, PickMode::Reconfigure);
467
468 let endpoint = match atlas
469 .connect_capability(
470 CONSUMER_ID,
471 provider_id,
472 LIST_CONTRACT,
473 atlas_pb::Transport::Grpc,
474 )
475 .await
476 {
477 Ok((_, ep, _)) => {
478 if ep.starts_with("http") {
479 ep
480 } else {
481 format!("http://{ep}")
482 }
483 }
484 Err(_) => {
485 if reconf {
486 flash_picker_message(
487 terminal,
488 &format!(
489 "{provider_id} doesn't expose list_devices — \
490 using OS default device. Rebuild the package \
491 (bash scripts/build.sh) to pick up the new contract."
492 ),
493 )?;
494 }
495 return Ok(String::new());
496 }
497 };
498
499 let mut client = match RobonixPrimitiveAudioListDevicesClient::connect(endpoint.clone()).await {
500 Ok(c) => c,
501 Err(e) => {
502 if reconf {
503 flash_picker_message(
504 terminal,
505 &format!("connect list_devices on {provider_id}: {e}"),
506 )?;
507 }
508 return Ok(String::new());
509 }
510 };
511 let resp = match client
512 .list_audio_devices(crate::pb::audio::ListAudioDevicesRequest {})
513 .await
514 {
515 Ok(r) => r.into_inner(),
516 Err(e) => {
517 if reconf {
518 flash_picker_message(terminal, &format!("ListAudioDevices on {provider_id}: {e}"))?;
519 }
520 return Ok(String::new());
521 }
522 };
523
524 let usable: Vec<crate::pb::audio::AudioDevice> = resp
525 .devices
526 .into_iter()
527 .filter(|d| d.kind == kind || d.kind == "duplex")
528 .collect();
529 if usable.is_empty() {
530 if reconf {
531 flash_picker_message(
532 terminal,
533 &format!("{provider_id} reports no {kind} devices — using OS default"),
534 )?;
535 }
536 return Ok(String::new());
537 }
538
539 if matches!(mode, PickMode::FirstRun) {
543 if let Some(saved) = saved_device_id
544 && usable.iter().any(|d| d.id == saved)
545 {
546 return Ok(saved.to_string());
547 }
548 if usable.len() == 1 {
549 let id = usable[0].id.clone();
550 flash_picker_message(
551 terminal,
552 &format!("auto-selected {label} device: {}", usable[0].name),
553 )?;
554 return Ok(id);
555 }
556 }
557
558 let chosen = pick_device_tui(terminal, label, &usable)?;
559 if chosen.is_empty()
562 && let Some(saved) = saved_device_id
563 {
564 return Ok(saved.to_string());
565 }
566 Ok(chosen)
567}
568
569async fn call_select_device(
570 atlas: &mut AtlasClient,
571 provider_id: &str,
572 kind: &str,
573 device_id: &str,
574) -> Result<()> {
575 use crate::pb::contracts::robonix_primitive_audio_select_device_client::RobonixPrimitiveAudioSelectDeviceClient;
576 const SELECT_CONTRACT: &str = "robonix/primitive/audio/select_device";
577
578 let (_, ep, _) = atlas
579 .connect_capability(
580 CONSUMER_ID,
581 provider_id,
582 SELECT_CONTRACT,
583 atlas_pb::Transport::Grpc,
584 )
585 .await?;
586 let endpoint = if ep.starts_with("http") {
587 ep
588 } else {
589 format!("http://{ep}")
590 };
591 let mut client = RobonixPrimitiveAudioSelectDeviceClient::connect(endpoint).await?;
592 let resp = client
593 .select_audio_device(crate::pb::audio::SelectAudioDeviceRequest {
594 kind: kind.to_string(),
595 id: device_id.to_string(),
596 })
597 .await?
598 .into_inner();
599 if !resp.ok {
600 anyhow::bail!("impl rejected: {}", resp.error);
601 }
602 Ok(())
603}
604
605fn pick_device_tui(
606 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
607 label: &str,
608 devices: &[crate::pb::audio::AudioDevice],
609) -> Result<String> {
610 let mut idx = 0usize;
611 loop {
612 terminal.draw(|f| {
613 let area = f.area();
614 let lines: Vec<Line> = devices
615 .iter()
616 .enumerate()
617 .map(|(i, d)| {
618 let mark = if i == idx { "▶ " } else { " " };
619 let style = if i == idx {
620 Style::default()
621 .fg(Color::Black)
622 .bg(Color::Cyan)
623 .add_modifier(Modifier::BOLD)
624 } else {
625 Style::default()
626 };
627 let mut tags = Vec::new();
628 if d.is_default {
629 tags.push("default".to_string());
630 }
631 if !d.note.is_empty() {
632 tags.push(format!("⚠ {}", d.note));
633 }
634 let suffix = if tags.is_empty() {
635 String::new()
636 } else {
637 format!(" ({})", tags.join(", "))
638 };
639 Line::from(vec![Span::styled(
640 format!("{mark}#{} {}{}", d.id, d.name, suffix),
641 style,
642 )])
643 })
644 .collect();
645 let body = Paragraph::new(lines)
646 .block(Block::default().borders(Borders::ALL).title(format!(
647 " choose {label} device — ↑↓ select · Enter confirm · Esc skip "
648 )))
649 .wrap(Wrap { trim: false });
650 f.render_widget(body, area);
651 })?;
652 if event::poll(std::time::Duration::from_millis(200))?
653 && let Event::Key(key) = event::read()?
654 {
655 match key.code {
656 KeyCode::Up | KeyCode::Char('k') => idx = idx.saturating_sub(1),
657 KeyCode::Down | KeyCode::Char('j') if idx + 1 < devices.len() => idx += 1,
658 KeyCode::Enter => return Ok(devices[idx].id.clone()),
659 KeyCode::Char('q') | KeyCode::Esc => return Ok(String::new()),
660 _ => {}
661 }
662 }
663 }
664}
665
666fn pick_tui(
667 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
668 label: &str,
669 contract: &str,
670 providers: &[atlas_pb::CapabilityProvider],
671) -> Result<Option<String>> {
672 let mut idx = 0usize;
673 loop {
674 terminal.draw(|f| {
675 let area = f.area();
676 let lines: Vec<Line> = providers
677 .iter()
678 .enumerate()
679 .map(|(i, r)| {
680 let mark = if i == idx { "▶ " } else { " " };
681 let style = if i == idx {
682 Style::default()
683 .fg(Color::Black)
684 .bg(Color::Cyan)
685 .add_modifier(Modifier::BOLD)
686 } else {
687 Style::default()
688 };
689 let detail = if r.state_detail.is_empty() {
690 String::new()
691 } else {
692 format!(" ({})", r.state_detail)
693 };
694 Line::from(vec![Span::styled(format!("{mark}{}{detail}", r.id), style)])
695 })
696 .collect();
697 let body = Paragraph::new(lines)
698 .block(Block::default().borders(Borders::ALL).title(format!(
699 " choose {label} provider ({contract}) — ↑↓ select · Enter confirm · Esc skip "
700 )))
701 .wrap(Wrap { trim: false });
702 f.render_widget(body, area);
703 })?;
704 if event::poll(std::time::Duration::from_millis(200))?
705 && let Event::Key(key) = event::read()?
706 {
707 match key.code {
708 KeyCode::Up | KeyCode::Char('k') => idx = idx.saturating_sub(1),
709 KeyCode::Down | KeyCode::Char('j') if idx + 1 < providers.len() => {
710 idx += 1;
711 }
712 KeyCode::Enter => return Ok(Some(providers[idx].id.clone())),
713 KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
714 _ => {}
715 }
716 }
717 }
718}
719
720#[derive(Clone, Copy, PartialEq, Eq)]
730enum AudioSection {
731 MicProvider,
732 MicDevice,
733 SpeakerProvider,
734 SpeakerDevice,
735}
736
737struct AudioSettingsPage {
738 mic_providers: Vec<atlas_pb::CapabilityProvider>,
739 speaker_providers: Vec<atlas_pb::CapabilityProvider>,
740 mic_devices: Vec<crate::pb::audio::AudioDevice>,
741 speaker_devices: Vec<crate::pb::audio::AudioDevice>,
742
743 mic_cap_id: String,
744 mic_device_id: String,
745 speaker_cap_id: String,
746 speaker_device_id: String,
747
748 section: AudioSection,
749 cursor_mp: usize,
750 cursor_md: usize,
751 cursor_sp: usize,
752 cursor_sd: usize,
753
754 status: String,
755}
756
757async fn fetch_devices_filtered(
758 atlas: &mut AtlasClient,
759 provider_id: &str,
760 kind: &str,
761) -> Vec<crate::pb::audio::AudioDevice> {
762 use crate::pb::contracts::robonix_primitive_audio_list_devices_client::RobonixPrimitiveAudioListDevicesClient;
763 const LIST_CONTRACT: &str = "robonix/primitive/audio/list_devices";
764 if provider_id.is_empty() {
765 return Vec::new();
766 }
767 let endpoint = match atlas
768 .connect_capability(
769 CONSUMER_ID,
770 provider_id,
771 LIST_CONTRACT,
772 atlas_pb::Transport::Grpc,
773 )
774 .await
775 {
776 Ok((_, ep, _)) if ep.starts_with("http") => ep,
777 Ok((_, ep, _)) => format!("http://{ep}"),
778 Err(_) => return Vec::new(),
779 };
780 let mut client = match RobonixPrimitiveAudioListDevicesClient::connect(endpoint).await {
781 Ok(c) => c,
782 Err(_) => return Vec::new(),
783 };
784 let resp = match client
785 .list_audio_devices(crate::pb::audio::ListAudioDevicesRequest {})
786 .await
787 {
788 Ok(r) => r.into_inner(),
789 Err(_) => return Vec::new(),
790 };
791 resp.devices
792 .into_iter()
793 .filter(|d| d.kind == kind || d.kind == "duplex")
794 .collect()
795}
796
797impl AudioSettingsPage {
798 async fn load(atlas: &mut AtlasClient, cfg: &ChatConfig) -> Self {
799 let mic_providers = atlas
800 .query_capabilities("", MIC_CONTRACT, atlas_pb::Transport::Grpc)
801 .await
802 .unwrap_or_default();
803 let speaker_providers = atlas
804 .query_capabilities("", SPEAKER_CONTRACT, atlas_pb::Transport::Grpc)
805 .await
806 .unwrap_or_default();
807
808 let mic_cap_id = cfg.mic_cap_id.clone().unwrap_or_else(|| {
809 mic_providers
810 .first()
811 .map(|p| p.id.clone())
812 .unwrap_or_default()
813 });
814 let speaker_cap_id = cfg.speaker_cap_id.clone().unwrap_or_else(|| {
815 speaker_providers
816 .first()
817 .map(|p| p.id.clone())
818 .unwrap_or_default()
819 });
820
821 let mic_devices = fetch_devices_filtered(atlas, &mic_cap_id, "input").await;
822 let speaker_devices = fetch_devices_filtered(atlas, &speaker_cap_id, "output").await;
823
824 let mic_device_id = cfg.mic_device_id.clone().unwrap_or_default();
825 let speaker_device_id = cfg.speaker_device_id.clone().unwrap_or_default();
826
827 let cursor_mp = mic_providers
830 .iter()
831 .position(|p| p.id == mic_cap_id)
832 .unwrap_or(0);
833 let cursor_sp = speaker_providers
834 .iter()
835 .position(|p| p.id == speaker_cap_id)
836 .unwrap_or(0);
837 let cursor_md = mic_devices
838 .iter()
839 .position(|d| d.id == mic_device_id)
840 .unwrap_or(0);
841 let cursor_sd = speaker_devices
842 .iter()
843 .position(|d| d.id == speaker_device_id)
844 .unwrap_or(0);
845
846 let mut status = String::new();
847 if mic_providers.is_empty() && speaker_providers.is_empty() {
848 status.push_str("no audio providers in atlas — boot the audio package first");
849 }
850
851 Self {
852 mic_providers,
853 speaker_providers,
854 mic_devices,
855 speaker_devices,
856 mic_cap_id,
857 mic_device_id,
858 speaker_cap_id,
859 speaker_device_id,
860 section: AudioSection::MicProvider,
861 cursor_mp,
862 cursor_md,
863 cursor_sp,
864 cursor_sd,
865 status,
866 }
867 }
868
869 fn current_len(&self) -> usize {
870 match self.section {
871 AudioSection::MicProvider => self.mic_providers.len(),
872 AudioSection::MicDevice => self.mic_devices.len(),
873 AudioSection::SpeakerProvider => self.speaker_providers.len(),
874 AudioSection::SpeakerDevice => self.speaker_devices.len(),
875 }
876 }
877 fn current_cursor(&self) -> usize {
878 match self.section {
879 AudioSection::MicProvider => self.cursor_mp,
880 AudioSection::MicDevice => self.cursor_md,
881 AudioSection::SpeakerProvider => self.cursor_sp,
882 AudioSection::SpeakerDevice => self.cursor_sd,
883 }
884 }
885 fn current_cursor_mut(&mut self) -> &mut usize {
886 match self.section {
887 AudioSection::MicProvider => &mut self.cursor_mp,
888 AudioSection::MicDevice => &mut self.cursor_md,
889 AudioSection::SpeakerProvider => &mut self.cursor_sp,
890 AudioSection::SpeakerDevice => &mut self.cursor_sd,
891 }
892 }
893 fn cursor_up(&mut self) {
894 let c = self.current_cursor_mut();
895 if *c > 0 {
896 *c -= 1;
897 }
898 }
899 fn cursor_down(&mut self) {
900 let n = self.current_len();
901 let c = self.current_cursor_mut();
902 if *c + 1 < n {
903 *c += 1;
904 }
905 }
906 fn next_section(&mut self) {
907 self.section = match self.section {
908 AudioSection::MicProvider => AudioSection::MicDevice,
909 AudioSection::MicDevice => AudioSection::SpeakerProvider,
910 AudioSection::SpeakerProvider => AudioSection::SpeakerDevice,
911 AudioSection::SpeakerDevice => AudioSection::MicProvider,
912 };
913 }
914 fn prev_section(&mut self) {
915 self.section = match self.section {
916 AudioSection::MicProvider => AudioSection::SpeakerDevice,
917 AudioSection::MicDevice => AudioSection::MicProvider,
918 AudioSection::SpeakerProvider => AudioSection::MicDevice,
919 AudioSection::SpeakerDevice => AudioSection::SpeakerProvider,
920 };
921 }
922
923 async fn enter(&mut self, atlas: &mut AtlasClient) {
924 let i = self.current_cursor();
925 match self.section {
926 AudioSection::MicProvider => {
927 if let Some(p) = self.mic_providers.get(i) {
928 let new_cap = p.id.clone();
929 if new_cap != self.mic_cap_id {
930 self.mic_cap_id = new_cap.clone();
931 self.mic_devices = fetch_devices_filtered(atlas, &new_cap, "input").await;
932 self.mic_device_id.clear();
933 self.cursor_md = 0;
934 }
935 self.status = format!("mic provider → {new_cap}");
936 }
937 }
938 AudioSection::MicDevice => {
939 if let Some(d) = self.mic_devices.get(i) {
940 let id = d.id.clone();
941 let name = d.name.clone();
942 self.mic_device_id = id.clone();
943 match call_select_device(atlas, &self.mic_cap_id, "input", &id).await {
944 Ok(()) => self.status = format!("mic device → {name} ({id})"),
945 Err(e) => self.status = format!("mic device → {id} (warn: {e:#})"),
946 }
947 }
948 }
949 AudioSection::SpeakerProvider => {
950 if let Some(p) = self.speaker_providers.get(i) {
951 let new_cap = p.id.clone();
952 if new_cap != self.speaker_cap_id {
953 self.speaker_cap_id = new_cap.clone();
954 self.speaker_devices =
955 fetch_devices_filtered(atlas, &new_cap, "output").await;
956 self.speaker_device_id.clear();
957 self.cursor_sd = 0;
958 }
959 self.status = format!("speaker provider → {new_cap}");
960 }
961 }
962 AudioSection::SpeakerDevice => {
963 if let Some(d) = self.speaker_devices.get(i) {
964 let id = d.id.clone();
965 let name = d.name.clone();
966 self.speaker_device_id = id.clone();
967 match call_select_device(atlas, &self.speaker_cap_id, "output", &id).await {
968 Ok(()) => self.status = format!("speaker device → {name} ({id})"),
969 Err(e) => self.status = format!("speaker device → {id} (warn: {e:#})"),
970 }
971 }
972 }
973 }
974 }
975
976 async fn refresh(&mut self, atlas: &mut AtlasClient) {
977 *self = Self::load(
978 atlas,
979 &ChatConfig {
980 mic_cap_id: (!self.mic_cap_id.is_empty()).then(|| self.mic_cap_id.clone()),
981 mic_device_id: (!self.mic_device_id.is_empty()).then(|| self.mic_device_id.clone()),
982 speaker_cap_id: (!self.speaker_cap_id.is_empty())
983 .then(|| self.speaker_cap_id.clone()),
984 speaker_device_id: (!self.speaker_device_id.is_empty())
985 .then(|| self.speaker_device_id.clone()),
986 },
987 )
988 .await;
989 self.status = "refreshed".into();
990 }
991
992 fn draw(&self, frame: &mut ratatui::Frame) {
993 let mut lines: Vec<Line> = Vec::with_capacity(64);
994 let dim = Style::default().fg(Color::DarkGray);
995 let normal = Style::default();
996 let selected_style = Style::default()
997 .fg(Color::Green)
998 .add_modifier(Modifier::BOLD);
999 let cursor_style = Style::default()
1000 .fg(Color::Black)
1001 .bg(Color::Cyan)
1002 .add_modifier(Modifier::BOLD);
1003
1004 let render_provider_row = |i: usize,
1005 p: &atlas_pb::CapabilityProvider,
1006 sel: &str,
1007 in_section: bool,
1008 cursor: usize|
1009 -> Line {
1010 let is_cursor = in_section && i == cursor;
1011 let is_selected = p.id == sel;
1012 let mark_left = if is_cursor { "▶" } else { " " };
1013 let bullet = if is_selected { "●" } else { "○" };
1014 let prefix = format!(" {mark_left} {bullet} ");
1015 let style = if is_cursor {
1016 cursor_style
1017 } else if is_selected {
1018 selected_style
1019 } else {
1020 normal
1021 };
1022 Line::from(vec![Span::styled(format!("{prefix}{}", p.id), style)])
1023 };
1024
1025 let render_device_row = |i: usize,
1026 d: &crate::pb::audio::AudioDevice,
1027 sel: &str,
1028 in_section: bool,
1029 cursor: usize|
1030 -> Line {
1031 let is_cursor = in_section && i == cursor;
1032 let is_selected = d.id == sel;
1033 let mark_left = if is_cursor { "▶" } else { " " };
1034 let bullet = if is_selected { "●" } else { "○" };
1035 let mut tags: Vec<String> = Vec::new();
1036 if d.is_default {
1037 tags.push("default".into());
1038 }
1039 if !d.note.is_empty() {
1040 tags.push(format!("⚠ {}", d.note));
1041 }
1042 let suffix = if tags.is_empty() {
1043 String::new()
1044 } else {
1045 format!(" [{}]", tags.join(", "))
1046 };
1047 let body = format!(" {mark_left} {bullet} {:<10} {}{}", d.id, d.name, suffix);
1048 let style = if is_cursor {
1049 cursor_style
1050 } else if is_selected {
1051 selected_style
1052 } else {
1053 normal
1054 };
1055 Line::from(vec![Span::styled(body, style)])
1056 };
1057
1058 let section_title = |title: &str, active: bool| -> Line {
1059 let bar = "━".repeat(8);
1060 let prefix = if active { "▼" } else { " " };
1061 let style = if active {
1062 Style::default()
1063 .fg(Color::Cyan)
1064 .add_modifier(Modifier::BOLD)
1065 } else {
1066 Style::default()
1067 .fg(Color::Gray)
1068 .add_modifier(Modifier::BOLD)
1069 };
1070 Line::from(vec![Span::styled(
1071 format!("{prefix} {bar} {title} {bar}"),
1072 style,
1073 )])
1074 };
1075
1076 lines.push(Line::from(""));
1078 lines.push(Line::from(vec![Span::styled(
1079 " MICROPHONE",
1080 Style::default()
1081 .fg(Color::Yellow)
1082 .add_modifier(Modifier::BOLD),
1083 )]));
1084 lines.push(section_title(
1085 "Cap (robonix handle)",
1086 self.section == AudioSection::MicProvider,
1087 ));
1088 if self.mic_providers.is_empty() {
1089 lines.push(Line::from(vec![Span::styled(
1090 " (no providers — rbnx boot first)",
1091 dim,
1092 )]));
1093 } else {
1094 for (i, p) in self.mic_providers.iter().enumerate() {
1095 lines.push(render_provider_row(
1096 i,
1097 p,
1098 &self.mic_cap_id,
1099 self.section == AudioSection::MicProvider,
1100 self.cursor_mp,
1101 ));
1102 }
1103 }
1104 lines.push(section_title(
1105 "Driver-internal device",
1106 self.section == AudioSection::MicDevice,
1107 ));
1108 if self.mic_devices.is_empty() {
1109 lines.push(Line::from(vec![Span::styled(
1110 " (no devices — impl missing list_devices, or rebuild package)",
1111 dim,
1112 )]));
1113 } else {
1114 for (i, d) in self.mic_devices.iter().enumerate() {
1115 lines.push(render_device_row(
1116 i,
1117 d,
1118 &self.mic_device_id,
1119 self.section == AudioSection::MicDevice,
1120 self.cursor_md,
1121 ));
1122 }
1123 }
1124
1125 lines.push(Line::from(""));
1127 lines.push(Line::from(vec![Span::styled(
1128 " SPEAKER",
1129 Style::default()
1130 .fg(Color::Yellow)
1131 .add_modifier(Modifier::BOLD),
1132 )]));
1133 lines.push(section_title(
1134 "Cap (robonix handle)",
1135 self.section == AudioSection::SpeakerProvider,
1136 ));
1137 if self.speaker_providers.is_empty() {
1138 lines.push(Line::from(vec![Span::styled(
1139 " (no providers — rbnx boot first)",
1140 dim,
1141 )]));
1142 } else {
1143 for (i, p) in self.speaker_providers.iter().enumerate() {
1144 lines.push(render_provider_row(
1145 i,
1146 p,
1147 &self.speaker_cap_id,
1148 self.section == AudioSection::SpeakerProvider,
1149 self.cursor_sp,
1150 ));
1151 }
1152 }
1153 lines.push(section_title(
1154 "Driver-internal device",
1155 self.section == AudioSection::SpeakerDevice,
1156 ));
1157 if self.speaker_devices.is_empty() {
1158 lines.push(Line::from(vec![Span::styled(
1159 " (no devices — impl missing list_devices, or rebuild package)",
1160 dim,
1161 )]));
1162 } else {
1163 for (i, d) in self.speaker_devices.iter().enumerate() {
1164 lines.push(render_device_row(
1165 i,
1166 d,
1167 &self.speaker_device_id,
1168 self.section == AudioSection::SpeakerDevice,
1169 self.cursor_sd,
1170 ));
1171 }
1172 }
1173
1174 lines.push(Line::from(""));
1175
1176 let area = frame.area();
1177 let chunks = Layout::vertical([
1178 Constraint::Min(0),
1179 Constraint::Length(1),
1180 Constraint::Length(1),
1181 ])
1182 .split(area);
1183
1184 let body = Paragraph::new(lines)
1185 .block(Block::default().borders(Borders::ALL).title(
1186 " Audio Settings — Cap = robonix handle · Driver-internal device = optional, multi-device drivers only ",
1187 ))
1188 .wrap(Wrap { trim: false });
1189 frame.render_widget(body, chunks[0]);
1190
1191 let status_line = if self.status.is_empty() {
1192 String::from(" ")
1193 } else {
1194 format!(" {}", self.status)
1195 };
1196 let status = Paragraph::new(status_line).style(Style::default().fg(Color::Cyan));
1197 frame.render_widget(status, chunks[1]);
1198
1199 let help = Paragraph::new(
1200 " Tab/Shift+Tab: section · ↑↓ jk: item · Enter/Space: pick · r: refresh · Esc/Ctrl+A: close",
1201 )
1202 .style(dim);
1203 frame.render_widget(help, chunks[2]);
1204 }
1205}
1206
1207async fn run_audio_settings_page(
1208 atlas_endpoint: &str,
1209 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1210 cfg: ChatConfig,
1211) -> Result<ChatConfig> {
1212 let mut atlas = AtlasClient::connect(atlas_endpoint)
1213 .await
1214 .with_context(|| format!("connect to atlas at '{atlas_endpoint}' for audio settings"))?;
1215 let mut page = AudioSettingsPage::load(&mut atlas, &cfg).await;
1216
1217 loop {
1218 terminal.draw(|f| page.draw(f))?;
1219 if event::poll(std::time::Duration::from_millis(150))?
1220 && let Event::Key(key) = event::read()?
1221 {
1222 match (key.modifiers, key.code) {
1223 (_, KeyCode::Tab) => page.next_section(),
1224 (_, KeyCode::BackTab) => page.prev_section(),
1225 (_, KeyCode::Up) | (_, KeyCode::Char('k')) => page.cursor_up(),
1226 (_, KeyCode::Down) | (_, KeyCode::Char('j')) => page.cursor_down(),
1227 (_, KeyCode::Enter) | (_, KeyCode::Char(' ')) => page.enter(&mut atlas).await,
1228 (_, KeyCode::Char('r')) => page.refresh(&mut atlas).await,
1229 (KeyModifiers::CONTROL, KeyCode::Char('a'))
1230 | (_, KeyCode::Esc)
1231 | (_, KeyCode::Char('q')) => break,
1232 _ => {}
1233 }
1234 }
1235 }
1236
1237 let new_cfg = ChatConfig {
1238 mic_cap_id: (!page.mic_cap_id.is_empty()).then_some(page.mic_cap_id),
1239 mic_device_id: (!page.mic_device_id.is_empty()).then_some(page.mic_device_id),
1240 speaker_cap_id: (!page.speaker_cap_id.is_empty()).then_some(page.speaker_cap_id),
1241 speaker_device_id: (!page.speaker_device_id.is_empty()).then_some(page.speaker_device_id),
1242 };
1243 if let Err(e) = save_chat_config(&new_cfg) {
1244 log::warn!("could not save chat config: {e:#}");
1245 }
1246 Ok(new_cfg)
1247}
1248
1249fn flash_picker_message(
1254 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1255 msg: &str,
1256) -> Result<()> {
1257 terminal.draw(|f| {
1258 let body = Paragraph::new(msg)
1259 .block(
1260 Block::default()
1261 .borders(Borders::ALL)
1262 .title(" rbnx chat — audio "),
1263 )
1264 .wrap(Wrap { trim: false });
1265 f.render_widget(body, f.area());
1266 })?;
1267 std::thread::sleep(std::time::Duration::from_millis(1400));
1268 Ok(())
1269}
1270
1271async fn run_tui(
1272 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1273 atlas_endpoint: &str,
1274 liaison_endpoint: &str,
1275 mut chat_cfg: ChatConfig,
1276 audio_warnings: &[String],
1277) -> Result<()> {
1278 let local_user = format!("local:{}", whoami_username());
1279 let mut initial: Vec<ChatMessage> = Vec::with_capacity(1 + audio_warnings.len());
1280 initial.push(ChatMessage {
1281 role: Role::Status,
1282 text: format!(
1283 "Connected to Liaison at {liaison_endpoint} as {local_user}. \
1284 Enter = send · Ctrl+V = voice (auto end on silence) · Ctrl+A = audio settings · Esc = abort turn · Ctrl+C = quit."
1285 ),
1286 });
1287 for w in audio_warnings {
1288 initial.push(ChatMessage {
1289 role: Role::Status,
1290 text: w.clone(),
1291 });
1292 }
1293 let messages: Rc<RefCell<Vec<ChatMessage>>> = Rc::new(RefCell::new(initial));
1294 let mut input = String::new();
1295 let mut scroll: u16 = 0;
1296 let mut busy = false;
1297 let session_id = Uuid::new_v4().to_string();
1298
1299 loop {
1300 draw(terminal, &messages.borrow(), &input, scroll, busy)?;
1301
1302 if event::poll(std::time::Duration::from_millis(50))?
1303 && let Event::Key(key) = event::read()?
1304 {
1305 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1306 let _ = notify_session_end(liaison_endpoint, &session_id, &local_user).await;
1307 break;
1308 }
1309
1310 if !busy
1315 && key.modifiers.contains(KeyModifiers::CONTROL)
1316 && key.code == KeyCode::Char('a')
1317 {
1318 match run_audio_settings_page(atlas_endpoint, terminal, chat_cfg.clone()).await {
1319 Ok(new_cfg) => {
1320 chat_cfg = new_cfg;
1321 messages.borrow_mut().push(ChatMessage {
1322 role: Role::Status,
1323 text: format!(
1324 "audio settings updated: mic={}/{} · speaker={}/{}",
1325 chat_cfg.mic_cap_id.as_deref().unwrap_or("(unset)"),
1326 chat_cfg.mic_device_id.as_deref().unwrap_or("default"),
1327 chat_cfg.speaker_cap_id.as_deref().unwrap_or("(unset)"),
1328 chat_cfg.speaker_device_id.as_deref().unwrap_or("default"),
1329 ),
1330 });
1331 }
1332 Err(e) => messages.borrow_mut().push(ChatMessage {
1333 role: Role::Status,
1334 text: format!("audio settings: {e:#}"),
1335 }),
1336 }
1337 continue;
1338 }
1339
1340 if !busy
1342 && key.modifiers.contains(KeyModifiers::CONTROL)
1343 && key.code == KeyCode::Char('v')
1344 {
1345 busy = true;
1346 messages.borrow_mut().push(ChatMessage {
1347 role: Role::Status,
1348 text: "Ctrl+V — starting voice session…".to_string(),
1349 });
1350 draw(terminal, &messages.borrow(), &input, scroll, busy)?;
1351 if let Err(e) = run_voice_session_with_esc_abort(
1352 liaison_endpoint,
1353 &session_id,
1354 &local_user,
1355 Rc::clone(&messages),
1356 terminal,
1357 &input,
1358 &mut scroll,
1359 &chat_cfg,
1360 )
1361 .await
1362 {
1363 messages.borrow_mut().push(ChatMessage {
1364 role: Role::Status,
1365 text: format!("Voice error: {e:#}"),
1366 });
1367 }
1368 busy = false;
1369 continue;
1370 }
1371
1372 if busy {
1373 match key.code {
1374 KeyCode::PageUp => scroll = scroll.saturating_add(5),
1375 KeyCode::PageDown => scroll = scroll.saturating_sub(5),
1376 _ => {}
1377 }
1378 continue;
1379 }
1380 match key.code {
1381 KeyCode::Enter => {
1382 let msg = input.trim().to_string();
1383 input.clear();
1384 scroll = 0;
1385 if msg.is_empty() {
1386 continue;
1387 }
1388 if msg == "quit" || msg == "exit" {
1389 let _ =
1390 notify_session_end(liaison_endpoint, &session_id, &local_user).await;
1391 break;
1392 }
1393 messages.borrow_mut().push(ChatMessage {
1394 role: Role::User,
1395 text: msg.clone(),
1396 });
1397 busy = true;
1398 draw(terminal, &messages.borrow(), &input, scroll, busy)?;
1399
1400 match run_text_intent_with_esc_abort(
1401 liaison_endpoint,
1402 &session_id,
1403 &local_user,
1404 &msg,
1405 Rc::clone(&messages),
1406 terminal,
1407 &input,
1408 &mut scroll,
1409 )
1410 .await
1411 {
1412 Ok(()) => {}
1413 Err(e) => {
1414 messages.borrow_mut().push(ChatMessage {
1415 role: Role::Status,
1416 text: format!("Error: {e:#}"),
1417 });
1418 }
1419 }
1420 busy = false;
1421 }
1422 KeyCode::Char(c) => input.push(c),
1423 KeyCode::Backspace => {
1424 input.pop();
1425 }
1426 KeyCode::PageUp => scroll = scroll.saturating_add(5),
1427 KeyCode::PageDown => scroll = scroll.saturating_sub(5),
1428 KeyCode::Esc if !input.is_empty() => {
1434 input.clear();
1435 }
1436 _ => {}
1437 }
1438 }
1439 }
1440 Ok(())
1441}
1442
1443fn build_text_task(session_id: &str, user_id: &str, text: &str) -> crate::pb::pilot::Task {
1446 use crate::pb::pilot::Task;
1447 const INTENT_SOURCE_TEXT: u32 = 0;
1448 Task {
1449 task_id: Uuid::new_v4().to_string(),
1450 session_id: session_id.to_string(),
1451 source: INTENT_SOURCE_TEXT,
1452 text: text.to_string(),
1453 audio_data: vec![],
1454 context_json: serde_json::json!({"user_id": user_id, "modality": "text"}).to_string(),
1455 timestamp_ms: now_ms(),
1456 }
1457}
1458
1459fn build_control_task(
1460 session_id: &str,
1461 user_id: &str,
1462 extra_context_json: &str,
1463) -> crate::pb::pilot::Task {
1464 use crate::pb::pilot::Task;
1465 const INTENT_SOURCE_TEXT: u32 = 0;
1466 let mut ctx: serde_json::Value =
1467 serde_json::from_str(extra_context_json.trim()).unwrap_or_else(|_| serde_json::json!({}));
1468 if let Some(obj) = ctx.as_object_mut() {
1469 obj.entry("user_id").or_insert(serde_json::json!(user_id));
1470 }
1471 Task {
1472 task_id: Uuid::new_v4().to_string(),
1473 session_id: session_id.to_string(),
1474 source: INTENT_SOURCE_TEXT,
1475 text: String::new(),
1476 audio_data: vec![],
1477 context_json: ctx.to_string(),
1478 timestamp_ms: now_ms(),
1479 }
1480}
1481
1482async fn notify_session_end(liaison_endpoint: &str, session_id: &str, user_id: &str) -> Result<()> {
1483 use crate::pb::contracts::robonix_system_liaison_submit_client::RobonixSystemLiaisonSubmitClient;
1484
1485 let mut client = RobonixSystemLiaisonSubmitClient::connect(liaison_endpoint.to_string())
1486 .await
1487 .context("failed to connect to Liaison for session_end")?;
1488
1489 let task = build_control_task(session_id, user_id, r#"{"session_end":true}"#);
1490 let mut stream = client
1491 .submit_task(tonic::Request::new(task))
1492 .await
1493 .context("Liaison Stream session_end failed")?
1494 .into_inner();
1495 while stream.next().await.is_some() {}
1496 Ok(())
1497}
1498
1499async fn abort_session(liaison_endpoint: &str, session_id: &str, user_id: &str) -> Result<()> {
1500 use crate::pb::contracts::robonix_system_liaison_submit_client::RobonixSystemLiaisonSubmitClient;
1501
1502 let mut client = RobonixSystemLiaisonSubmitClient::connect(liaison_endpoint.to_string())
1503 .await
1504 .context("failed to connect to Liaison for abort_turn")?;
1505
1506 let task = build_control_task(session_id, user_id, r#"{"abort_turn":true}"#);
1507 let _ = client
1508 .submit_task(tonic::Request::new(task))
1509 .await
1510 .context("Liaison abort_turn Stream failed")?;
1511 Ok(())
1512}
1513
1514#[allow(clippy::too_many_arguments)]
1517async fn run_text_intent_with_esc_abort(
1518 liaison_endpoint: &str,
1519 session_id: &str,
1520 user_id: &str,
1521 user_msg: &str,
1522 messages: Rc<RefCell<Vec<ChatMessage>>>,
1523 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1524 input: &str,
1525 scroll: &mut u16,
1526) -> Result<()> {
1527 use crate::pb::contracts::robonix_system_liaison_submit_client::RobonixSystemLiaisonSubmitClient;
1528 use crate::pb::pilot::PilotEvent;
1529 use tonic::Status;
1530
1531 let (tx, mut rx) = tokio::sync::mpsc::channel::<Result<PilotEvent, Status>>(64);
1532 let liaison_ep = liaison_endpoint.to_string();
1533 let task = build_text_task(session_id, user_id, user_msg);
1534
1535 let _stream_task = tokio::spawn(async move {
1536 let mut client = match RobonixSystemLiaisonSubmitClient::connect(liaison_ep.clone()).await {
1537 Ok(c) => c,
1538 Err(e) => {
1539 let _ = tx.send(Err(Status::unavailable(e.to_string()))).await;
1540 return;
1541 }
1542 };
1543 let stream = match client.submit_task(tonic::Request::new(task)).await {
1544 Ok(r) => r.into_inner(),
1545 Err(e) => {
1546 let _ = tx.send(Err(e)).await;
1547 return;
1548 }
1549 };
1550 let mut stream = stream;
1551 while let Some(item) = stream.next().await {
1552 if tx.send(item).await.is_err() {
1553 break;
1554 }
1555 }
1556 });
1557
1558 loop {
1559 tokio::select! {
1560 biased;
1561 item = rx.recv() => {
1562 match item {
1563 None => break,
1564 Some(Ok(event)) => {
1565 apply_pilot_event(&messages, &event)?;
1566 draw(terminal, &messages.borrow(), input, 0, true)?;
1567 }
1568 Some(Err(e)) => {
1569 messages.borrow_mut().push(ChatMessage {
1570 role: Role::Status,
1571 text: format!("Liaison stream error: {e}"),
1572 });
1573 draw(terminal, &messages.borrow(), input, 0, true)?;
1574 break;
1575 }
1576 }
1577 }
1578 _ = tokio::time::sleep(std::time::Duration::from_millis(25)) => {
1579 if event::poll(std::time::Duration::ZERO)?
1580 && let Event::Key(key) = event::read()? {
1581 match key.code {
1582 KeyCode::Esc => {
1583 let _ = abort_session(liaison_endpoint, session_id, user_id).await;
1584 messages.borrow_mut().push(ChatMessage {
1585 role: Role::Status,
1586 text: "Esc — abort_turn sent (in-flight turn should stop)."
1587 .to_string(),
1588 });
1589 draw(terminal, &messages.borrow(), input, *scroll, true)?;
1590 }
1591 KeyCode::PageUp => {
1592 *scroll = scroll.saturating_add(5);
1593 draw(terminal, &messages.borrow(), input, *scroll, true)?;
1594 }
1595 KeyCode::PageDown => {
1596 *scroll = scroll.saturating_sub(5);
1597 draw(terminal, &messages.borrow(), input, *scroll, true)?;
1598 }
1599 _ => {}
1600 }
1601 }
1602 }
1603 }
1604 }
1605 Ok(())
1606}
1607
1608#[allow(clippy::too_many_arguments)]
1611async fn run_voice_session_with_esc_abort(
1612 liaison_endpoint: &str,
1613 session_id: &str,
1614 user_id: &str,
1615 messages: Rc<RefCell<Vec<ChatMessage>>>,
1616 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1617 input: &str,
1618 scroll: &mut u16,
1619 chat_cfg: &ChatConfig,
1620) -> Result<()> {
1621 use crate::pb::contracts::robonix_system_liaison_voice_client::RobonixSystemLiaisonVoiceClient;
1622 use crate::pb::liaison::{StartVoiceSessionRequest, VoiceEvent};
1623 use tonic::Status;
1624
1625 let req = StartVoiceSessionRequest {
1626 session_id: session_id.to_string(),
1627 client_user_id: user_id.to_string(),
1628 record_seconds: voice_record_seconds(),
1629 language: voice_language(),
1630 tts_enabled: voice_tts_enabled(),
1631 mic_node_id: voice_node_with_cfg("ROBONIX_CHAT_MIC_NODE", chat_cfg.mic_cap_id.as_deref()),
1632 asr_node_id: voice_node("ROBONIX_CHAT_ASR_NODE"),
1633 voiceprint_node_id: String::new(),
1634 tts_node_id: voice_node("ROBONIX_CHAT_TTS_NODE"),
1635 speaker_node_id: voice_node_with_cfg(
1636 "ROBONIX_CHAT_SPEAKER_NODE",
1637 chat_cfg.speaker_cap_id.as_deref(),
1638 ),
1639 context_json: String::new(),
1640 };
1641
1642 let (tx, mut rx) = tokio::sync::mpsc::channel::<Result<VoiceEvent, Status>>(64);
1643 let liaison_ep = liaison_endpoint.to_string();
1644
1645 let _stream_task = tokio::spawn(async move {
1646 let mut client = match RobonixSystemLiaisonVoiceClient::connect(liaison_ep.clone()).await {
1647 Ok(c) => c,
1648 Err(e) => {
1649 let _ = tx.send(Err(Status::unavailable(e.to_string()))).await;
1650 return;
1651 }
1652 };
1653 let stream = match client.start_voice_session(tonic::Request::new(req)).await {
1654 Ok(r) => r.into_inner(),
1655 Err(e) => {
1656 let _ = tx.send(Err(e)).await;
1657 return;
1658 }
1659 };
1660 let mut stream = stream;
1661 while let Some(item) = stream.next().await {
1662 if tx.send(item).await.is_err() {
1663 break;
1664 }
1665 }
1666 });
1667
1668 loop {
1669 tokio::select! {
1670 biased;
1671 item = rx.recv() => {
1672 match item {
1673 None => break,
1674 Some(Ok(event)) => {
1675 apply_voice_event(&messages, &event)?;
1676 draw(terminal, &messages.borrow(), input, 0, true)?;
1677 }
1678 Some(Err(e)) => {
1679 messages.borrow_mut().push(ChatMessage {
1680 role: Role::Status,
1681 text: format!("Voice stream error: {e}"),
1682 });
1683 draw(terminal, &messages.borrow(), input, 0, true)?;
1684 break;
1685 }
1686 }
1687 }
1688 _ = tokio::time::sleep(std::time::Duration::from_millis(25)) => {
1689 if event::poll(std::time::Duration::ZERO)?
1690 && let Event::Key(key) = event::read()? {
1691 match key.code {
1692 KeyCode::Esc => {
1693 let _ = abort_session(liaison_endpoint, session_id, user_id).await;
1694 messages.borrow_mut().push(ChatMessage {
1695 role: Role::Status,
1696 text: "Esc — abort_turn sent (Pilot stops; voice playback may still finish).".to_string(),
1697 });
1698 draw(terminal, &messages.borrow(), input, *scroll, true)?;
1699 }
1700 KeyCode::PageUp => {
1701 *scroll = scroll.saturating_add(5);
1702 draw(terminal, &messages.borrow(), input, *scroll, true)?;
1703 }
1704 KeyCode::PageDown => {
1705 *scroll = scroll.saturating_sub(5);
1706 draw(terminal, &messages.borrow(), input, *scroll, true)?;
1707 }
1708 _ => {}
1709 }
1710 }
1711 }
1712 }
1713 }
1714 Ok(())
1715}
1716
1717fn voice_record_seconds() -> u32 {
1718 std::env::var("ROBONIX_CHAT_VOICE_SECONDS")
1719 .ok()
1720 .and_then(|s| s.parse().ok())
1721 .unwrap_or(0) }
1723
1724fn voice_language() -> String {
1725 std::env::var("ROBONIX_CHAT_VOICE_LANG").unwrap_or_default()
1726}
1727
1728fn voice_tts_enabled() -> bool {
1729 !matches!(
1730 std::env::var("ROBONIX_CHAT_VOICE_TTS").as_deref(),
1731 Ok("0") | Ok("false") | Ok("no") | Ok("off")
1732 )
1733}
1734
1735fn voice_node(env_key: &str) -> String {
1736 std::env::var(env_key).unwrap_or_default()
1737}
1738
1739fn voice_node_with_cfg(env_key: &str, cfg_value: Option<&str>) -> String {
1743 if let Ok(v) = std::env::var(env_key)
1744 && !v.is_empty()
1745 {
1746 return v;
1747 }
1748 cfg_value.unwrap_or("").to_string()
1749}
1750
1751fn apply_pilot_event(
1754 messages: &Rc<RefCell<Vec<ChatMessage>>>,
1755 event: &crate::pb::pilot::PilotEvent,
1756) -> Result<()> {
1757 const EVT_TEXT_CHUNK: u32 = 0;
1759 const EVT_PLAN: u32 = 1;
1760 const EVT_FINAL_TEXT: u32 = 4;
1761
1762 let mut m = messages.borrow_mut();
1763 match event.event_kind {
1764 EVT_TEXT_CHUNK => {
1765 let t = event.text_chunk.clone();
1766 if let Some(last) = m.last_mut() {
1767 if matches!(last.role, Role::Agent) {
1768 last.text.push_str(&t);
1769 } else {
1770 m.push(ChatMessage {
1771 role: Role::Agent,
1772 text: t,
1773 });
1774 }
1775 } else {
1776 m.push(ChatMessage {
1777 role: Role::Agent,
1778 text: t,
1779 });
1780 }
1781 }
1782 EVT_FINAL_TEXT => {
1783 let t = event.final_text.clone();
1784 let has_agent = m.last().is_some_and(|x| matches!(x.role, Role::Agent));
1785 if !has_agent && !t.is_empty() {
1786 m.push(ChatMessage {
1787 role: Role::Agent,
1788 text: t,
1789 });
1790 }
1791 }
1792 EVT_PLAN => {
1793 if let Some(ref p) = event.plan {
1799 for call in plan_calls(p) {
1800 let leaf = call
1801 .contract_id
1802 .rsplit_once('/')
1803 .map(|(_, l)| l.to_string())
1804 .unwrap_or_else(|| call.contract_id.clone());
1805 m.push(ChatMessage {
1806 role: Role::ToolCall,
1807 text: format!("[r{}] {}({})", p.round, leaf, call.args_json),
1808 });
1809 }
1810 }
1811 }
1812 _ => {}
1813 }
1814 Ok(())
1815}
1816
1817fn plan_calls(plan: &crate::pb::pilot::Plan) -> Vec<&crate::pb::pilot::CapabilityCall> {
1818 plan.nodes
1819 .iter()
1820 .filter_map(|node| node.call.as_ref())
1821 .collect()
1822}
1823
1824fn apply_voice_event(
1825 messages: &Rc<RefCell<Vec<ChatMessage>>>,
1826 event: &crate::pb::liaison::VoiceEvent,
1827) -> Result<()> {
1828 const KIND_SESSION_STARTED: u32 = 0;
1830 const KIND_RECORDING_STARTED: u32 = 1;
1831 const KIND_RECORDING_DONE: u32 = 2;
1832 const KIND_ASR_PARTIAL: u32 = 3;
1833 const KIND_ASR_FINAL: u32 = 4;
1834 const KIND_USER_IDENTIFIED: u32 = 5;
1835 const KIND_PILOT: u32 = 6;
1836 const KIND_TTS_STARTED: u32 = 7;
1837 const KIND_TTS_DONE: u32 = 8;
1838 const KIND_SESSION_DONE: u32 = 9;
1839 const KIND_ERROR: u32 = 10;
1840
1841 match event.event_kind {
1842 KIND_SESSION_STARTED | KIND_RECORDING_STARTED | KIND_RECORDING_DONE => {
1843 messages.borrow_mut().push(ChatMessage {
1844 role: Role::Voice,
1845 text: format!("voice · {}", event.status_message),
1846 });
1847 }
1848 KIND_ASR_PARTIAL => {
1849 messages.borrow_mut().push(ChatMessage {
1850 role: Role::Voice,
1851 text: format!("asr (partial, {:.2}): {}", event.confidence, event.text),
1852 });
1853 }
1854 KIND_ASR_FINAL => {
1855 messages.borrow_mut().push(ChatMessage {
1856 role: Role::User,
1857 text: format!("(voice) {}", event.text),
1858 });
1859 }
1860 KIND_USER_IDENTIFIED => {
1861 let label = if event.status_message.is_empty() {
1862 format!("identified user → {}", event.user_id)
1863 } else {
1864 format!(
1865 "identified user → {} · {}",
1866 event.user_id, event.status_message
1867 )
1868 };
1869 messages.borrow_mut().push(ChatMessage {
1870 role: Role::Voice,
1871 text: label,
1872 });
1873 }
1874 KIND_PILOT => {
1875 if let Some(ref pe) = event.pilot {
1876 apply_pilot_event(messages, pe)?;
1877 }
1878 }
1879 KIND_TTS_STARTED => {
1880 messages.borrow_mut().push(ChatMessage {
1881 role: Role::Voice,
1882 text: format!("tts · {}", event.status_message),
1883 });
1884 }
1885 KIND_TTS_DONE => {
1886 messages.borrow_mut().push(ChatMessage {
1887 role: Role::Voice,
1888 text: format!("tts done · {}", event.status_message),
1889 });
1890 }
1891 KIND_SESSION_DONE => {
1892 messages.borrow_mut().push(ChatMessage {
1893 role: Role::Status,
1894 text: "voice session done".to_string(),
1895 });
1896 }
1897 KIND_ERROR => {
1898 messages.borrow_mut().push(ChatMessage {
1899 role: Role::Status,
1900 text: format!("voice error: {}", event.error),
1901 });
1902 }
1903 _ => {
1904 messages.borrow_mut().push(ChatMessage {
1905 role: Role::Voice,
1906 text: format!("voice (kind={}) {}", event.event_kind, event.status_message),
1907 });
1908 }
1909 }
1910 Ok(())
1911}
1912
1913fn draw(
1914 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1915 messages: &[ChatMessage],
1916 input: &str,
1917 scroll: u16,
1918 busy: bool,
1919) -> Result<()> {
1920 terminal.draw(|f| {
1921 let area = f.area();
1922 let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).split(area);
1923
1924 let mut lines: Vec<Line> = Vec::new();
1925 for msg in messages {
1926 let (prefix, indent, style) = match msg.role {
1927 Role::User => (
1928 "You: ",
1929 " ",
1930 Style::default()
1931 .fg(Color::Cyan)
1932 .add_modifier(Modifier::BOLD),
1933 ),
1934 Role::Agent => ("Robonix: ", " ", Style::default().fg(Color::Green)),
1940 Role::ToolCall => ("> ", " ", Style::default().fg(Color::Yellow)),
1941 Role::Status => (
1942 "",
1943 "",
1944 Style::default()
1945 .fg(Color::Magenta)
1946 .add_modifier(Modifier::ITALIC),
1947 ),
1948 Role::Voice => (
1949 "[v] ",
1950 " ",
1951 Style::default()
1952 .fg(Color::Blue)
1953 .add_modifier(Modifier::ITALIC),
1954 ),
1955 };
1956 for (i, text_line) in msg.text.lines().enumerate() {
1957 let lead = if i == 0 { prefix } else { indent };
1958 lines.push(Line::from(vec![
1959 Span::styled(lead, style),
1960 Span::styled(text_line.to_string(), style),
1961 ]));
1962 }
1963 }
1964
1965 let status = if busy { " [thinking...]" } else { "" };
1966 let block = Block::default()
1967 .borders(Borders::ALL)
1968 .title(format!(" Robonix{status} "));
1969 let inner = block.inner(chunks[0]);
1970
1971 let text_only = Paragraph::new(lines.clone()).wrap(Wrap { trim: false });
1972 let total_lines = text_only.line_count(inner.width) as u16;
1973 let visible = inner.height;
1974 let auto_scroll = if scroll == 0 {
1975 total_lines.saturating_sub(visible)
1976 } else {
1977 total_lines.saturating_sub(visible).saturating_sub(scroll)
1978 };
1979
1980 let history = Paragraph::new(lines)
1981 .block(block)
1982 .wrap(Wrap { trim: false })
1983 .scroll((auto_scroll, 0));
1984 f.render_widget(history, chunks[0]);
1985
1986 let input_widget =
1987 Paragraph::new(input.to_string()).block(Block::default().borders(Borders::ALL).title(
1988 " > Enter = send · Ctrl+V = voice (auto end) · Esc = abort · Ctrl+C = quit ",
1989 ));
1990 f.render_widget(input_widget, chunks[1]);
1991 })?;
1992 Ok(())
1993}
1994
1995fn now_ms() -> u64 {
1996 std::time::SystemTime::now()
1997 .duration_since(std::time::UNIX_EPOCH)
1998 .unwrap_or_default()
1999 .as_millis() as u64
2000}
2001
2002fn localhost_to_ipv4_loopback(url: &str) -> String {
2005 url.replace("localhost", "127.0.0.1")
2006}
2007
2008fn whoami_username() -> String {
2012 std::env::var("USER")
2013 .or_else(|_| std::env::var("USERNAME"))
2014 .unwrap_or_else(|_| "user".to_string())
2015}