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