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