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