Skip to main content

rpi_cli/
tui_shell.rs

1//! Driver-generic TUI shell.
2//!
3//! The shell half of the unification: a single terminal UI that drives **any**
4//! [`SessionDriver`] — chrome (banner + status slot + footer), the editor dock,
5//! the input loop, and the slash-command surface. It renders the transcript
6//! through the shared [`TranscriptView`] and applies normalized [`UiEvent`]s, so
7//! the same shell serves the remote client today and the in-process host once
8//! its runner adopts the driver seam.
9//!
10//! Local-only capabilities (session tree, extensions, selectors) are not part of
11//! this shell: it is the transport-agnostic floor both frontends share.
12//!
13//! [`UiEvent`]: crate::transcript_view::UiEvent
14
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind};
19use rpi_tui::{
20    bold as tui_bold, Component, Container, Editor, Focusable, FooterComponent, ProcessTerminal,
21    ScrollView, StackChild, StackEntry, StatusIndicator, Text, TuiAltScreen, VStack, WorkingState,
22    TUI,
23};
24
25use crate::session_driver::{DriverCommand, DriverEvent, DriverStatus, SessionDriver};
26use crate::transcript_view::TranscriptView;
27
28/// Drive the generic shell against `driver` until the user exits (or the
29/// driver's event stream ends). `addr` is a display label for the banner.
30pub async fn run_driver_tui<D: SessionDriver>(driver: &D, addr: &str) -> Result<(), String> {
31    let mut events = driver
32        .events()
33        .ok_or("driver did not yield an event stream")?;
34
35    // ---- Build the UI ----
36    let chrome = ChatChrome::new(addr);
37    let document = Arc::new(Container::new());
38    document.add_child(chrome.header.clone());
39    let transcript_container = Arc::new(Container::new());
40    document.add_child(transcript_container.clone());
41    let scroll = Arc::new(ScrollView::primary(document.clone()));
42
43    // Retained transcript view: the same pi-tui components the local TUI uses,
44    // updated in place from the driver's normalized event stream.
45    let view = TranscriptView::new(transcript_container.clone());
46
47    let editor = Arc::new(Editor::simple());
48
49    // Layout mirrors the local TUI's bottom dock: transcript (grow) over
50    // status slot + editor + footer.
51    let root = VStack::from_children(vec![
52        StackChild::Entry(
53            StackEntry::new(scroll.clone())
54                .basis(0)
55                .grow(1)
56                .shrink(1)
57                .min_size(1),
58        ),
59        StackChild::Entry(StackEntry::new(chrome.status.clone())),
60        StackChild::Entry(StackEntry::new(editor.clone()).shrink(0).min_size(3)),
61        StackChild::Entry(StackEntry::new(chrome.footer.clone())),
62    ]);
63    let root: Arc<dyn Component> = Arc::new(root);
64
65    let tui = Arc::new(TuiAltScreen::new(
66        Box::new(ProcessTerminal::new()),
67        false,
68        None,
69    ));
70    tui.set_layout_root(Some(root));
71    tui.set_focus(Some(editor.clone()));
72    editor.set_focused(true);
73    tui.start_render_scheduler();
74    tui.start_readerless();
75
76    // ---- Input plumbing ----
77    let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
78    let (submit_tx, mut submit_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
79    editor.on_submit(Arc::new(move |text: &str| {
80        let _ = submit_tx.send(text.to_string());
81    }));
82
83    let input_tui = tui.clone();
84    let _reader = tokio::task::spawn_blocking(move || loop {
85        match crossterm::event::poll(Duration::from_millis(50)) {
86            Ok(true) => {}
87            Ok(false) => continue,
88            Err(_) => break,
89        }
90        match crossterm::event::read() {
91            Ok(event) => {
92                let _ = input_tui.refresh_size();
93                if input_tx.send(event).is_err() {
94                    break;
95                }
96            }
97            Err(_) => break,
98        }
99    });
100
101    // Initial paint (the transcript starts empty; chrome shows connection state).
102    let mut status = DriverStatus::default();
103    chrome.refresh(addr, &status);
104    tui.request_render(true);
105
106    let outcome = run_loop(
107        driver,
108        &mut status,
109        &mut events,
110        &mut input_rx,
111        &mut submit_rx,
112        &tui,
113        &editor,
114        &chrome,
115        &view,
116        addr,
117    )
118    .await;
119
120    tui.stop(Default::default());
121    outcome
122}
123
124#[allow(clippy::too_many_arguments)]
125async fn run_loop<D: SessionDriver>(
126    driver: &D,
127    status: &mut DriverStatus,
128    events: &mut tokio::sync::mpsc::UnboundedReceiver<DriverEvent>,
129    input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Event>,
130    submit_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
131    tui: &Arc<TuiAltScreen>,
132    editor: &Arc<Editor>,
133    chrome: &ChatChrome,
134    view: &TranscriptView,
135    addr: &str,
136) -> Result<(), String> {
137    // Repaint cadence while a run is in flight so the in-editor spinner and the
138    // retry countdown animate between streamed tokens (mirrors the local render
139    // tick). Idle screens never request a frame.
140    let mut ticker = tokio::time::interval(Duration::from_millis(120));
141    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
142    loop {
143        tokio::select! {
144            maybe_event = events.recv() => {
145                let Some(event) = maybe_event else { break };
146                match event {
147                    DriverEvent::Ui(ui) => view.apply(&ui, tui.width()),
148                    DriverEvent::Status(next) => {
149                        *status = next;
150                        sync_working(editor, status.streaming);
151                        chrome.refresh(addr, status);
152                    }
153                    DriverEvent::Notice(message) => view.add_notice(&message),
154                    DriverEvent::Ended => break,
155                }
156                tui.request_render(false);
157            }
158            maybe_input = input_rx.recv() => {
159                let Some(event) = maybe_input else { break };
160                match event {
161                    Event::Resize(_, _) => tui.refresh_size(),
162                    Event::Mouse(mouse) => {
163                        match mouse.kind {
164                            MouseEventKind::ScrollUp => tui.scroll_by(-3),
165                            MouseEventKind::ScrollDown => tui.scroll_by(3),
166                            _ => {}
167                        }
168                        tui.request_render(false);
169                    }
170                    Event::Key(key) if key.kind != KeyEventKind::Release => {
171                        crate::key_trace::key(&key, "driver-tui");
172                        if is_ctrl(&key, 'c') {
173                            if status.streaming {
174                                view.add_notice("aborting…");
175                                let _ = driver.send(DriverCommand::Abort).await;
176                            } else {
177                                break;
178                            }
179                        } else {
180                            editor.handle_key(key);
181                        }
182                        tui.request_render(false);
183                    }
184                    other => {
185                        crate::key_trace::note(&format!("driver tui event {other:?}"));
186                    }
187                }
188            }
189            maybe_text = submit_rx.recv() => {
190                let Some(text) = maybe_text else { break };
191                match handle_submit(driver, status, view, &text).await {
192                    Ok(SubmitAction::Continue) => {}
193                    Ok(SubmitAction::Quit) => break,
194                    Err(error) => view.add_notice(&format!("error: {error}")),
195                }
196                editor.clear();
197                tui.request_render(false);
198            }
199            _ = ticker.tick() => {
200                let retrying = chrome.tick_retry();
201                if status.streaming || retrying {
202                    tui.request_render(false);
203                }
204            }
205            else => break,
206        }
207    }
208    Ok(())
209}
210
211enum SubmitAction {
212    Continue,
213    Quit,
214}
215
216async fn handle_submit<D: SessionDriver>(
217    driver: &D,
218    status: &DriverStatus,
219    view: &TranscriptView,
220    text: &str,
221) -> Result<SubmitAction, String> {
222    let trimmed = text.trim();
223    if trimmed.is_empty() {
224        return Ok(SubmitAction::Continue);
225    }
226
227    if let Some(rest) = trimmed.strip_prefix('/') {
228        let (name, arg) = match rest.split_once(char::is_whitespace) {
229            Some((name, arg)) => (name, arg.trim()),
230            None => (rest, ""),
231        };
232        match name {
233            "exit" | "quit" | "q" => return Ok(SubmitAction::Quit),
234            "abort" => driver.send(DriverCommand::Abort).await?,
235            "state" => driver.send(DriverCommand::RefreshState).await?,
236            "model" if !arg.is_empty() => {
237                driver
238                    .send(DriverCommand::SetModel(arg.to_string()))
239                    .await?
240            }
241            "thinking" if !arg.is_empty() => {
242                driver
243                    .send(DriverCommand::SetThinkingLevel(arg.to_string()))
244                    .await?
245            }
246            "tools" => {
247                let tools = if status.active_tools.is_empty() {
248                    "(default)".to_string()
249                } else {
250                    status.active_tools.join(", ")
251                };
252                view.add_notice(&format!("active tools: {tools}"));
253            }
254            "help" => view
255                .add_notice("commands: /abort /state /model <id> /thinking <level> /tools /exit"),
256            "" => view.add_notice("empty command (try /help)"),
257            other => view.add_notice(&format!(
258                "unsupported command: /{other} (session-tree and reload commands are not available in remote mode; try /help)"
259            )),
260        }
261        return Ok(SubmitAction::Continue);
262    }
263
264    view.add_user(trimmed);
265    driver
266        .send(DriverCommand::Prompt(trimmed.to_string()))
267        .await?;
268    Ok(SubmitAction::Continue)
269}
270
271fn is_ctrl(key: &KeyEvent, ch: char) -> bool {
272    key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char(ch)
273}
274
275/// The non-transcript chrome (top banner + status slot + footer), built from
276/// the same `pi-tui` components the local TUI uses ([`StatusIndicator`],
277/// [`FooterComponent`]) so the two screens read the same.
278struct ChatChrome {
279    header: Arc<Text>,
280    status: Arc<Container>,
281    footer: Arc<FooterComponent>,
282    /// The live retry indicator, kept across refreshes so its countdown ticks.
283    retry: Mutex<Option<(u32, u32, Arc<StatusIndicator>)>>,
284}
285
286impl ChatChrome {
287    fn new(addr: &str) -> Self {
288        let footer = Arc::new(FooterComponent::new());
289        footer.set_hints(
290            "Enter: Send | Shift+Enter: New line | Ctrl+C: Clear/Exit | Esc: Abort | /help",
291        );
292        Self {
293            header: Arc::new(Text::new(format!("rpi · {addr} · connecting…"), 1, 0)),
294            status: Arc::new(Container::new()),
295            footer,
296            retry: Mutex::new(None),
297        }
298    }
299
300    /// Refresh header + footer + retry indicator from the driver status.
301    fn refresh(&self, addr: &str, status: &DriverStatus) {
302        let model = status.model.as_deref().unwrap_or("");
303        let thinking = status.thinking.as_deref().unwrap_or("off");
304        let connection = if status.ready { "ready" } else { "connecting" };
305        self.header.set_text(tui_bold(&format!(
306            "rpi · {addr} · {connection} · model {} · thinking {thinking}",
307            if model.is_empty() { "(default)" } else { model }
308        )));
309        self.footer.set_model(&short_model_name(model));
310        self.footer.set_thinking_level(Some(thinking));
311        self.footer.set_status("");
312
313        let mut slot = self.retry.lock().unwrap();
314        match status.retry {
315            Some((attempt, max, delay_ms)) => {
316                let unchanged =
317                    matches!(slot.as_ref(), Some((a, m, _)) if *a == attempt && *m == max);
318                if !unchanged {
319                    let indicator = Arc::new(StatusIndicator::retry(
320                        attempt,
321                        max,
322                        Duration::from_millis(delay_ms),
323                    ));
324                    self.status.clear();
325                    self.status.add_child(indicator.clone());
326                    *slot = Some((attempt, max, indicator));
327                }
328            }
329            None => {
330                if slot.take().is_some() {
331                    self.status.clear();
332                }
333            }
334        }
335    }
336
337    /// Tick the retry countdown; returns whether a retry is currently shown.
338    fn tick_retry(&self) -> bool {
339        match self.retry.lock().unwrap().as_ref() {
340            Some((_, _, indicator)) => {
341                indicator.tick_countdown();
342                true
343            }
344            None => false,
345        }
346    }
347}
348
349/// Keep the in-editor working indicator in step with the run state — the local
350/// TUI shows the spinner in the editor's top border, not the status slot.
351fn sync_working(editor: &Arc<Editor>, streaming: bool) {
352    if streaming {
353        if editor.working().is_none() {
354            editor.set_working(Some(WorkingState {
355                frame: 0,
356                started_at: Instant::now(),
357                message: "Working…".to_string(),
358            }));
359        }
360    } else if editor.working().is_some() {
361        editor.set_working(None);
362    }
363}
364
365/// The trailing path segment of a model id (`provider/model` → `model`).
366fn short_model_name(id: &str) -> String {
367    id.rsplit([':', '/'])
368        .next()
369        .filter(|segment| !segment.is_empty())
370        .unwrap_or(id)
371        .to_string()
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn ctrl_detection_is_specific() {
380        assert!(is_ctrl(
381            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
382            'c'
383        ));
384        assert!(!is_ctrl(
385            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
386            'c'
387        ));
388    }
389
390    #[test]
391    fn short_model_name_keeps_the_trailing_segment() {
392        assert_eq!(short_model_name("openai/gpt-5.2"), "gpt-5.2");
393        assert_eq!(short_model_name("anthropic:claude-4"), "claude-4");
394        assert_eq!(short_model_name("bare"), "bare");
395        assert_eq!(short_model_name(""), "");
396    }
397}