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 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 if input_tx.send(event).is_err() {
110 break;
111 }
112 }
113 Err(_) => break,
114 }
115 }
116 });
117
118 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 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 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 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
336struct ChatChrome {
340 header: Arc<Text>,
341 status: Arc<Container>,
342 footer: Arc<FooterComponent>,
343 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 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 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
410fn 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
426fn 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}