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