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
28pub 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 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 let view = TranscriptView::new(transcript_container.clone());
46
47 let editor = Arc::new(Editor::simple());
48
49 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 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 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 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
275struct ChatChrome {
279 header: Arc<Text>,
280 status: Arc<Container>,
281 footer: Arc<FooterComponent>,
282 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 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 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
349fn 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
365fn 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}