Skip to main content

rpi_cli/remote/
tui.rs

1//! Terminal UI for `rpi --connect`: renders a remote session's transcript and
2//! forwards input as protocol commands.
3//!
4//! The client is intentionally thin — it holds no provider, tools, extensions,
5//! or session files. It connects, starts a remote session, subscribes to its
6//! event stream, and renders. All agent work happens on the server.
7
8use std::sync::Arc;
9use std::time::Duration;
10
11use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind};
12use rpi_tui::{
13    bold as tui_bold, Component, Container, Editor, Focusable, Markdown, ProcessTerminal,
14    ScrollView, StackChild, StackEntry, Text, TuiAltScreen, VStack, TUI,
15};
16use serde_json::json;
17
18use crate::app::EXIT_RUNTIME;
19use crate::remote::client::RemoteClient;
20use crate::remote::protocol::RemoteCommand;
21use crate::remote::session::{RemoteSession, Transcript, TranscriptItem};
22
23/// Maximum transcript items rendered (older items are dropped from the view).
24const MAX_RENDERED_ITEMS: usize = 400;
25
26/// Connect to a server and run the interactive client until the user exits.
27pub async fn run(addr: &str, token: Option<&str>) -> i32 {
28    match connect_and_run(addr, token).await {
29        Ok(()) => 0,
30        Err(error) => {
31            eprintln!("error: {error}");
32            EXIT_RUNTIME
33        }
34    }
35}
36
37async fn connect_and_run(addr: &str, token: Option<&str>) -> Result<(), String> {
38    let mut client = RemoteClient::connect(addr).await?;
39
40    // Authenticate before any other request when a token is configured.
41    if let Err(error) = client.authenticate(token.unwrap_or("")).await {
42        return Err(auth_hint(error));
43    }
44
45    // Handshake: create a session and subscribe to its stream.
46    let started = client
47        .call("start_session", json!({}))
48        .await
49        .map_err(auth_hint)?;
50    let session_id = started
51        .get("sessionId")
52        .and_then(|v| v.as_str())
53        .ok_or("server did not return a sessionId")?
54        .to_string();
55
56    let subscribed = client
57        .call("subscribe", json!({"sessionId": session_id}))
58        .await?;
59    let subscription_id = subscribed
60        .get("subscriptionId")
61        .and_then(|v| v.as_u64())
62        .ok_or("server did not return a subscriptionId")?;
63
64    let mut events = client.start_event_pump(subscription_id);
65    let mut next_id: u64 = 1;
66
67    // Seed the state (model / thinking / tools) for the header.
68    client
69        .send_command(
70            &session_id,
71            RemoteCommand::GetState.with_id(next_id),
72        )
73        .await?;
74    next_id += 1;
75
76    // ---- Build the UI ----
77    let mut session = RemoteSession::new();
78    let header = Arc::new(Text::new(format!("rpi remote · {addr} · connecting…"), 1, 0));
79    let document = Arc::new(Container::new());
80    document.add_child(header.clone());
81    let transcript_container = Arc::new(Container::new());
82    document.add_child(transcript_container.clone());
83    let scroll = Arc::new(ScrollView::primary(document.clone()));
84
85    let editor = Arc::new(Editor::simple());
86    let footer = Arc::new(Text::new("", 1, 0));
87    let status = Arc::new(Text::new("", 1, 0));
88
89    let root = VStack::from_children(vec![
90        StackChild::Entry(
91            StackEntry::new(scroll.clone())
92                .basis(0)
93                .grow(1)
94                .shrink(1)
95                .min_size(1),
96        ),
97        StackChild::Entry(
98            StackEntry::new(status.clone())
99                .shrink(0)
100                .min_size(1),
101        ),
102        StackChild::Entry(
103            StackEntry::new(editor.clone())
104                .shrink(0)
105                .min_size(1),
106        ),
107        StackChild::Entry(
108            StackEntry::new(footer.clone())
109                .shrink(0)
110                .min_size(1),
111        ),
112    ]);
113    let root: Arc<dyn Component> = Arc::new(root);
114
115    let tui = Arc::new(TuiAltScreen::new(
116        Box::new(ProcessTerminal::new()),
117        false,
118        None,
119    ));
120    tui.set_layout_root(Some(root));
121    tui.set_focus(Some(editor.clone()));
122    editor.set_focused(true);
123    tui.start_render_scheduler();
124    tui.start_readerless();
125
126    // ---- Input plumbing ----
127    let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
128    let (submit_tx, mut submit_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
129    editor.on_submit(Arc::new(move |text: &str| {
130        let _ = submit_tx.send(text.to_string());
131    }));
132
133    let input_tui = tui.clone();
134    let _reader = tokio::task::spawn_blocking(move || loop {
135        match crossterm::event::poll(Duration::from_millis(50)) {
136            Ok(true) => {}
137            Ok(false) => continue,
138            Err(_) => break,
139        }
140        match crossterm::event::read() {
141            Ok(event) => {
142                let _ = input_tui.refresh_size();
143                if input_tx.send(event).is_err() {
144                    break;
145                }
146            }
147            Err(_) => break,
148        }
149    });
150
151    // Initial paint.
152    rebuild_transcript(&transcript_container, &session.transcript);
153    update_chrome(&header, &status, &footer, addr, &session);
154    tui.request_render(true);
155
156    let outcome = run_loop(
157        &mut client,
158        &session_id,
159        &mut session,
160        &mut events,
161        &mut input_rx,
162        &mut submit_rx,
163        &tui,
164        &editor,
165        &scroll,
166        &header,
167        &status,
168        &footer,
169        &transcript_container,
170        addr,
171        &mut next_id,
172    )
173    .await;
174
175    client.stop_session(&session_id).await;
176    tui.stop(Default::default());
177    outcome
178}
179
180#[allow(clippy::too_many_arguments)]
181async fn run_loop(
182    client: &mut RemoteClient,
183    session_id: &str,
184    session: &mut RemoteSession,
185    events: &mut tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>,
186    input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Event>,
187    submit_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
188    tui: &Arc<TuiAltScreen>,
189    editor: &Arc<Editor>,
190    _scroll: &Arc<ScrollView>,
191    header: &Arc<Text>,
192    status: &Arc<Text>,
193    footer: &Arc<Text>,
194    transcript_container: &Arc<Container>,
195    addr: &str,
196    next_id: &mut u64,
197) -> Result<(), String> {
198    loop {
199        tokio::select! {
200            maybe_event = events.recv() => {
201                let Some(line) = maybe_event else { break };
202                session.apply_line(&line);
203                rebuild_transcript(transcript_container, &session.transcript);
204                update_chrome(header, status, footer, addr, session);
205                tui.request_render(false);
206            }
207            maybe_input = input_rx.recv() => {
208                let Some(event) = maybe_input else { break };
209                match event {
210                    Event::Resize(_, _) => tui.refresh_size(),
211                    Event::Mouse(mouse) => {
212                        match mouse.kind {
213                            MouseEventKind::ScrollUp => tui.scroll_by(-3),
214                            MouseEventKind::ScrollDown => tui.scroll_by(3),
215                            _ => {}
216                        }
217                        tui.request_render(false);
218                    }
219                    Event::Key(key) if key.kind != KeyEventKind::Release => {
220                        if is_ctrl(&key, 'c') {
221                            if session.streaming {
222                                session.push_notice("aborting…");
223                                send(client, session_id, RemoteCommand::Abort, next_id).await?;
224                                rebuild_transcript(transcript_container, &session.transcript);
225                            } else {
226                                break;
227                            }
228                        } else {
229                            editor.handle_key(key);
230                        }
231                        tui.request_render(false);
232                    }
233                    _ => {}
234                }
235            }
236            maybe_text = submit_rx.recv() => {
237                let Some(text) = maybe_text else { break };
238                match handle_submit(client, session_id, session, &text, next_id).await {
239                    Ok(SubmitAction::Continue) => {}
240                    Ok(SubmitAction::Quit) => break,
241                    Err(error) => {
242                        session.push_notice(format!("error: {error}"));
243                    }
244                }
245                editor.clear();
246                rebuild_transcript(transcript_container, &session.transcript);
247                update_chrome(header, status, footer, addr, session);
248                tui.request_render(false);
249            }
250            else => break,
251        }
252    }
253    Ok(())
254}
255
256enum SubmitAction {
257    Continue,
258    Quit,
259}
260
261async fn send(
262    client: &mut RemoteClient,
263    session_id: &str,
264    command: RemoteCommand,
265    next_id: &mut u64,
266) -> Result<(), String> {
267    let id = *next_id;
268    *next_id += 1;
269    client.send_command(session_id, command.with_id(id)).await
270}
271
272async fn handle_submit(
273    client: &mut RemoteClient,
274    session_id: &str,
275    session: &mut RemoteSession,
276    text: &str,
277    next_id: &mut u64,
278) -> Result<SubmitAction, String> {
279    let trimmed = text.trim();
280    if trimmed.is_empty() {
281        return Ok(SubmitAction::Continue);
282    }
283
284    if let Some(rest) = trimmed.strip_prefix('/') {
285        let (name, arg) = match rest.split_once(char::is_whitespace) {
286            Some((name, arg)) => (name, arg.trim()),
287            None => (rest, ""),
288        };
289        match name {
290            "exit" | "quit" | "q" => return Ok(SubmitAction::Quit),
291            "abort" => send(client, session_id, RemoteCommand::Abort, next_id).await?,
292            "state" => send(client, session_id, RemoteCommand::GetState, next_id).await?,
293            "model" if !arg.is_empty() => {
294                send(
295                    client,
296                    session_id,
297                    RemoteCommand::SetModel {
298                        model: arg.to_string(),
299                    },
300                    next_id,
301                )
302                .await?
303            }
304            "thinking" if !arg.is_empty() => {
305                send(
306                    client,
307                    session_id,
308                    RemoteCommand::SetThinkingLevel {
309                        level: arg.to_string(),
310                    },
311                    next_id,
312                )
313                .await?
314            }
315            "tools" => {
316                let tools = if session.state.active_tools.is_empty() {
317                    "(default)".to_string()
318                } else {
319                    session.state.active_tools.join(", ")
320                };
321                session.push_notice(format!("active tools: {tools}"));
322            }
323            "help" => session.push_notice(
324                "commands: /abort /state /model <id> /thinking <level> /tools /exit",
325            ),
326            "" => session.push_notice("empty command (try /help)"),
327            other => session.push_notice(format!(
328                "unsupported command: /{other} (session-tree and reload commands are not available in remote mode; try /help)"
329            )),
330        }
331        return Ok(SubmitAction::Continue);
332    }
333
334    session.on_user_prompt(trimmed);
335    send(
336        client,
337        session_id,
338        RemoteCommand::Prompt {
339            content: trimmed.to_string(),
340        },
341        next_id,
342    )
343    .await?;
344    Ok(SubmitAction::Continue)
345}
346
347fn is_ctrl(key: &KeyEvent, ch: char) -> bool {
348    key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char(ch)
349}
350
351/// Append a `--token` hint when the server rejected us for authentication.
352fn auth_hint(error: String) -> String {
353    let lower = error.to_lowercase();
354    if lower.contains("authentication") || lower.contains("token") {
355        format!("{error} (retrieve the token printed by `rpi --server` and pass `--token <token>`)")
356    } else {
357        error
358    }
359}
360
361/// Rebuild the transcript view. Cheap enough for a first cut: the transcript is
362/// bounded and re-rendering on each event keeps the model trivial.
363fn rebuild_transcript(container: &Arc<Container>, transcript: &Transcript) {
364    container.clear();
365    let start = transcript.items.len().saturating_sub(MAX_RENDERED_ITEMS);
366    for item in &transcript.items[start..] {
367        container.add_child(render_item(item));
368    }
369}
370
371fn render_item(item: &TranscriptItem) -> Arc<dyn Component> {
372    match item {
373        TranscriptItem::User(text) => {
374            Arc::new(Text::new(format!("› {text}"), 1, 0)) as Arc<dyn Component>
375        }
376        TranscriptItem::Assistant(text) => {
377            Arc::new(Markdown::new(text.clone(), 1, 0)) as Arc<dyn Component>
378        }
379        TranscriptItem::Thinking(text) => {
380            Arc::new(Text::new(format!("[thinking] {text}"), 1, 0)) as Arc<dyn Component>
381        }
382        TranscriptItem::Tool(tool) => {
383            let status = if tool.done {
384                if tool.is_error {
385                    "error"
386                } else {
387                    "ok"
388                }
389            } else {
390                "running"
391            };
392            let body = tool
393                .result
394                .as_ref()
395                .or(tool.partial.as_ref())
396                .map(String::as_str)
397                .unwrap_or("");
398            let text = if body.is_empty() {
399                format!("[tool {} · {}]", tool.name, status)
400            } else {
401                format!("[tool {} · {}]\n{body}", tool.name, status)
402            };
403            Arc::new(Text::new(text, 1, 0)) as Arc<dyn Component>
404        }
405        TranscriptItem::Notice(text) => {
406            Arc::new(Text::new(format!("· {text}"), 1, 0)) as Arc<dyn Component>
407        }
408    }
409}
410
411fn update_chrome(
412    header: &Arc<Text>,
413    status: &Arc<Text>,
414    footer: &Arc<Text>,
415    addr: &str,
416    session: &RemoteSession,
417) {
418    let model = session.state.model.as_deref().unwrap_or("(default)");
419    let thinking = session
420        .state
421        .thinking_level
422        .as_ref()
423        .and_then(|v| v.as_str())
424        .unwrap_or("off");
425    let connection = if session.ready { "ready" } else { "connecting" };
426    header.set_text(format!(
427        "{}",
428        tui_bold(&format!(
429            "rpi remote · {addr} · {connection} · model {model} · thinking {thinking}"
430        ))
431    ));
432
433    let status_text = if let Some((attempt, max)) = session.retry_attempt {
434        format!("retrying {attempt}/{max}…")
435    } else if session.streaming {
436        "working…".to_string()
437    } else {
438        String::new()
439    };
440    status.set_text(status_text);
441
442    footer.set_text(
443        "Enter: send · Ctrl+C: abort/exit · Esc: /exit · /help · (session tree & reload unavailable remotely)",
444    );
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn ctrl_detection_is_specific() {
453        assert!(is_ctrl(
454            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
455            'c'
456        ));
457        assert!(!is_ctrl(
458            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
459            'c'
460        ));
461    }
462}