1use 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
31pub 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 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 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 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 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 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 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 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
328struct ChatChrome {
332 header: Arc<Text>,
333 status: Arc<Container>,
334 footer: Arc<FooterComponent>,
335 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 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 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
402fn 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
418fn 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}