Skip to main content

rbnx/cmd/
chat.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// cmd/chat.rs — TUI chat client (connects to robonix-liaison via SrvLiaison).
3//
4// Modalities (one TUI, one stream type per turn):
5//
6//   ┌────────────────┐ Enter ─────────► SrvLiaison.Stream(Task)
7//   │ rbnx chat TUI  │                 (text → PilotEvent stream)
8//   └────────────────┘ F2 ────────► SrvLiaison.StartVoiceSession(req)
9//                                       (voice → VoiceEvent stream which
10//                                        wraps PilotEvent in `pilot` field)
11//
12// Liaison handles user-id assignment, voice mic/ASR/voiceprint/TTS/speaker
13// orchestration, and forwarding to Pilot. The TUI just renders events.
14
15use 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";
40// Liaison was split into two contracts (submit + voice). chat only needs
41// to find one of them in atlas to know liaison is up; submit is the
42// always-present one (voice is optional / mode-gated), so wait on it.
43const LIAISON_CONTRACT_ID: &str = "robonix/system/liaison/submit";
44
45/// Atlas contract ids for the audio primitives that have user-visible
46/// device choices on a multi-provider host (e.g. local ALSA driver vs
47/// the macOS bridge). asr/tts are software backends with one provider
48/// per box, so we don't prompt for them.
49const 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/// Live RTDL forest state for the right-side panel. Fed by `PilotEvent`
66/// task_state / plan / node_state / batch_result events; rendered every frame.
67#[derive(Default)]
68struct ForestView {
69    goal: String,
70    success_criterion: String,
71    status: String,
72    /// In-dispatch order; each is one RTDL tree of the current turn.
73    plans: Vec<PlanView>,
74}
75
76struct PlanView {
77    plan_id: String,
78    plan: crate::pb::pilot::Plan,
79    /// node_index → executor state (2=ok 3=fail 4=cancel …). Absent = pending.
80    node_states: HashMap<u32, u32>,
81    done: bool,
82    any_failed: bool,
83}
84
85impl ForestView {
86    /// Reset for a brand-new top-level turn (a fresh user message, not a steer).
87    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
99/// The standard LLM-facing capability name, exactly as pilot presents it to
100/// the model and as the model emits it in `do.cap`: `provider_id.<area>_<leaf>`
101/// — e.g. `tiago_camera.camera_snapshot`, `tiago_lidar.lidar_snapshot`,
102/// `explore.explore_status`, `executor.builtin_read_file`. The `<area>_<leaf>`
103/// part mirrors `robonix_pilot::discovery::llm_name` (kept in sync here since
104/// rbnx-cli doesn't depend on the pilot crate); the `<area>` prefix is what
105/// disambiguates the shared `snapshot` / `status` leaves across providers.
106fn 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
122/// Compact one-line label for a `do` node: `provider.leaf(short args)`.
123/// Collapses whitespace/newlines (run_command args are whole shell scripts)
124/// and elides past ~48 chars so the tree stays readable in both the log and
125/// the panel.
126fn 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
140/// Glyph + style for one node given its executor state (None = pending).
141fn 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
158/// Render the whole forest panel (goal + status + every tree with per-node
159/// live state) as styled lines.
160fn 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    // Panel is a simple schematic: capability name only (no args), and bare
237    // operator labels. The full args live in the left-hand log tree.
238    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/// Persisted user choices for `rbnx chat`, written to ~/.robonix/chat.yaml.
274///
275/// Two layers per audio side:
276///   *_cap_id      → which audio impl provider in atlas (e.g. .alsa vs .macos)
277///   *_device_id   → which OS-level device that impl should drive (impl-
278///                   specific id from ListAudioDevices; "" = OS default)
279///
280/// Hot-swap-able: Ctrl+A in chat re-runs the picker. Initial run reads
281/// this file; missing fields trigger a picker prompt.
282#[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    // ensure_audio_devices is best-effort: if atlas is unreachable, no
349    // audio providers are registered, or the user skips with Esc, we
350    // surface the reason as a warning in the chat history and continue
351    // — text chat works without any audio path. Hard errors here would
352    // mean a single-user typo at audio-pick time kills the whole TUI.
353    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
378/// Try to discover Liaison via Atlas, retrying for up to `timeout_secs`.
379async 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// ── Audio-device first-run picker ───────────────────────────────────────────
432//
433// Resolution order for mic/speaker capability_ids when starting a voice
434// session is: ROBONIX_CHAT_*_NODE env (highest, overrides everything) →
435// chat.yaml on disk → first-run TUI picker. The picker only fires when
436// neither env nor config supplies the provider_id; once chosen it's saved
437// to ~/.robonix/chat.yaml so future sessions don't ask again. To
438// re-pick: delete that file (or just edit it), re-run rbnx chat.
439//
440// Why this lives here and not in liaison: the user choice is per-client
441// preference (which physical box's audio do I want when sitting at this
442// terminal), not a per-deployment server setting.
443
444/// Pick mode for the legacy modal picker chain (FirstRun only — the
445/// Ctrl+A path now uses the dashboard via `run_audio_settings_page`).
446/// `Reconfigure` is kept so the older code paths that still take a
447/// PickMode parameter compile cleanly even though nothing constructs it.
448#[derive(Clone, Copy)]
449#[allow(dead_code)]
450enum PickMode {
451    FirstRun,
452    Reconfigure,
453}
454
455/// Best-effort audio device discovery. Anything that goes wrong here
456/// (atlas unreachable, no providers registered, user pressed Esc) is
457/// downgraded to a chat-history warning — text mode keeps working.
458async 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    // Validate stored pins against current atlas state. A pin pointing at a
491    // provider that's not in this deploy (e.g. config saved from an earlier
492    // deploy where the audio provider was named differently) would silently break
493    // voice — re-prompt instead.
494    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
577/// True iff atlas currently has a provider whose id (or namespace) matches the
578/// pin AND it provides `contract` over GRPC. Used at chat startup to detect
579/// stale pins from a prior deploy whose audio provider has since been renamed
580/// or removed — caller drops the pin and re-prompts the picker instead of
581/// silently letting voice fail with "no provider".
582async 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        // Atlas unreachable — don't drop the pin on a transient failure;
588        // the outer caller already warned and degraded.
589        return true;
590    };
591    providers.iter().any(|r| r.id == pin || r.namespace == pin)
592}
593
594/// `Ok(Some((provider_id, device_id)))` = picked both layers; device_id may be ""
595/// when the impl returned UNIMPLEMENTED on list_devices.
596/// `Ok(None)` = no providers in atlas.
597#[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    // Layer A — provider (provider_id). FirstRun honours the saved choice
616    // when it's still in atlas, or auto-picks the lone provider with a
617    // 400 ms flash. Reconfigure (Ctrl+A) ALWAYS shows the TUI even
618    // for a single entry — auto-pick on Ctrl+A looks identical to
619    // "Ctrl+A did nothing", which is what the user just hit.
620    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    // Layer B — device id within the chosen impl. Connect to its
634    // list_devices contract (UNIMPLEMENTED is OK — fall through with "").
635    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    // Tell the impl which device to use. Best-effort; ignore failures.
647    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
656/// Connect to `provider_id`'s list_devices capability, ask for the device
657/// list, run a picker on the entries that match `kind` (input/output)
658/// + duplex. Returns "" when the impl doesn't expose the contract.
659async 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    // Reconfigure mode shows visible feedback for every silent path
672    // below; FirstRun stays quiet so a missing list_devices contract
673    // doesn't litter chat history with a non-actionable message.
674    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    // FirstRun: honour saved device when still listed; auto-pick a lone
748    // device with a flash. Reconfigure: always show the TUI so Ctrl+A
749    // gives the user a real page they can interact with.
750    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    // pick_device_tui returns "" on Esc/q — preserve any previously-saved
768    // device id so a Reconfigure-then-skip doesn't accidentally clear it.
769    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// ── Audio settings dashboard (btop-style, single page) ─────────────────────
929//
930// Replacement for the old sequential modal pickers. One screen, four
931// sections (mic provider / mic device / speaker provider / speaker
932// device), Tab cycles section, ↑↓ moves cursor within section, Enter
933// picks the highlighted item (and reloads device list when picking a
934// new provider, or calls SelectAudioDevice when picking a device),
935// Esc / Ctrl+A / q saves and closes.
936
937#[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        // Position cursors at the currently-selected entry so the user
1036        // sees what's active rather than always landing at row 0.
1037        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        // ─── MICROPHONE ───
1285        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        // ─── SPEAKER ───
1334        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
1457/// Render a single-line status page and pause briefly so the user can
1458/// read it before the next picker step (or the chat) takes over the
1459/// screen. Long messages now sit for 1.4 s — the previous 400 ms felt
1460/// like "the page just flashed and disappeared."
1461fn 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            // Ctrl+A → btop-style single-page audio settings dashboard.
1527            // All four sections (mic provider, mic device, speaker
1528            // provider, speaker device) visible at once, Tab to cycle,
1529            // ↑↓ to move within a section, Enter to pick. Esc to close.
1530            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            // F2 → push-to-talk voice session (auto-ends on silence).
1557            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                    // Fresh top-level task: reset the live forest panel.
1619                    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                // Idle Esc: clear the draft input. During a turn,
1660                // run_text_intent_with_esc_abort handles Esc differently
1661                // (sends abort_turn). The header line says "Esc = abort
1662                // turn" which is true mid-turn; idle Esc is a vim-style
1663                // "clear what I typed" affordance.
1664                KeyCode::Esc if !input.is_empty() => {
1665                    input.clear();
1666                }
1667                _ => {}
1668            }
1669        }
1670    }
1671    Ok(())
1672}
1673
1674// ── Liaison gRPC helpers ─────────────────────────────────────────────────────
1675
1676fn 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
1745/// Fire-and-forget a mid-turn VOICE steer (Ctrl+V during a running turn):
1746/// record + ASR another utterance via a fresh voice session and let its
1747/// transcript submit — Pilot steers it into the active turn. TTS is disabled
1748/// here (the per-segment playback rides the main turn's stream); we just drain
1749/// this session's events on a spawned task.
1750fn 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// ── Text turn ────────────────────────────────────────────────────────────────
1786
1787#[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    // Mid-task input: the user can keep typing while the turn runs. Enter sends
1831    // the text as a steer (a new Task on the same session); pilot routes it to
1832    // the running turn instead of starting a second one. The local buffer is
1833    // separate from the idle-draft `input` so the two never collide.
1834    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
1916/// Submit `text` as a mid-task steer on an already-running session. Pilot routes
1917/// it to the live turn's steer queue and returns an empty stream, which we drain.
1918async 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// ── Voice turn ───────────────────────────────────────────────────────────────
1940
1941#[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    // Mid-task steer during a voice turn (mirrors the text turn): type + Enter
2001    // sends a text steer; Ctrl+V records another utterance as a voice steer.
2002    // Pilot routes either into the running turn instead of starting a new one.
2003    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) // 0 → server default (5s)
2099}
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
2116/// Same as `voice_node` but falls back to `cfg_value` (from chat.yaml)
2117/// when the env var is unset / empty. Empty result still means "let
2118/// liaison auto-pick from atlas".
2119fn 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
2128// ── Event → ChatMessage rendering ────────────────────────────────────────────
2129
2130fn apply_pilot_event(
2131    messages: &Rc<RefCell<Vec<ChatMessage>>>,
2132    forest: &Rc<RefCell<ForestView>>,
2133    event: &crate::pb::pilot::PilotEvent,
2134) -> Result<()> {
2135    // event_kind discriminants — see PilotEvent.msg.
2136    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            // Log the dispatched RTDL tree as ASCII (historical record) and add
2175            // it to the live forest panel.
2176            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
2223/// Render a `Plan` arena as an ASCII tree, e.g.
2224/// ```text
2225/// [plan 1]
2226/// sequence: fetch water
2227/// ├─ navigate(kitchen)
2228/// └─ grasp(water cup)
2229/// ```
2230fn 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    // Mirror the kinds in voice.rs / VoiceEvent.msg
2280    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        // Split the body into chat (left) + live RTDL forest panel (right).
2376        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                // "Robonix:" — the user-facing system name. The reply
2390                // went through Liaison → Pilot → tools → Liaison → here,
2391                // so naming it after one internal crate ("Pilot") leaks
2392                // an architectural detail that the user has no reason
2393                // to know or care about.
2394                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        // Right: live task + RTDL forest. Auto-scrolls to the bottom so the
2442        // freshest trees stay visible.
2443        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
2472/// Pilot/Liaison bind IPv4 (`0.0.0.0`). Resolving `localhost` often prefers
2473/// `::1`, so the gRPC client hits IPv6 and gets connection refused — force IPv4 loopback.
2474fn localhost_to_ipv4_loopback(url: &str) -> String {
2475    url.replace("localhost", "127.0.0.1")
2476}
2477
2478/// Tiny username probe — avoids pulling in `whoami` for the CLI by reading
2479/// $USER/$USERNAME with a "user" fallback. Liaison uses the real `whoami`
2480/// crate when it stamps the canonical `local:<user>` identity.
2481fn whoami_username() -> String {
2482    std::env::var("USER")
2483        .or_else(|_| std::env::var("USERNAME"))
2484        .unwrap_or_else(|_| "user".to_string())
2485}