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 robonix_scribe::{info, warn};
32use std::cell::RefCell;
33use std::collections::HashMap;
34use std::io;
35use std::rc::Rc;
36use tokio_stream::StreamExt;
37use uuid::Uuid;
38
39const CONSUMER_ID: &str = "rbnx-cli/chat";
40const LIAISON_CONTRACT_ID: &str = "robonix/system/liaison/submit";
44
45const MIC_CONTRACT: &str = "robonix/primitive/audio/mic";
50const SPEAKER_CONTRACT: &str = "robonix/primitive/audio/speaker";
51
52struct ChatMessage {
53 role: Role,
54 text: String,
55}
56
57enum Role {
58 User,
59 Agent,
60 ToolCall,
61 Status,
62 Voice,
63}
64
65#[derive(Default)]
68struct ForestView {
69 goal: String,
70 success_criterion: String,
71 status: String,
72 plans: Vec<PlanView>,
74}
75
76struct PlanView {
77 plan_id: String,
78 plan: crate::pb::pilot::Plan,
79 node_states: HashMap<u32, u32>,
81 done: bool,
82 any_failed: bool,
83}
84
85impl ForestView {
86 fn begin_turn(&mut self) {
88 self.goal.clear();
89 self.success_criterion.clear();
90 self.status.clear();
91 self.plans.clear();
92 }
93
94 fn plan_mut(&mut self, plan_id: &str) -> Option<&mut PlanView> {
95 self.plans.iter_mut().find(|p| p.plan_id == plan_id)
96 }
97}
98
99fn cap_short_name(provider_id: &str, contract_id: &str) -> String {
107 let mut segs = contract_id.rsplit('/');
108 let leaf = segs.next().unwrap_or(contract_id);
109 let area = segs.next().unwrap_or("");
110 let llm_name = if area.is_empty() {
111 leaf.to_string()
112 } else {
113 format!("{area}_{leaf}")
114 };
115 if provider_id.is_empty() {
116 llm_name
117 } else {
118 format!("{provider_id}.{llm_name}")
119 }
120}
121
122fn compact_call_label(provider_id: &str, contract_id: &str, args_json: &str) -> String {
127 let name = cap_short_name(provider_id, contract_id);
128 let flat = args_json.split_whitespace().collect::<Vec<_>>().join(" ");
129 const MAX: usize = 48;
130 let total = flat.chars().count();
131 let shown = if total > MAX {
132 let head: String = flat.chars().take(MAX).collect();
133 format!("{head}… (+{} chars)", total - MAX)
134 } else {
135 flat
136 };
137 format!("{name}({shown})")
138}
139
140fn node_state_glyph(state: Option<u32>) -> (&'static str, Style) {
142 match state {
143 Some(1) => (
144 "▸",
145 Style::default()
146 .fg(Color::Yellow)
147 .add_modifier(Modifier::BOLD),
148 ),
149 Some(2) => ("✓", Style::default().fg(Color::Green)),
150 Some(3) => ("✗", Style::default().fg(Color::Red)),
151 Some(4) => ("⊘", Style::default().fg(Color::Magenta)),
152 Some(5) => ("⏱", Style::default().fg(Color::Red)),
153 Some(6) => ("⏸", Style::default().fg(Color::Blue)),
154 _ => ("·", Style::default().fg(Color::DarkGray)),
155 }
156}
157
158fn forest_panel_lines(forest: &ForestView) -> Vec<Line<'static>> {
161 let header = |s: &str| {
162 Line::from(Span::styled(
163 s.to_string(),
164 Style::default()
165 .fg(Color::Cyan)
166 .add_modifier(Modifier::BOLD),
167 ))
168 };
169 let mut lines: Vec<Line<'static>> = Vec::new();
170 lines.push(header("Goal"));
171 lines.push(Line::from(if forest.goal.is_empty() {
172 "(none yet)".to_string()
173 } else {
174 forest.goal.clone()
175 }));
176 if !forest.success_criterion.is_empty() {
177 lines.push(Line::from(Span::styled(
178 format!("done when: {}", forest.success_criterion),
179 Style::default().fg(Color::DarkGray),
180 )));
181 }
182 let (status_txt, status_style) = match forest.status.as_str() {
183 "done" => ("done", Style::default().fg(Color::Green)),
184 "in_progress" => ("in_progress", Style::default().fg(Color::Yellow)),
185 _ => ("—", Style::default().fg(Color::DarkGray)),
186 };
187 lines.push(Line::from(vec![
188 Span::raw("status: "),
189 Span::styled(status_txt.to_string(), status_style),
190 ]));
191 lines.push(Line::from(""));
192 lines.push(header("Forest"));
193 if forest.plans.is_empty() {
194 lines.push(Line::from(Span::styled(
195 "(no trees yet)".to_string(),
196 Style::default().fg(Color::DarkGray),
197 )));
198 }
199 for pv in &forest.plans {
200 let (marker, head_style) = if pv.done {
201 if pv.any_failed {
202 ("✗", Style::default().fg(Color::Red))
203 } else {
204 ("✓", Style::default().fg(Color::Green))
205 }
206 } else {
207 (
208 "▸",
209 Style::default()
210 .fg(Color::Yellow)
211 .add_modifier(Modifier::BOLD),
212 )
213 };
214 lines.push(Line::from(Span::styled(
215 format!("{marker} plan {}", pv.plan_id),
216 head_style,
217 )));
218 forest_node_lines(pv, pv.plan.root_index as usize, "", true, true, &mut lines);
219 }
220 lines
221}
222
223fn forest_node_lines(
224 pv: &PlanView,
225 idx: usize,
226 prefix: &str,
227 is_root: bool,
228 is_last: bool,
229 out: &mut Vec<Line<'static>>,
230) {
231 if idx >= pv.plan.nodes.len() {
232 return;
233 }
234 let node = &pv.plan.nodes[idx];
235 let (glyph, style) = node_state_glyph(pv.node_states.get(&(idx as u32)).copied());
236 let label = match node.node_kind {
239 0 => "sequence".to_string(),
240 1 => "parallel".to_string(),
241 _ => match node.call.as_ref() {
242 Some(c) => cap_short_name(&c.provider_id, &c.contract_id),
243 None => node.description.clone(),
244 },
245 };
246 let branch = if is_root {
247 String::new()
248 } else if is_last {
249 format!("{prefix}└─ ")
250 } else {
251 format!("{prefix}├─ ")
252 };
253 out.push(Line::from(vec![
254 Span::raw(branch),
255 Span::styled(format!("{glyph} "), style),
256 Span::styled(label, style),
257 ]));
258 let n = node.children.len();
259 for (i, child) in node.children.iter().enumerate() {
260 let child_prefix = if is_root {
261 String::new()
262 } else if is_last {
263 format!("{prefix} ")
264 } else {
265 format!("{prefix}│ ")
266 };
267 forest_node_lines(pv, *child as usize, &child_prefix, false, i + 1 == n, out);
268 }
269}
270
271const DEFAULT_LIAISON_FALLBACK: &str = "http://127.0.0.1:50081";
272
273#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
283struct ChatConfig {
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 mic_cap_id: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 mic_device_id: Option<String>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 speaker_cap_id: Option<String>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 speaker_device_id: Option<String>,
292}
293
294fn chat_config_path() -> Option<std::path::PathBuf> {
295 dirs::home_dir().map(|h| h.join(".robonix").join("chat.yaml"))
296}
297
298fn load_chat_config() -> ChatConfig {
299 let Some(p) = chat_config_path() else {
300 return ChatConfig::default();
301 };
302 let Ok(text) = std::fs::read_to_string(&p) else {
303 return ChatConfig::default();
304 };
305 serde_yaml::from_str(&text).unwrap_or_default()
306}
307
308fn save_chat_config(cfg: &ChatConfig) -> Result<()> {
309 let p = chat_config_path().context("no home dir")?;
310 if let Some(parent) = p.parent() {
311 std::fs::create_dir_all(parent)?;
312 }
313 std::fs::write(&p, serde_yaml::to_string(cfg)?)?;
314 Ok(())
315}
316
317pub async fn execute(server: &str) -> Result<()> {
318 let atlas_endpoint = if server.starts_with("http") {
319 server.to_string()
320 } else {
321 format!("http://{server}")
322 };
323
324 let liaison_endpoint = if let Ok(ep) = std::env::var("ROBONIX_LIAISON_ENDPOINT") {
325 info!("using ROBONIX_LIAISON_ENDPOINT={ep}");
326 if ep.starts_with("http") {
327 ep
328 } else {
329 format!("http://{ep}")
330 }
331 } else {
332 discover_liaison(&atlas_endpoint).await.unwrap_or_else(|e| {
333 warn!(
334 "liaison discovery timed out ({e:#}), falling back to {DEFAULT_LIAISON_FALLBACK}"
335 );
336 DEFAULT_LIAISON_FALLBACK.to_string()
337 })
338 };
339 let liaison_endpoint = localhost_to_ipv4_loopback(&liaison_endpoint);
340
341 let mut stdout = io::stdout();
342 stdout.execute(EnterAlternateScreen)?;
343 terminal::enable_raw_mode()?;
344
345 let backend = CrosstermBackend::new(stdout);
346 let mut terminal = Terminal::new(backend)?;
347
348 let (chat_cfg, audio_warnings) =
354 match ensure_audio_devices(&atlas_endpoint, &mut terminal).await {
355 Ok(v) => v,
356 Err(e) => {
357 terminal::disable_raw_mode()?;
358 terminal.backend_mut().execute(LeaveAlternateScreen)?;
359 return Err(e);
360 }
361 };
362
363 let result = run_tui(
364 &mut terminal,
365 &atlas_endpoint,
366 &liaison_endpoint,
367 chat_cfg,
368 &audio_warnings,
369 )
370 .await;
371
372 terminal::disable_raw_mode()?;
373 terminal.backend_mut().execute(LeaveAlternateScreen)?;
374
375 result
376}
377
378async fn discover_liaison(atlas_endpoint: &str) -> Result<String> {
380 const RETRY_INTERVAL_MS: u64 = 2_000;
381 const TIMEOUT_SECS: u64 = 60;
382
383 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(TIMEOUT_SECS);
384 let mut attempt = 0u32;
385
386 loop {
387 attempt += 1;
388 match try_discover_once(atlas_endpoint, LIAISON_CONTRACT_ID).await {
389 Ok(ep) => return Ok(ep),
390 Err(e) => {
391 if std::time::Instant::now() >= deadline {
392 anyhow::bail!("liaison not found in Atlas after {TIMEOUT_SECS}s: {e:#}");
393 }
394 if attempt == 1 {
395 eprintln!("[chat] waiting for Liaison to register in Atlas ({e:#})…");
396 }
397 tokio::time::sleep(std::time::Duration::from_millis(RETRY_INTERVAL_MS)).await;
398 }
399 }
400 }
401}
402
403async fn try_discover_once(atlas_endpoint: &str, contract_id: &str) -> Result<String> {
404 let mut atlas = AtlasClient::connect(atlas_endpoint).await?;
405 let transport = atlas_pb::Transport::Grpc;
406 let providers = atlas.query_capabilities("", contract_id, transport).await?;
407 for provider in &providers {
408 let has = provider
409 .capabilities
410 .iter()
411 .any(|i| i.contract_id == contract_id && i.transport == transport as i32);
412 if !has {
413 continue;
414 }
415 let (_, endpoint, _) = atlas
416 .connect_capability(CONSUMER_ID, &provider.id, contract_id, transport)
417 .await?;
418 if endpoint.is_empty() {
419 continue;
420 }
421 let uri = if endpoint.starts_with("http") {
422 endpoint
423 } else {
424 format!("http://{endpoint}")
425 };
426 return Ok(localhost_to_ipv4_loopback(&uri));
427 }
428 anyhow::bail!("no {contract_id} capability found in Atlas registry")
429}
430
431#[derive(Clone, Copy)]
449#[allow(dead_code)]
450enum PickMode {
451 FirstRun,
452 Reconfigure,
453}
454
455async fn ensure_audio_devices(
459 atlas_endpoint: &str,
460 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
461) -> Result<(ChatConfig, Vec<String>)> {
462 pick_audio_settings(atlas_endpoint, terminal, PickMode::FirstRun).await
463}
464
465async fn pick_audio_settings(
466 atlas_endpoint: &str,
467 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
468 mode: PickMode,
469) -> Result<(ChatConfig, Vec<String>)> {
470 let mut cfg = load_chat_config();
471 let mut warnings: Vec<String> = Vec::new();
472
473 let env_set = |key: &str| std::env::var(key).ok().filter(|s| !s.is_empty()).is_some();
474 let always = matches!(mode, PickMode::Reconfigure);
475 let mut need_mic = always || (!env_set("ROBONIX_CHAT_MIC_NODE") && cfg.mic_cap_id.is_none());
476 let mut need_speaker =
477 always || (!env_set("ROBONIX_CHAT_SPEAKER_NODE") && cfg.speaker_cap_id.is_none());
478
479 let mut atlas = match AtlasClient::connect(atlas_endpoint).await {
480 Ok(c) => c,
481 Err(e) => {
482 warnings.push(format!(
483 "audio device pick skipped — atlas unreachable at {atlas_endpoint}: {e:#}. \
484 Text mode still works; voice (F2) will fail until atlas is up."
485 ));
486 return Ok((cfg, warnings));
487 }
488 };
489
490 if !need_mic
495 && let Some(pin) = cfg.mic_cap_id.as_deref()
496 && !pin_exists_in_atlas(&mut atlas, pin, MIC_CONTRACT).await
497 {
498 warnings.push(format!(
499 "mic pin '{pin}' not in atlas (stale config) — re-prompting"
500 ));
501 cfg.mic_cap_id = None;
502 cfg.mic_device_id = None;
503 need_mic = true;
504 }
505 if !need_speaker
506 && let Some(pin) = cfg.speaker_cap_id.as_deref()
507 && !pin_exists_in_atlas(&mut atlas, pin, SPEAKER_CONTRACT).await
508 {
509 warnings.push(format!(
510 "speaker pin '{pin}' not in atlas (stale config) — re-prompting"
511 ));
512 cfg.speaker_cap_id = None;
513 cfg.speaker_device_id = None;
514 need_speaker = true;
515 }
516 if !need_mic && !need_speaker {
517 return Ok((cfg, warnings));
518 }
519
520 if need_mic {
521 let saved_cap = cfg.mic_cap_id.clone();
522 let saved_dev = cfg.mic_device_id.clone();
523 match try_pick(
524 &mut atlas,
525 terminal,
526 "mic",
527 MIC_CONTRACT,
528 "input",
529 saved_cap.as_deref(),
530 saved_dev.as_deref(),
531 mode,
532 )
533 .await
534 {
535 Ok(Some((provider_id, device_id))) => {
536 cfg.mic_cap_id = Some(provider_id);
537 cfg.mic_device_id = Some(device_id);
538 }
539 Ok(None) => warnings.push(
540 "no mic provider in atlas — voice input disabled (text mode unaffected)".into(),
541 ),
542 Err(e) => warnings.push(format!("mic pick: {e:#}")),
543 }
544 }
545 if need_speaker {
546 let saved_cap = cfg.speaker_cap_id.clone();
547 let saved_dev = cfg.speaker_device_id.clone();
548 match try_pick(
549 &mut atlas,
550 terminal,
551 "speaker",
552 SPEAKER_CONTRACT,
553 "output",
554 saved_cap.as_deref(),
555 saved_dev.as_deref(),
556 mode,
557 )
558 .await
559 {
560 Ok(Some((provider_id, device_id))) => {
561 cfg.speaker_cap_id = Some(provider_id);
562 cfg.speaker_device_id = Some(device_id);
563 }
564 Ok(None) => warnings.push(
565 "no speaker provider in atlas — voice playback disabled (text mode unaffected)"
566 .into(),
567 ),
568 Err(e) => warnings.push(format!("speaker pick: {e:#}")),
569 }
570 }
571 if let Err(e) = save_chat_config(&cfg) {
572 warn!("could not save chat config: {e:#}");
573 }
574 Ok((cfg, warnings))
575}
576
577async fn pin_exists_in_atlas(atlas: &mut AtlasClient, pin: &str, contract: &str) -> bool {
583 let Ok(providers) = atlas
584 .query_capabilities("", contract, atlas_pb::Transport::Grpc)
585 .await
586 else {
587 return true;
590 };
591 providers.iter().any(|r| r.id == pin || r.namespace == pin)
592}
593
594#[allow(clippy::too_many_arguments)]
598async fn try_pick(
599 atlas: &mut AtlasClient,
600 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
601 label: &str,
602 contract: &str,
603 kind: &str,
604 saved_cap_id: Option<&str>,
605 saved_device_id: Option<&str>,
606 mode: PickMode,
607) -> Result<Option<(String, String)>> {
608 let providers = atlas
609 .query_capabilities("", contract, atlas_pb::Transport::Grpc)
610 .await?;
611 if providers.is_empty() {
612 return Ok(None);
613 }
614
615 let provider_id = match (mode, saved_cap_id) {
621 (PickMode::FirstRun, Some(s)) if providers.iter().any(|p| p.id == s) => s.to_string(),
622 (PickMode::FirstRun, _) if providers.len() == 1 => {
623 let id = providers[0].id.clone();
624 flash_picker_message(terminal, &format!("auto-selected {label}: {id}"))?;
625 id
626 }
627 _ => match pick_tui(terminal, label, contract, &providers)? {
628 Some(s) => s,
629 None => return Ok(None),
630 },
631 };
632
633 let device_id = pick_device_for_cap(
636 atlas,
637 terminal,
638 &provider_id,
639 label,
640 kind,
641 saved_device_id,
642 mode,
643 )
644 .await?;
645
646 if !device_id.is_empty()
648 && let Err(e) = call_select_device(atlas, &provider_id, kind, &device_id).await
649 {
650 warn!("SelectAudioDevice on {provider_id} ({kind}={device_id}) failed: {e:#}");
651 }
652
653 Ok(Some((provider_id, device_id)))
654}
655
656async fn pick_device_for_cap(
660 atlas: &mut AtlasClient,
661 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
662 provider_id: &str,
663 label: &str,
664 kind: &str,
665 saved_device_id: Option<&str>,
666 mode: PickMode,
667) -> Result<String> {
668 use crate::pb::contracts::robonix_primitive_audio_list_devices_client::RobonixPrimitiveAudioListDevicesClient;
669
670 const LIST_CONTRACT: &str = "robonix/primitive/audio/list_devices";
671 let reconf = matches!(mode, PickMode::Reconfigure);
675
676 let endpoint = match atlas
677 .connect_capability(
678 CONSUMER_ID,
679 provider_id,
680 LIST_CONTRACT,
681 atlas_pb::Transport::Grpc,
682 )
683 .await
684 {
685 Ok((_, ep, _)) => {
686 if ep.starts_with("http") {
687 ep
688 } else {
689 format!("http://{ep}")
690 }
691 }
692 Err(_) => {
693 if reconf {
694 flash_picker_message(
695 terminal,
696 &format!(
697 "{provider_id} doesn't expose list_devices — \
698 using OS default device. Rebuild the package \
699 (bash scripts/build.sh) to pick up the new contract."
700 ),
701 )?;
702 }
703 return Ok(String::new());
704 }
705 };
706
707 let mut client = match RobonixPrimitiveAudioListDevicesClient::connect(endpoint.clone()).await {
708 Ok(c) => c,
709 Err(e) => {
710 if reconf {
711 flash_picker_message(
712 terminal,
713 &format!("connect list_devices on {provider_id}: {e}"),
714 )?;
715 }
716 return Ok(String::new());
717 }
718 };
719 let resp = match client
720 .list_audio_devices(crate::pb::audio::ListAudioDevicesRequest {})
721 .await
722 {
723 Ok(r) => r.into_inner(),
724 Err(e) => {
725 if reconf {
726 flash_picker_message(terminal, &format!("ListAudioDevices on {provider_id}: {e}"))?;
727 }
728 return Ok(String::new());
729 }
730 };
731
732 let usable: Vec<crate::pb::audio::AudioDevice> = resp
733 .devices
734 .into_iter()
735 .filter(|d| d.kind == kind || d.kind == "duplex")
736 .collect();
737 if usable.is_empty() {
738 if reconf {
739 flash_picker_message(
740 terminal,
741 &format!("{provider_id} reports no {kind} devices — using OS default"),
742 )?;
743 }
744 return Ok(String::new());
745 }
746
747 if matches!(mode, PickMode::FirstRun) {
751 if let Some(saved) = saved_device_id
752 && usable.iter().any(|d| d.id == saved)
753 {
754 return Ok(saved.to_string());
755 }
756 if usable.len() == 1 {
757 let id = usable[0].id.clone();
758 flash_picker_message(
759 terminal,
760 &format!("auto-selected {label} device: {}", usable[0].name),
761 )?;
762 return Ok(id);
763 }
764 }
765
766 let chosen = pick_device_tui(terminal, label, &usable)?;
767 if chosen.is_empty()
770 && let Some(saved) = saved_device_id
771 {
772 return Ok(saved.to_string());
773 }
774 Ok(chosen)
775}
776
777async fn call_select_device(
778 atlas: &mut AtlasClient,
779 provider_id: &str,
780 kind: &str,
781 device_id: &str,
782) -> Result<()> {
783 use crate::pb::contracts::robonix_primitive_audio_select_device_client::RobonixPrimitiveAudioSelectDeviceClient;
784 const SELECT_CONTRACT: &str = "robonix/primitive/audio/select_device";
785
786 let (_, ep, _) = atlas
787 .connect_capability(
788 CONSUMER_ID,
789 provider_id,
790 SELECT_CONTRACT,
791 atlas_pb::Transport::Grpc,
792 )
793 .await?;
794 let endpoint = if ep.starts_with("http") {
795 ep
796 } else {
797 format!("http://{ep}")
798 };
799 let mut client = RobonixPrimitiveAudioSelectDeviceClient::connect(endpoint).await?;
800 let resp = client
801 .select_audio_device(crate::pb::audio::SelectAudioDeviceRequest {
802 kind: kind.to_string(),
803 id: device_id.to_string(),
804 })
805 .await?
806 .into_inner();
807 if !resp.ok {
808 anyhow::bail!("impl rejected: {}", resp.error);
809 }
810 Ok(())
811}
812
813fn pick_device_tui(
814 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
815 label: &str,
816 devices: &[crate::pb::audio::AudioDevice],
817) -> Result<String> {
818 let mut idx = 0usize;
819 loop {
820 terminal.draw(|f| {
821 let area = f.area();
822 let lines: Vec<Line> = devices
823 .iter()
824 .enumerate()
825 .map(|(i, d)| {
826 let mark = if i == idx { "▶ " } else { " " };
827 let style = if i == idx {
828 Style::default()
829 .fg(Color::Black)
830 .bg(Color::Cyan)
831 .add_modifier(Modifier::BOLD)
832 } else {
833 Style::default()
834 };
835 let mut tags = Vec::new();
836 if d.is_default {
837 tags.push("default".to_string());
838 }
839 if !d.note.is_empty() {
840 tags.push(format!("⚠ {}", d.note));
841 }
842 let suffix = if tags.is_empty() {
843 String::new()
844 } else {
845 format!(" ({})", tags.join(", "))
846 };
847 Line::from(vec![Span::styled(
848 format!("{mark}#{} {}{}", d.id, d.name, suffix),
849 style,
850 )])
851 })
852 .collect();
853 let body = Paragraph::new(lines)
854 .block(Block::default().borders(Borders::ALL).title(format!(
855 " choose {label} device — ↑↓ select · Enter confirm · Esc skip "
856 )))
857 .wrap(Wrap { trim: false });
858 f.render_widget(body, area);
859 })?;
860 if event::poll(std::time::Duration::from_millis(200))?
861 && let Event::Key(key) = event::read()?
862 {
863 match key.code {
864 KeyCode::Up | KeyCode::Char('k') => idx = idx.saturating_sub(1),
865 KeyCode::Down | KeyCode::Char('j') if idx + 1 < devices.len() => idx += 1,
866 KeyCode::Enter => return Ok(devices[idx].id.clone()),
867 KeyCode::Char('q') | KeyCode::Esc => return Ok(String::new()),
868 _ => {}
869 }
870 }
871 }
872}
873
874fn pick_tui(
875 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
876 label: &str,
877 contract: &str,
878 providers: &[atlas_pb::CapabilityProvider],
879) -> Result<Option<String>> {
880 let mut idx = 0usize;
881 loop {
882 terminal.draw(|f| {
883 let area = f.area();
884 let lines: Vec<Line> = providers
885 .iter()
886 .enumerate()
887 .map(|(i, r)| {
888 let mark = if i == idx { "▶ " } else { " " };
889 let style = if i == idx {
890 Style::default()
891 .fg(Color::Black)
892 .bg(Color::Cyan)
893 .add_modifier(Modifier::BOLD)
894 } else {
895 Style::default()
896 };
897 let detail = if r.state_detail.is_empty() {
898 String::new()
899 } else {
900 format!(" ({})", r.state_detail)
901 };
902 Line::from(vec![Span::styled(format!("{mark}{}{detail}", r.id), style)])
903 })
904 .collect();
905 let body = Paragraph::new(lines)
906 .block(Block::default().borders(Borders::ALL).title(format!(
907 " choose {label} provider ({contract}) — ↑↓ select · Enter confirm · Esc skip "
908 )))
909 .wrap(Wrap { trim: false });
910 f.render_widget(body, area);
911 })?;
912 if event::poll(std::time::Duration::from_millis(200))?
913 && let Event::Key(key) = event::read()?
914 {
915 match key.code {
916 KeyCode::Up | KeyCode::Char('k') => idx = idx.saturating_sub(1),
917 KeyCode::Down | KeyCode::Char('j') if idx + 1 < providers.len() => {
918 idx += 1;
919 }
920 KeyCode::Enter => return Ok(Some(providers[idx].id.clone())),
921 KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
922 _ => {}
923 }
924 }
925 }
926}
927
928#[derive(Clone, Copy, PartialEq, Eq)]
938enum AudioSection {
939 MicProvider,
940 MicDevice,
941 SpeakerProvider,
942 SpeakerDevice,
943}
944
945struct AudioSettingsPage {
946 mic_providers: Vec<atlas_pb::CapabilityProvider>,
947 speaker_providers: Vec<atlas_pb::CapabilityProvider>,
948 mic_devices: Vec<crate::pb::audio::AudioDevice>,
949 speaker_devices: Vec<crate::pb::audio::AudioDevice>,
950
951 mic_cap_id: String,
952 mic_device_id: String,
953 speaker_cap_id: String,
954 speaker_device_id: String,
955
956 section: AudioSection,
957 cursor_mp: usize,
958 cursor_md: usize,
959 cursor_sp: usize,
960 cursor_sd: usize,
961
962 status: String,
963}
964
965async fn fetch_devices_filtered(
966 atlas: &mut AtlasClient,
967 provider_id: &str,
968 kind: &str,
969) -> Vec<crate::pb::audio::AudioDevice> {
970 use crate::pb::contracts::robonix_primitive_audio_list_devices_client::RobonixPrimitiveAudioListDevicesClient;
971 const LIST_CONTRACT: &str = "robonix/primitive/audio/list_devices";
972 if provider_id.is_empty() {
973 return Vec::new();
974 }
975 let endpoint = match atlas
976 .connect_capability(
977 CONSUMER_ID,
978 provider_id,
979 LIST_CONTRACT,
980 atlas_pb::Transport::Grpc,
981 )
982 .await
983 {
984 Ok((_, ep, _)) if ep.starts_with("http") => ep,
985 Ok((_, ep, _)) => format!("http://{ep}"),
986 Err(_) => return Vec::new(),
987 };
988 let mut client = match RobonixPrimitiveAudioListDevicesClient::connect(endpoint).await {
989 Ok(c) => c,
990 Err(_) => return Vec::new(),
991 };
992 let resp = match client
993 .list_audio_devices(crate::pb::audio::ListAudioDevicesRequest {})
994 .await
995 {
996 Ok(r) => r.into_inner(),
997 Err(_) => return Vec::new(),
998 };
999 resp.devices
1000 .into_iter()
1001 .filter(|d| d.kind == kind || d.kind == "duplex")
1002 .collect()
1003}
1004
1005impl AudioSettingsPage {
1006 async fn load(atlas: &mut AtlasClient, cfg: &ChatConfig) -> Self {
1007 let mic_providers = atlas
1008 .query_capabilities("", MIC_CONTRACT, atlas_pb::Transport::Grpc)
1009 .await
1010 .unwrap_or_default();
1011 let speaker_providers = atlas
1012 .query_capabilities("", SPEAKER_CONTRACT, atlas_pb::Transport::Grpc)
1013 .await
1014 .unwrap_or_default();
1015
1016 let mic_cap_id = cfg.mic_cap_id.clone().unwrap_or_else(|| {
1017 mic_providers
1018 .first()
1019 .map(|p| p.id.clone())
1020 .unwrap_or_default()
1021 });
1022 let speaker_cap_id = cfg.speaker_cap_id.clone().unwrap_or_else(|| {
1023 speaker_providers
1024 .first()
1025 .map(|p| p.id.clone())
1026 .unwrap_or_default()
1027 });
1028
1029 let mic_devices = fetch_devices_filtered(atlas, &mic_cap_id, "input").await;
1030 let speaker_devices = fetch_devices_filtered(atlas, &speaker_cap_id, "output").await;
1031
1032 let mic_device_id = cfg.mic_device_id.clone().unwrap_or_default();
1033 let speaker_device_id = cfg.speaker_device_id.clone().unwrap_or_default();
1034
1035 let cursor_mp = mic_providers
1038 .iter()
1039 .position(|p| p.id == mic_cap_id)
1040 .unwrap_or(0);
1041 let cursor_sp = speaker_providers
1042 .iter()
1043 .position(|p| p.id == speaker_cap_id)
1044 .unwrap_or(0);
1045 let cursor_md = mic_devices
1046 .iter()
1047 .position(|d| d.id == mic_device_id)
1048 .unwrap_or(0);
1049 let cursor_sd = speaker_devices
1050 .iter()
1051 .position(|d| d.id == speaker_device_id)
1052 .unwrap_or(0);
1053
1054 let mut status = String::new();
1055 if mic_providers.is_empty() && speaker_providers.is_empty() {
1056 status.push_str("no audio providers in atlas — boot the audio package first");
1057 }
1058
1059 Self {
1060 mic_providers,
1061 speaker_providers,
1062 mic_devices,
1063 speaker_devices,
1064 mic_cap_id,
1065 mic_device_id,
1066 speaker_cap_id,
1067 speaker_device_id,
1068 section: AudioSection::MicProvider,
1069 cursor_mp,
1070 cursor_md,
1071 cursor_sp,
1072 cursor_sd,
1073 status,
1074 }
1075 }
1076
1077 fn current_len(&self) -> usize {
1078 match self.section {
1079 AudioSection::MicProvider => self.mic_providers.len(),
1080 AudioSection::MicDevice => self.mic_devices.len(),
1081 AudioSection::SpeakerProvider => self.speaker_providers.len(),
1082 AudioSection::SpeakerDevice => self.speaker_devices.len(),
1083 }
1084 }
1085 fn current_cursor(&self) -> usize {
1086 match self.section {
1087 AudioSection::MicProvider => self.cursor_mp,
1088 AudioSection::MicDevice => self.cursor_md,
1089 AudioSection::SpeakerProvider => self.cursor_sp,
1090 AudioSection::SpeakerDevice => self.cursor_sd,
1091 }
1092 }
1093 fn current_cursor_mut(&mut self) -> &mut usize {
1094 match self.section {
1095 AudioSection::MicProvider => &mut self.cursor_mp,
1096 AudioSection::MicDevice => &mut self.cursor_md,
1097 AudioSection::SpeakerProvider => &mut self.cursor_sp,
1098 AudioSection::SpeakerDevice => &mut self.cursor_sd,
1099 }
1100 }
1101 fn cursor_up(&mut self) {
1102 let c = self.current_cursor_mut();
1103 if *c > 0 {
1104 *c -= 1;
1105 }
1106 }
1107 fn cursor_down(&mut self) {
1108 let n = self.current_len();
1109 let c = self.current_cursor_mut();
1110 if *c + 1 < n {
1111 *c += 1;
1112 }
1113 }
1114 fn next_section(&mut self) {
1115 self.section = match self.section {
1116 AudioSection::MicProvider => AudioSection::MicDevice,
1117 AudioSection::MicDevice => AudioSection::SpeakerProvider,
1118 AudioSection::SpeakerProvider => AudioSection::SpeakerDevice,
1119 AudioSection::SpeakerDevice => AudioSection::MicProvider,
1120 };
1121 }
1122 fn prev_section(&mut self) {
1123 self.section = match self.section {
1124 AudioSection::MicProvider => AudioSection::SpeakerDevice,
1125 AudioSection::MicDevice => AudioSection::MicProvider,
1126 AudioSection::SpeakerProvider => AudioSection::MicDevice,
1127 AudioSection::SpeakerDevice => AudioSection::SpeakerProvider,
1128 };
1129 }
1130
1131 async fn enter(&mut self, atlas: &mut AtlasClient) {
1132 let i = self.current_cursor();
1133 match self.section {
1134 AudioSection::MicProvider => {
1135 if let Some(p) = self.mic_providers.get(i) {
1136 let new_cap = p.id.clone();
1137 if new_cap != self.mic_cap_id {
1138 self.mic_cap_id = new_cap.clone();
1139 self.mic_devices = fetch_devices_filtered(atlas, &new_cap, "input").await;
1140 self.mic_device_id.clear();
1141 self.cursor_md = 0;
1142 }
1143 self.status = format!("mic provider → {new_cap}");
1144 }
1145 }
1146 AudioSection::MicDevice => {
1147 if let Some(d) = self.mic_devices.get(i) {
1148 let id = d.id.clone();
1149 let name = d.name.clone();
1150 self.mic_device_id = id.clone();
1151 match call_select_device(atlas, &self.mic_cap_id, "input", &id).await {
1152 Ok(()) => self.status = format!("mic device → {name} ({id})"),
1153 Err(e) => self.status = format!("mic device → {id} (warn: {e:#})"),
1154 }
1155 }
1156 }
1157 AudioSection::SpeakerProvider => {
1158 if let Some(p) = self.speaker_providers.get(i) {
1159 let new_cap = p.id.clone();
1160 if new_cap != self.speaker_cap_id {
1161 self.speaker_cap_id = new_cap.clone();
1162 self.speaker_devices =
1163 fetch_devices_filtered(atlas, &new_cap, "output").await;
1164 self.speaker_device_id.clear();
1165 self.cursor_sd = 0;
1166 }
1167 self.status = format!("speaker provider → {new_cap}");
1168 }
1169 }
1170 AudioSection::SpeakerDevice => {
1171 if let Some(d) = self.speaker_devices.get(i) {
1172 let id = d.id.clone();
1173 let name = d.name.clone();
1174 self.speaker_device_id = id.clone();
1175 match call_select_device(atlas, &self.speaker_cap_id, "output", &id).await {
1176 Ok(()) => self.status = format!("speaker device → {name} ({id})"),
1177 Err(e) => self.status = format!("speaker device → {id} (warn: {e:#})"),
1178 }
1179 }
1180 }
1181 }
1182 }
1183
1184 async fn refresh(&mut self, atlas: &mut AtlasClient) {
1185 *self = Self::load(
1186 atlas,
1187 &ChatConfig {
1188 mic_cap_id: (!self.mic_cap_id.is_empty()).then(|| self.mic_cap_id.clone()),
1189 mic_device_id: (!self.mic_device_id.is_empty()).then(|| self.mic_device_id.clone()),
1190 speaker_cap_id: (!self.speaker_cap_id.is_empty())
1191 .then(|| self.speaker_cap_id.clone()),
1192 speaker_device_id: (!self.speaker_device_id.is_empty())
1193 .then(|| self.speaker_device_id.clone()),
1194 },
1195 )
1196 .await;
1197 self.status = "refreshed".into();
1198 }
1199
1200 fn draw(&self, frame: &mut ratatui::Frame) {
1201 let mut lines: Vec<Line> = Vec::with_capacity(64);
1202 let dim = Style::default().fg(Color::DarkGray);
1203 let normal = Style::default();
1204 let selected_style = Style::default()
1205 .fg(Color::Green)
1206 .add_modifier(Modifier::BOLD);
1207 let cursor_style = Style::default()
1208 .fg(Color::Black)
1209 .bg(Color::Cyan)
1210 .add_modifier(Modifier::BOLD);
1211
1212 let render_provider_row = |i: usize,
1213 p: &atlas_pb::CapabilityProvider,
1214 sel: &str,
1215 in_section: bool,
1216 cursor: usize|
1217 -> Line {
1218 let is_cursor = in_section && i == cursor;
1219 let is_selected = p.id == sel;
1220 let mark_left = if is_cursor { "▶" } else { " " };
1221 let bullet = if is_selected { "●" } else { "○" };
1222 let prefix = format!(" {mark_left} {bullet} ");
1223 let style = if is_cursor {
1224 cursor_style
1225 } else if is_selected {
1226 selected_style
1227 } else {
1228 normal
1229 };
1230 Line::from(vec![Span::styled(format!("{prefix}{}", p.id), style)])
1231 };
1232
1233 let render_device_row = |i: usize,
1234 d: &crate::pb::audio::AudioDevice,
1235 sel: &str,
1236 in_section: bool,
1237 cursor: usize|
1238 -> Line {
1239 let is_cursor = in_section && i == cursor;
1240 let is_selected = d.id == sel;
1241 let mark_left = if is_cursor { "▶" } else { " " };
1242 let bullet = if is_selected { "●" } else { "○" };
1243 let mut tags: Vec<String> = Vec::new();
1244 if d.is_default {
1245 tags.push("default".into());
1246 }
1247 if !d.note.is_empty() {
1248 tags.push(format!("⚠ {}", d.note));
1249 }
1250 let suffix = if tags.is_empty() {
1251 String::new()
1252 } else {
1253 format!(" [{}]", tags.join(", "))
1254 };
1255 let body = format!(" {mark_left} {bullet} {:<10} {}{}", d.id, d.name, suffix);
1256 let style = if is_cursor {
1257 cursor_style
1258 } else if is_selected {
1259 selected_style
1260 } else {
1261 normal
1262 };
1263 Line::from(vec![Span::styled(body, style)])
1264 };
1265
1266 let section_title = |title: &str, active: bool| -> Line {
1267 let bar = "━".repeat(8);
1268 let prefix = if active { "▼" } else { " " };
1269 let style = if active {
1270 Style::default()
1271 .fg(Color::Cyan)
1272 .add_modifier(Modifier::BOLD)
1273 } else {
1274 Style::default()
1275 .fg(Color::Gray)
1276 .add_modifier(Modifier::BOLD)
1277 };
1278 Line::from(vec![Span::styled(
1279 format!("{prefix} {bar} {title} {bar}"),
1280 style,
1281 )])
1282 };
1283
1284 lines.push(Line::from(""));
1286 lines.push(Line::from(vec![Span::styled(
1287 " MICROPHONE",
1288 Style::default()
1289 .fg(Color::Yellow)
1290 .add_modifier(Modifier::BOLD),
1291 )]));
1292 lines.push(section_title(
1293 "Cap (robonix handle)",
1294 self.section == AudioSection::MicProvider,
1295 ));
1296 if self.mic_providers.is_empty() {
1297 lines.push(Line::from(vec![Span::styled(
1298 " (no providers — rbnx boot first)",
1299 dim,
1300 )]));
1301 } else {
1302 for (i, p) in self.mic_providers.iter().enumerate() {
1303 lines.push(render_provider_row(
1304 i,
1305 p,
1306 &self.mic_cap_id,
1307 self.section == AudioSection::MicProvider,
1308 self.cursor_mp,
1309 ));
1310 }
1311 }
1312 lines.push(section_title(
1313 "Driver-internal device",
1314 self.section == AudioSection::MicDevice,
1315 ));
1316 if self.mic_devices.is_empty() {
1317 lines.push(Line::from(vec![Span::styled(
1318 " (no devices — impl missing list_devices, or rebuild package)",
1319 dim,
1320 )]));
1321 } else {
1322 for (i, d) in self.mic_devices.iter().enumerate() {
1323 lines.push(render_device_row(
1324 i,
1325 d,
1326 &self.mic_device_id,
1327 self.section == AudioSection::MicDevice,
1328 self.cursor_md,
1329 ));
1330 }
1331 }
1332
1333 lines.push(Line::from(""));
1335 lines.push(Line::from(vec![Span::styled(
1336 " SPEAKER",
1337 Style::default()
1338 .fg(Color::Yellow)
1339 .add_modifier(Modifier::BOLD),
1340 )]));
1341 lines.push(section_title(
1342 "Cap (robonix handle)",
1343 self.section == AudioSection::SpeakerProvider,
1344 ));
1345 if self.speaker_providers.is_empty() {
1346 lines.push(Line::from(vec![Span::styled(
1347 " (no providers — rbnx boot first)",
1348 dim,
1349 )]));
1350 } else {
1351 for (i, p) in self.speaker_providers.iter().enumerate() {
1352 lines.push(render_provider_row(
1353 i,
1354 p,
1355 &self.speaker_cap_id,
1356 self.section == AudioSection::SpeakerProvider,
1357 self.cursor_sp,
1358 ));
1359 }
1360 }
1361 lines.push(section_title(
1362 "Driver-internal device",
1363 self.section == AudioSection::SpeakerDevice,
1364 ));
1365 if self.speaker_devices.is_empty() {
1366 lines.push(Line::from(vec![Span::styled(
1367 " (no devices — impl missing list_devices, or rebuild package)",
1368 dim,
1369 )]));
1370 } else {
1371 for (i, d) in self.speaker_devices.iter().enumerate() {
1372 lines.push(render_device_row(
1373 i,
1374 d,
1375 &self.speaker_device_id,
1376 self.section == AudioSection::SpeakerDevice,
1377 self.cursor_sd,
1378 ));
1379 }
1380 }
1381
1382 lines.push(Line::from(""));
1383
1384 let area = frame.area();
1385 let chunks = Layout::vertical([
1386 Constraint::Min(0),
1387 Constraint::Length(1),
1388 Constraint::Length(1),
1389 ])
1390 .split(area);
1391
1392 let body = Paragraph::new(lines)
1393 .block(Block::default().borders(Borders::ALL).title(
1394 " Audio Settings — Cap = robonix handle · Driver-internal device = optional, multi-device drivers only ",
1395 ))
1396 .wrap(Wrap { trim: false });
1397 frame.render_widget(body, chunks[0]);
1398
1399 let status_line = if self.status.is_empty() {
1400 String::from(" ")
1401 } else {
1402 format!(" {}", self.status)
1403 };
1404 let status = Paragraph::new(status_line).style(Style::default().fg(Color::Cyan));
1405 frame.render_widget(status, chunks[1]);
1406
1407 let help = Paragraph::new(
1408 " Tab/Shift+Tab: section · ↑↓ jk: item · Enter/Space: pick · r: refresh · Esc/Ctrl+A: close",
1409 )
1410 .style(dim);
1411 frame.render_widget(help, chunks[2]);
1412 }
1413}
1414
1415async fn run_audio_settings_page(
1416 atlas_endpoint: &str,
1417 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1418 cfg: ChatConfig,
1419) -> Result<ChatConfig> {
1420 let mut atlas = AtlasClient::connect(atlas_endpoint)
1421 .await
1422 .with_context(|| format!("connect to atlas at '{atlas_endpoint}' for audio settings"))?;
1423 let mut page = AudioSettingsPage::load(&mut atlas, &cfg).await;
1424
1425 loop {
1426 terminal.draw(|f| page.draw(f))?;
1427 if event::poll(std::time::Duration::from_millis(150))?
1428 && let Event::Key(key) = event::read()?
1429 {
1430 match (key.modifiers, key.code) {
1431 (_, KeyCode::Tab) => page.next_section(),
1432 (_, KeyCode::BackTab) => page.prev_section(),
1433 (_, KeyCode::Up) | (_, KeyCode::Char('k')) => page.cursor_up(),
1434 (_, KeyCode::Down) | (_, KeyCode::Char('j')) => page.cursor_down(),
1435 (_, KeyCode::Enter) | (_, KeyCode::Char(' ')) => page.enter(&mut atlas).await,
1436 (_, KeyCode::Char('r')) => page.refresh(&mut atlas).await,
1437 (KeyModifiers::CONTROL, KeyCode::Char('a'))
1438 | (_, KeyCode::Esc)
1439 | (_, KeyCode::Char('q')) => break,
1440 _ => {}
1441 }
1442 }
1443 }
1444
1445 let new_cfg = ChatConfig {
1446 mic_cap_id: (!page.mic_cap_id.is_empty()).then_some(page.mic_cap_id),
1447 mic_device_id: (!page.mic_device_id.is_empty()).then_some(page.mic_device_id),
1448 speaker_cap_id: (!page.speaker_cap_id.is_empty()).then_some(page.speaker_cap_id),
1449 speaker_device_id: (!page.speaker_device_id.is_empty()).then_some(page.speaker_device_id),
1450 };
1451 if let Err(e) = save_chat_config(&new_cfg) {
1452 warn!("could not save chat config: {e:#}");
1453 }
1454 Ok(new_cfg)
1455}
1456
1457fn flash_picker_message(
1462 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1463 msg: &str,
1464) -> Result<()> {
1465 terminal.draw(|f| {
1466 let body = Paragraph::new(msg)
1467 .block(
1468 Block::default()
1469 .borders(Borders::ALL)
1470 .title(" rbnx chat — audio "),
1471 )
1472 .wrap(Wrap { trim: false });
1473 f.render_widget(body, f.area());
1474 })?;
1475 std::thread::sleep(std::time::Duration::from_millis(1400));
1476 Ok(())
1477}
1478
1479async fn run_tui(
1480 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1481 atlas_endpoint: &str,
1482 liaison_endpoint: &str,
1483 mut chat_cfg: ChatConfig,
1484 audio_warnings: &[String],
1485) -> Result<()> {
1486 let local_user = format!("local:{}", whoami_username());
1487 let mut initial: Vec<ChatMessage> = Vec::with_capacity(1 + audio_warnings.len());
1488 initial.push(ChatMessage {
1489 role: Role::Status,
1490 text: format!(
1491 "Connected to Liaison at {liaison_endpoint} as {local_user}. \
1492 Enter = send · F2 = voice (auto end on silence) · Ctrl+A = audio settings · Esc = abort turn · Ctrl+C = quit."
1493 ),
1494 });
1495 for w in audio_warnings {
1496 initial.push(ChatMessage {
1497 role: Role::Status,
1498 text: w.clone(),
1499 });
1500 }
1501 let messages: Rc<RefCell<Vec<ChatMessage>>> = Rc::new(RefCell::new(initial));
1502 let forest: Rc<RefCell<ForestView>> = Rc::new(RefCell::new(ForestView::default()));
1503 let mut input = String::new();
1504 let mut scroll: u16 = 0;
1505 let mut busy = false;
1506 let session_id = Uuid::new_v4().to_string();
1507
1508 loop {
1509 draw(
1510 terminal,
1511 &messages.borrow(),
1512 &forest.borrow(),
1513 &input,
1514 scroll,
1515 busy,
1516 )?;
1517
1518 if event::poll(std::time::Duration::from_millis(50))?
1519 && let Event::Key(key) = event::read()?
1520 {
1521 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1522 let _ = notify_session_end(liaison_endpoint, &session_id, &local_user).await;
1523 break;
1524 }
1525
1526 if !busy
1531 && key.modifiers.contains(KeyModifiers::CONTROL)
1532 && key.code == KeyCode::Char('a')
1533 {
1534 match run_audio_settings_page(atlas_endpoint, terminal, chat_cfg.clone()).await {
1535 Ok(new_cfg) => {
1536 chat_cfg = new_cfg;
1537 messages.borrow_mut().push(ChatMessage {
1538 role: Role::Status,
1539 text: format!(
1540 "audio settings updated: mic={}/{} · speaker={}/{}",
1541 chat_cfg.mic_cap_id.as_deref().unwrap_or("(unset)"),
1542 chat_cfg.mic_device_id.as_deref().unwrap_or("default"),
1543 chat_cfg.speaker_cap_id.as_deref().unwrap_or("(unset)"),
1544 chat_cfg.speaker_device_id.as_deref().unwrap_or("default"),
1545 ),
1546 });
1547 }
1548 Err(e) => messages.borrow_mut().push(ChatMessage {
1549 role: Role::Status,
1550 text: format!("audio settings: {e:#}"),
1551 }),
1552 }
1553 continue;
1554 }
1555
1556 if !busy && key.code == KeyCode::F(2) {
1558 busy = true;
1559 messages.borrow_mut().push(ChatMessage {
1560 role: Role::Status,
1561 text: "F2 — starting voice session…".to_string(),
1562 });
1563 draw(
1564 terminal,
1565 &messages.borrow(),
1566 &forest.borrow(),
1567 &input,
1568 scroll,
1569 busy,
1570 )?;
1571 if let Err(e) = run_voice_session_with_esc_abort(
1572 liaison_endpoint,
1573 &session_id,
1574 &local_user,
1575 Rc::clone(&messages),
1576 &forest,
1577 terminal,
1578 &input,
1579 &mut scroll,
1580 &chat_cfg,
1581 )
1582 .await
1583 {
1584 messages.borrow_mut().push(ChatMessage {
1585 role: Role::Status,
1586 text: format!("Voice error: {e:#}"),
1587 });
1588 }
1589 busy = false;
1590 continue;
1591 }
1592
1593 if busy {
1594 match key.code {
1595 KeyCode::PageUp => scroll = scroll.saturating_add(5),
1596 KeyCode::PageDown => scroll = scroll.saturating_sub(5),
1597 _ => {}
1598 }
1599 continue;
1600 }
1601 match key.code {
1602 KeyCode::Enter => {
1603 let msg = input.trim().to_string();
1604 input.clear();
1605 scroll = 0;
1606 if msg.is_empty() {
1607 continue;
1608 }
1609 if msg == "quit" || msg == "exit" {
1610 let _ =
1611 notify_session_end(liaison_endpoint, &session_id, &local_user).await;
1612 break;
1613 }
1614 messages.borrow_mut().push(ChatMessage {
1615 role: Role::User,
1616 text: msg.clone(),
1617 });
1618 forest.borrow_mut().begin_turn();
1620 busy = true;
1621 draw(
1622 terminal,
1623 &messages.borrow(),
1624 &forest.borrow(),
1625 &input,
1626 scroll,
1627 busy,
1628 )?;
1629
1630 match run_text_intent_with_esc_abort(
1631 liaison_endpoint,
1632 &session_id,
1633 &local_user,
1634 &msg,
1635 Rc::clone(&messages),
1636 &forest,
1637 terminal,
1638 &input,
1639 &mut scroll,
1640 )
1641 .await
1642 {
1643 Ok(()) => {}
1644 Err(e) => {
1645 messages.borrow_mut().push(ChatMessage {
1646 role: Role::Status,
1647 text: format!("Error: {e:#}"),
1648 });
1649 }
1650 }
1651 busy = false;
1652 }
1653 KeyCode::Char(c) => input.push(c),
1654 KeyCode::Backspace => {
1655 input.pop();
1656 }
1657 KeyCode::PageUp => scroll = scroll.saturating_add(5),
1658 KeyCode::PageDown => scroll = scroll.saturating_sub(5),
1659 KeyCode::Esc if !input.is_empty() => {
1665 input.clear();
1666 }
1667 _ => {}
1668 }
1669 }
1670 }
1671 Ok(())
1672}
1673
1674fn build_text_task(session_id: &str, user_id: &str, text: &str) -> crate::pb::pilot::Task {
1677 use crate::pb::pilot::Task;
1678 const INTENT_SOURCE_TEXT: u32 = 0;
1679 Task {
1680 task_id: Uuid::new_v4().to_string(),
1681 session_id: session_id.to_string(),
1682 source: INTENT_SOURCE_TEXT,
1683 text: text.to_string(),
1684 audio_data: vec![],
1685 context_json: serde_json::json!({"user_id": user_id, "modality": "text"}).to_string(),
1686 timestamp_ms: now_ms(),
1687 }
1688}
1689
1690fn build_control_task(
1691 session_id: &str,
1692 user_id: &str,
1693 extra_context_json: &str,
1694) -> crate::pb::pilot::Task {
1695 use crate::pb::pilot::Task;
1696 const INTENT_SOURCE_TEXT: u32 = 0;
1697 let mut ctx: serde_json::Value =
1698 serde_json::from_str(extra_context_json.trim()).unwrap_or_else(|_| serde_json::json!({}));
1699 if let Some(obj) = ctx.as_object_mut() {
1700 obj.entry("user_id").or_insert(serde_json::json!(user_id));
1701 }
1702 Task {
1703 task_id: Uuid::new_v4().to_string(),
1704 session_id: session_id.to_string(),
1705 source: INTENT_SOURCE_TEXT,
1706 text: String::new(),
1707 audio_data: vec![],
1708 context_json: ctx.to_string(),
1709 timestamp_ms: now_ms(),
1710 }
1711}
1712
1713async fn notify_session_end(liaison_endpoint: &str, session_id: &str, user_id: &str) -> Result<()> {
1714 use crate::pb::contracts::robonix_system_liaison_submit_client::RobonixSystemLiaisonSubmitClient;
1715
1716 let mut client = RobonixSystemLiaisonSubmitClient::connect(liaison_endpoint.to_string())
1717 .await
1718 .context("failed to connect to Liaison for session_end")?;
1719
1720 let task = build_control_task(session_id, user_id, r#"{"session_end":true}"#);
1721 let mut stream = client
1722 .submit_task(tonic::Request::new(task))
1723 .await
1724 .context("Liaison Stream session_end failed")?
1725 .into_inner();
1726 while stream.next().await.is_some() {}
1727 Ok(())
1728}
1729
1730async fn abort_session(liaison_endpoint: &str, session_id: &str, user_id: &str) -> Result<()> {
1731 use crate::pb::contracts::robonix_system_liaison_submit_client::RobonixSystemLiaisonSubmitClient;
1732
1733 let mut client = RobonixSystemLiaisonSubmitClient::connect(liaison_endpoint.to_string())
1734 .await
1735 .context("failed to connect to Liaison for abort_turn")?;
1736
1737 let task = build_control_task(session_id, user_id, r#"{"abort_turn":true}"#);
1738 let _ = client
1739 .submit_task(tonic::Request::new(task))
1740 .await
1741 .context("Liaison abort_turn Stream failed")?;
1742 Ok(())
1743}
1744
1745fn spawn_voice_steer(
1751 liaison_endpoint: &str,
1752 session_id: &str,
1753 user_id: &str,
1754 chat_cfg: &ChatConfig,
1755) {
1756 use crate::pb::contracts::robonix_system_liaison_voice_client::RobonixSystemLiaisonVoiceClient;
1757 use crate::pb::liaison::StartVoiceSessionRequest;
1758 let ep = liaison_endpoint.to_string();
1759 let req = StartVoiceSessionRequest {
1760 session_id: session_id.to_string(),
1761 client_user_id: user_id.to_string(),
1762 record_seconds: voice_record_seconds(),
1763 language: voice_language(),
1764 tts_enabled: false,
1765 mic_node_id: voice_node_with_cfg("ROBONIX_CHAT_MIC_NODE", chat_cfg.mic_cap_id.as_deref()),
1766 asr_node_id: voice_node("ROBONIX_CHAT_ASR_NODE"),
1767 voiceprint_node_id: String::new(),
1768 tts_node_id: voice_node("ROBONIX_CHAT_TTS_NODE"),
1769 speaker_node_id: voice_node_with_cfg(
1770 "ROBONIX_CHAT_SPEAKER_NODE",
1771 chat_cfg.speaker_cap_id.as_deref(),
1772 ),
1773 context_json: String::new(),
1774 };
1775 tokio::spawn(async move {
1776 if let Ok(mut client) = RobonixSystemLiaisonVoiceClient::connect(ep).await
1777 && let Ok(resp) = client.start_voice_session(tonic::Request::new(req)).await
1778 {
1779 let mut s = resp.into_inner();
1780 while s.next().await.is_some() {}
1781 }
1782 });
1783}
1784
1785#[allow(clippy::too_many_arguments)]
1788async fn run_text_intent_with_esc_abort(
1789 liaison_endpoint: &str,
1790 session_id: &str,
1791 user_id: &str,
1792 user_msg: &str,
1793 messages: Rc<RefCell<Vec<ChatMessage>>>,
1794 forest: &Rc<RefCell<ForestView>>,
1795 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1796 input: &str,
1797 scroll: &mut u16,
1798) -> Result<()> {
1799 use crate::pb::contracts::robonix_system_liaison_submit_client::RobonixSystemLiaisonSubmitClient;
1800 use crate::pb::pilot::PilotEvent;
1801 use tonic::Status;
1802
1803 let (tx, mut rx) = tokio::sync::mpsc::channel::<Result<PilotEvent, Status>>(64);
1804 let liaison_ep = liaison_endpoint.to_string();
1805 let task = build_text_task(session_id, user_id, user_msg);
1806
1807 let _stream_task = tokio::spawn(async move {
1808 let mut client = match RobonixSystemLiaisonSubmitClient::connect(liaison_ep.clone()).await {
1809 Ok(c) => c,
1810 Err(e) => {
1811 let _ = tx.send(Err(Status::unavailable(e.to_string()))).await;
1812 return;
1813 }
1814 };
1815 let stream = match client.submit_task(tonic::Request::new(task)).await {
1816 Ok(r) => r.into_inner(),
1817 Err(e) => {
1818 let _ = tx.send(Err(e)).await;
1819 return;
1820 }
1821 };
1822 let mut stream = stream;
1823 while let Some(item) = stream.next().await {
1824 if tx.send(item).await.is_err() {
1825 break;
1826 }
1827 }
1828 });
1829
1830 let _ = input;
1835 let mut steer_input = String::new();
1836
1837 loop {
1838 tokio::select! {
1839 biased;
1840 item = rx.recv() => {
1841 match item {
1842 None => break,
1843 Some(Ok(event)) => {
1844 apply_pilot_event(&messages, forest, &event)?;
1845 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, 0, true)?;
1846 }
1847 Some(Err(e)) => {
1848 messages.borrow_mut().push(ChatMessage {
1849 role: Role::Status,
1850 text: format!("Liaison stream error: {e}"),
1851 });
1852 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, 0, true)?;
1853 break;
1854 }
1855 }
1856 }
1857 _ = tokio::time::sleep(std::time::Duration::from_millis(25)) => {
1858 if event::poll(std::time::Duration::ZERO)?
1859 && let Event::Key(key) = event::read()? {
1860 match key.code {
1861 KeyCode::Esc => {
1862 let _ = abort_session(liaison_endpoint, session_id, user_id).await;
1863 messages.borrow_mut().push(ChatMessage {
1864 role: Role::Status,
1865 text: "Esc — abort_turn sent (in-flight turn should stop)."
1866 .to_string(),
1867 });
1868 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
1869 }
1870 KeyCode::Enter => {
1871 let steer = steer_input.trim().to_string();
1872 steer_input.clear();
1873 if !steer.is_empty() {
1874 messages.borrow_mut().push(ChatMessage {
1875 role: Role::User,
1876 text: format!("(steer) {steer}"),
1877 });
1878 if let Err(e) =
1879 send_steer(liaison_endpoint, session_id, user_id, &steer)
1880 .await
1881 {
1882 messages.borrow_mut().push(ChatMessage {
1883 role: Role::Status,
1884 text: format!("steer failed: {e:#}"),
1885 });
1886 }
1887 *scroll = 0;
1888 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
1889 }
1890 }
1891 KeyCode::Char(c) => {
1892 steer_input.push(c);
1893 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
1894 }
1895 KeyCode::Backspace => {
1896 steer_input.pop();
1897 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
1898 }
1899 KeyCode::PageUp => {
1900 *scroll = scroll.saturating_add(5);
1901 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
1902 }
1903 KeyCode::PageDown => {
1904 *scroll = scroll.saturating_sub(5);
1905 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
1906 }
1907 _ => {}
1908 }
1909 }
1910 }
1911 }
1912 }
1913 Ok(())
1914}
1915
1916async fn send_steer(
1919 liaison_endpoint: &str,
1920 session_id: &str,
1921 user_id: &str,
1922 text: &str,
1923) -> Result<()> {
1924 use crate::pb::contracts::robonix_system_liaison_submit_client::RobonixSystemLiaisonSubmitClient;
1925
1926 let mut client = RobonixSystemLiaisonSubmitClient::connect(liaison_endpoint.to_string())
1927 .await
1928 .context("failed to connect to Liaison for steer")?;
1929 let task = build_text_task(session_id, user_id, text);
1930 let mut stream = client
1931 .submit_task(tonic::Request::new(task))
1932 .await
1933 .context("Liaison steer submit failed")?
1934 .into_inner();
1935 while stream.next().await.is_some() {}
1936 Ok(())
1937}
1938
1939#[allow(clippy::too_many_arguments)]
1942async fn run_voice_session_with_esc_abort(
1943 liaison_endpoint: &str,
1944 session_id: &str,
1945 user_id: &str,
1946 messages: Rc<RefCell<Vec<ChatMessage>>>,
1947 forest: &Rc<RefCell<ForestView>>,
1948 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1949 input: &str,
1950 scroll: &mut u16,
1951 chat_cfg: &ChatConfig,
1952) -> Result<()> {
1953 use crate::pb::contracts::robonix_system_liaison_voice_client::RobonixSystemLiaisonVoiceClient;
1954 use crate::pb::liaison::{StartVoiceSessionRequest, VoiceEvent};
1955 use tonic::Status;
1956
1957 let req = StartVoiceSessionRequest {
1958 session_id: session_id.to_string(),
1959 client_user_id: user_id.to_string(),
1960 record_seconds: voice_record_seconds(),
1961 language: voice_language(),
1962 tts_enabled: voice_tts_enabled(),
1963 mic_node_id: voice_node_with_cfg("ROBONIX_CHAT_MIC_NODE", chat_cfg.mic_cap_id.as_deref()),
1964 asr_node_id: voice_node("ROBONIX_CHAT_ASR_NODE"),
1965 voiceprint_node_id: String::new(),
1966 tts_node_id: voice_node("ROBONIX_CHAT_TTS_NODE"),
1967 speaker_node_id: voice_node_with_cfg(
1968 "ROBONIX_CHAT_SPEAKER_NODE",
1969 chat_cfg.speaker_cap_id.as_deref(),
1970 ),
1971 context_json: String::new(),
1972 };
1973
1974 let (tx, mut rx) = tokio::sync::mpsc::channel::<Result<VoiceEvent, Status>>(64);
1975 let liaison_ep = liaison_endpoint.to_string();
1976
1977 let _stream_task = tokio::spawn(async move {
1978 let mut client = match RobonixSystemLiaisonVoiceClient::connect(liaison_ep.clone()).await {
1979 Ok(c) => c,
1980 Err(e) => {
1981 let _ = tx.send(Err(Status::unavailable(e.to_string()))).await;
1982 return;
1983 }
1984 };
1985 let stream = match client.start_voice_session(tonic::Request::new(req)).await {
1986 Ok(r) => r.into_inner(),
1987 Err(e) => {
1988 let _ = tx.send(Err(e)).await;
1989 return;
1990 }
1991 };
1992 let mut stream = stream;
1993 while let Some(item) = stream.next().await {
1994 if tx.send(item).await.is_err() {
1995 break;
1996 }
1997 }
1998 });
1999
2000 let _ = input;
2004 let mut steer_input = String::new();
2005
2006 loop {
2007 tokio::select! {
2008 biased;
2009 item = rx.recv() => {
2010 match item {
2011 None => break,
2012 Some(Ok(event)) => {
2013 apply_voice_event(&messages, forest, &event)?;
2014 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, 0, true)?;
2015 }
2016 Some(Err(e)) => {
2017 messages.borrow_mut().push(ChatMessage {
2018 role: Role::Status,
2019 text: format!("Voice stream error: {e}"),
2020 });
2021 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, 0, true)?;
2022 break;
2023 }
2024 }
2025 }
2026 _ = tokio::time::sleep(std::time::Duration::from_millis(25)) => {
2027 if event::poll(std::time::Duration::ZERO)?
2028 && let Event::Key(key) = event::read()? {
2029 if key.code == KeyCode::Char('v')
2030 && key.modifiers.contains(KeyModifiers::CONTROL)
2031 {
2032 messages.borrow_mut().push(ChatMessage {
2033 role: Role::Voice,
2034 text: "Ctrl+V — voice steer: speak your addition…".to_string(),
2035 });
2036 spawn_voice_steer(liaison_endpoint, session_id, user_id, chat_cfg);
2037 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
2038 } else {
2039 match key.code {
2040 KeyCode::Esc => {
2041 let _ = abort_session(liaison_endpoint, session_id, user_id).await;
2042 messages.borrow_mut().push(ChatMessage {
2043 role: Role::Status,
2044 text: "Esc — abort_turn sent (Pilot stops; voice playback may still finish).".to_string(),
2045 });
2046 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
2047 }
2048 KeyCode::Enter => {
2049 let steer = steer_input.trim().to_string();
2050 steer_input.clear();
2051 if !steer.is_empty() {
2052 messages.borrow_mut().push(ChatMessage {
2053 role: Role::User,
2054 text: format!("(steer) {steer}"),
2055 });
2056 if let Err(e) =
2057 send_steer(liaison_endpoint, session_id, user_id, &steer).await
2058 {
2059 messages.borrow_mut().push(ChatMessage {
2060 role: Role::Status,
2061 text: format!("steer failed: {e:#}"),
2062 });
2063 }
2064 *scroll = 0;
2065 }
2066 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
2067 }
2068 KeyCode::Char(c) => {
2069 steer_input.push(c);
2070 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
2071 }
2072 KeyCode::Backspace => {
2073 steer_input.pop();
2074 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
2075 }
2076 KeyCode::PageUp => {
2077 *scroll = scroll.saturating_add(5);
2078 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
2079 }
2080 KeyCode::PageDown => {
2081 *scroll = scroll.saturating_sub(5);
2082 draw(terminal, &messages.borrow(), &forest.borrow(), &steer_input, *scroll, true)?;
2083 }
2084 _ => {}
2085 }
2086 }
2087 }
2088 }
2089 }
2090 }
2091 Ok(())
2092}
2093
2094fn voice_record_seconds() -> u32 {
2095 std::env::var("ROBONIX_CHAT_VOICE_SECONDS")
2096 .ok()
2097 .and_then(|s| s.parse().ok())
2098 .unwrap_or(0) }
2100
2101fn voice_language() -> String {
2102 std::env::var("ROBONIX_CHAT_VOICE_LANG").unwrap_or_default()
2103}
2104
2105fn voice_tts_enabled() -> bool {
2106 !matches!(
2107 std::env::var("ROBONIX_CHAT_VOICE_TTS").as_deref(),
2108 Ok("0") | Ok("false") | Ok("no") | Ok("off")
2109 )
2110}
2111
2112fn voice_node(env_key: &str) -> String {
2113 std::env::var(env_key).unwrap_or_default()
2114}
2115
2116fn voice_node_with_cfg(env_key: &str, cfg_value: Option<&str>) -> String {
2120 if let Ok(v) = std::env::var(env_key)
2121 && !v.is_empty()
2122 {
2123 return v;
2124 }
2125 cfg_value.unwrap_or("").to_string()
2126}
2127
2128fn apply_pilot_event(
2131 messages: &Rc<RefCell<Vec<ChatMessage>>>,
2132 forest: &Rc<RefCell<ForestView>>,
2133 event: &crate::pb::pilot::PilotEvent,
2134) -> Result<()> {
2135 const EVT_TEXT_CHUNK: u32 = 0;
2137 const EVT_PLAN: u32 = 1;
2138 const EVT_BATCH_RESULT: u32 = 2;
2139 const EVT_FINAL_TEXT: u32 = 4;
2140 const EVT_NODE_STATE: u32 = 5;
2141 const EVT_TASK_STATE: u32 = 6;
2142
2143 let mut m = messages.borrow_mut();
2144 match event.event_kind {
2145 EVT_TEXT_CHUNK => {
2146 let t = event.text_chunk.clone();
2147 if let Some(last) = m.last_mut() {
2148 if matches!(last.role, Role::Agent) {
2149 last.text.push_str(&t);
2150 } else {
2151 m.push(ChatMessage {
2152 role: Role::Agent,
2153 text: t,
2154 });
2155 }
2156 } else {
2157 m.push(ChatMessage {
2158 role: Role::Agent,
2159 text: t,
2160 });
2161 }
2162 }
2163 EVT_FINAL_TEXT => {
2164 let t = event.final_text.clone();
2165 let has_agent = m.last().is_some_and(|x| matches!(x.role, Role::Agent));
2166 if !has_agent && !t.is_empty() {
2167 m.push(ChatMessage {
2168 role: Role::Agent,
2169 text: t,
2170 });
2171 }
2172 }
2173 EVT_PLAN => {
2174 if let Some(ref p) = event.plan {
2177 m.push(ChatMessage {
2178 role: Role::ToolCall,
2179 text: render_plan_tree(p),
2180 });
2181 let mut f = forest.borrow_mut();
2182 if f.plan_mut(&p.plan_id).is_none() {
2183 f.plans.push(PlanView {
2184 plan_id: p.plan_id.clone(),
2185 plan: p.clone(),
2186 node_states: HashMap::new(),
2187 done: false,
2188 any_failed: false,
2189 });
2190 }
2191 }
2192 }
2193 EVT_NODE_STATE => {
2194 if let Some(ref ns) = event.node_state {
2195 let mut f = forest.borrow_mut();
2196 if let Some(pv) = f.plan_mut(&ns.plan_id) {
2197 pv.node_states.insert(ns.node_index, ns.state);
2198 }
2199 }
2200 }
2201 EVT_BATCH_RESULT => {
2202 if let Some(ref b) = event.batch_result {
2203 let mut f = forest.borrow_mut();
2204 if let Some(pv) = f.plan_mut(&b.plan_id) {
2205 pv.done = true;
2206 pv.any_failed = b.any_failed;
2207 }
2208 }
2209 }
2210 EVT_TASK_STATE => {
2211 if let Some(ref ts) = event.task_state {
2212 let mut f = forest.borrow_mut();
2213 f.goal = ts.goal.clone();
2214 f.success_criterion = ts.success_criterion.clone();
2215 f.status = ts.status.clone();
2216 }
2217 }
2218 _ => {}
2219 }
2220 Ok(())
2221}
2222
2223fn render_plan_tree(plan: &crate::pb::pilot::Plan) -> String {
2231 let mut lines = vec![format!("[plan {}]", plan.plan_id)];
2232 if (plan.root_index as usize) < plan.nodes.len() {
2233 render_plan_node(plan, plan.root_index as usize, "", true, true, &mut lines);
2234 }
2235 lines.join("\n")
2236}
2237
2238fn render_plan_node(
2239 plan: &crate::pb::pilot::Plan,
2240 idx: usize,
2241 prefix: &str,
2242 is_root: bool,
2243 is_last: bool,
2244 out: &mut Vec<String>,
2245) {
2246 let node = &plan.nodes[idx];
2247 let label = match node.node_kind {
2248 0 => format!("sequence: {}", node.description),
2249 1 => format!("parallel: {}", node.description),
2250 _ => match node.call.as_ref() {
2251 Some(c) => compact_call_label(&c.provider_id, &c.contract_id, &c.args_json),
2252 None => node.description.clone(),
2253 },
2254 };
2255 if is_root {
2256 out.push(label);
2257 } else {
2258 let branch = if is_last { "└─ " } else { "├─ " };
2259 out.push(format!("{prefix}{branch}{label}"));
2260 }
2261 let n = node.children.len();
2262 for (i, child) in node.children.iter().enumerate() {
2263 let child_prefix = if is_root {
2264 String::new()
2265 } else if is_last {
2266 format!("{prefix} ")
2267 } else {
2268 format!("{prefix}│ ")
2269 };
2270 render_plan_node(plan, *child as usize, &child_prefix, false, i + 1 == n, out);
2271 }
2272}
2273
2274fn apply_voice_event(
2275 messages: &Rc<RefCell<Vec<ChatMessage>>>,
2276 forest: &Rc<RefCell<ForestView>>,
2277 event: &crate::pb::liaison::VoiceEvent,
2278) -> Result<()> {
2279 const KIND_SESSION_STARTED: u32 = 0;
2281 const KIND_RECORDING_STARTED: u32 = 1;
2282 const KIND_RECORDING_DONE: u32 = 2;
2283 const KIND_ASR_PARTIAL: u32 = 3;
2284 const KIND_ASR_FINAL: u32 = 4;
2285 const KIND_USER_IDENTIFIED: u32 = 5;
2286 const KIND_PILOT: u32 = 6;
2287 const KIND_TTS_STARTED: u32 = 7;
2288 const KIND_TTS_DONE: u32 = 8;
2289 const KIND_SESSION_DONE: u32 = 9;
2290 const KIND_ERROR: u32 = 10;
2291
2292 match event.event_kind {
2293 KIND_SESSION_STARTED | KIND_RECORDING_STARTED | KIND_RECORDING_DONE => {
2294 messages.borrow_mut().push(ChatMessage {
2295 role: Role::Voice,
2296 text: format!("voice · {}", event.status_message),
2297 });
2298 }
2299 KIND_ASR_PARTIAL => {
2300 messages.borrow_mut().push(ChatMessage {
2301 role: Role::Voice,
2302 text: format!("asr (partial, {:.2}): {}", event.confidence, event.text),
2303 });
2304 }
2305 KIND_ASR_FINAL => {
2306 messages.borrow_mut().push(ChatMessage {
2307 role: Role::User,
2308 text: format!("(voice) {}", event.text),
2309 });
2310 }
2311 KIND_USER_IDENTIFIED => {
2312 let label = if event.status_message.is_empty() {
2313 format!("identified user → {}", event.user_id)
2314 } else {
2315 format!(
2316 "identified user → {} · {}",
2317 event.user_id, event.status_message
2318 )
2319 };
2320 messages.borrow_mut().push(ChatMessage {
2321 role: Role::Voice,
2322 text: label,
2323 });
2324 }
2325 KIND_PILOT => {
2326 if let Some(ref pe) = event.pilot {
2327 apply_pilot_event(messages, forest, pe)?;
2328 }
2329 }
2330 KIND_TTS_STARTED => {
2331 messages.borrow_mut().push(ChatMessage {
2332 role: Role::Voice,
2333 text: format!("tts · {}", event.status_message),
2334 });
2335 }
2336 KIND_TTS_DONE => {
2337 messages.borrow_mut().push(ChatMessage {
2338 role: Role::Voice,
2339 text: format!("tts done · {}", event.status_message),
2340 });
2341 }
2342 KIND_SESSION_DONE => {
2343 messages.borrow_mut().push(ChatMessage {
2344 role: Role::Status,
2345 text: "voice session done".to_string(),
2346 });
2347 }
2348 KIND_ERROR => {
2349 messages.borrow_mut().push(ChatMessage {
2350 role: Role::Status,
2351 text: format!("voice error: {}", event.error),
2352 });
2353 }
2354 _ => {
2355 messages.borrow_mut().push(ChatMessage {
2356 role: Role::Voice,
2357 text: format!("voice (kind={}) {}", event.event_kind, event.status_message),
2358 });
2359 }
2360 }
2361 Ok(())
2362}
2363
2364fn draw(
2365 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
2366 messages: &[ChatMessage],
2367 forest: &ForestView,
2368 input: &str,
2369 scroll: u16,
2370 busy: bool,
2371) -> Result<()> {
2372 terminal.draw(|f| {
2373 let area = f.area();
2374 let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).split(area);
2375 let body =
2377 Layout::horizontal([Constraint::Min(24), Constraint::Length(46)]).split(chunks[0]);
2378
2379 let mut lines: Vec<Line> = Vec::new();
2380 for msg in messages {
2381 let (prefix, indent, style) = match msg.role {
2382 Role::User => (
2383 "You: ",
2384 " ",
2385 Style::default()
2386 .fg(Color::Cyan)
2387 .add_modifier(Modifier::BOLD),
2388 ),
2389 Role::Agent => ("Robonix: ", " ", Style::default().fg(Color::Green)),
2395 Role::ToolCall => ("> ", " ", Style::default().fg(Color::Yellow)),
2396 Role::Status => (
2397 "",
2398 "",
2399 Style::default()
2400 .fg(Color::Magenta)
2401 .add_modifier(Modifier::ITALIC),
2402 ),
2403 Role::Voice => (
2404 "[v] ",
2405 " ",
2406 Style::default()
2407 .fg(Color::Blue)
2408 .add_modifier(Modifier::ITALIC),
2409 ),
2410 };
2411 for (i, text_line) in msg.text.lines().enumerate() {
2412 let lead = if i == 0 { prefix } else { indent };
2413 lines.push(Line::from(vec![
2414 Span::styled(lead, style),
2415 Span::styled(text_line.to_string(), style),
2416 ]));
2417 }
2418 }
2419
2420 let status = if busy { " [thinking...]" } else { "" };
2421 let block = Block::default()
2422 .borders(Borders::ALL)
2423 .title(format!(" Robonix{status} "));
2424 let inner = block.inner(body[0]);
2425
2426 let text_only = Paragraph::new(lines.clone()).wrap(Wrap { trim: false });
2427 let total_lines = text_only.line_count(inner.width) as u16;
2428 let visible = inner.height;
2429 let auto_scroll = if scroll == 0 {
2430 total_lines.saturating_sub(visible)
2431 } else {
2432 total_lines.saturating_sub(visible).saturating_sub(scroll)
2433 };
2434
2435 let history = Paragraph::new(lines)
2436 .block(block)
2437 .wrap(Wrap { trim: false })
2438 .scroll((auto_scroll, 0));
2439 f.render_widget(history, body[0]);
2440
2441 let panel_block = Block::default()
2444 .borders(Borders::ALL)
2445 .title(" Task / Forest ");
2446 let panel_inner_h = panel_block.inner(body[1]).height;
2447 let panel_lines = forest_panel_lines(forest);
2448 let panel_scroll = (panel_lines.len() as u16).saturating_sub(panel_inner_h);
2449 let panel = Paragraph::new(panel_lines)
2450 .block(panel_block)
2451 .wrap(Wrap { trim: false })
2452 .scroll((panel_scroll, 0));
2453 f.render_widget(panel, body[1]);
2454
2455 let input_widget = Paragraph::new(input.to_string()).block(
2456 Block::default()
2457 .borders(Borders::ALL)
2458 .title(" > Enter = send · F2 = voice (auto end) · Esc = abort · Ctrl+C = quit "),
2459 );
2460 f.render_widget(input_widget, chunks[1]);
2461 })?;
2462 Ok(())
2463}
2464
2465fn now_ms() -> u64 {
2466 std::time::SystemTime::now()
2467 .duration_since(std::time::UNIX_EPOCH)
2468 .unwrap_or_default()
2469 .as_millis() as u64
2470}
2471
2472fn localhost_to_ipv4_loopback(url: &str) -> String {
2475 url.replace("localhost", "127.0.0.1")
2476}
2477
2478fn whoami_username() -> String {
2482 std::env::var("USER")
2483 .or_else(|_| std::env::var("USERNAME"))
2484 .unwrap_or_else(|_| "user".to_string())
2485}