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                        crate::key_trace::key(&key, "remote-client");
221                        if is_ctrl(&key, 'c') {
222                            if session.streaming {
223                                session.push_notice("aborting…");
224                                send(client, session_id, RemoteCommand::Abort, next_id).await?;
225                                rebuild_transcript(transcript_container, &session.transcript);
226                            } else {
227                                break;
228                            }
229                        } else {
230                            editor.handle_key(key);
231                        }
232                        tui.request_render(false);
233                    }
234                    other => {
235                        crate::key_trace::note(&format!("remote client event {other:?}"));
236                    }
237                }
238            }
239            maybe_text = submit_rx.recv() => {
240                let Some(text) = maybe_text else { break };
241                match handle_submit(client, session_id, session, &text, next_id).await {
242                    Ok(SubmitAction::Continue) => {}
243                    Ok(SubmitAction::Quit) => break,
244                    Err(error) => {
245                        session.push_notice(format!("error: {error}"));
246                    }
247                }
248                editor.clear();
249                rebuild_transcript(transcript_container, &session.transcript);
250                update_chrome(header, status, footer, addr, session);
251                tui.request_render(false);
252            }
253            else => break,
254        }
255    }
256    Ok(())
257}
258
259enum SubmitAction {
260    Continue,
261    Quit,
262}
263
264async fn send(
265    client: &mut RemoteClient,
266    session_id: &str,
267    command: RemoteCommand,
268    next_id: &mut u64,
269) -> Result<(), String> {
270    let id = *next_id;
271    *next_id += 1;
272    client.send_command(session_id, command.with_id(id)).await
273}
274
275async fn handle_submit(
276    client: &mut RemoteClient,
277    session_id: &str,
278    session: &mut RemoteSession,
279    text: &str,
280    next_id: &mut u64,
281) -> Result<SubmitAction, String> {
282    let trimmed = text.trim();
283    if trimmed.is_empty() {
284        return Ok(SubmitAction::Continue);
285    }
286
287    if let Some(rest) = trimmed.strip_prefix('/') {
288        let (name, arg) = match rest.split_once(char::is_whitespace) {
289            Some((name, arg)) => (name, arg.trim()),
290            None => (rest, ""),
291        };
292        match name {
293            "exit" | "quit" | "q" => return Ok(SubmitAction::Quit),
294            "abort" => send(client, session_id, RemoteCommand::Abort, next_id).await?,
295            "state" => send(client, session_id, RemoteCommand::GetState, next_id).await?,
296            "model" if !arg.is_empty() => {
297                send(
298                    client,
299                    session_id,
300                    RemoteCommand::SetModel {
301                        model: arg.to_string(),
302                    },
303                    next_id,
304                )
305                .await?
306            }
307            "thinking" if !arg.is_empty() => {
308                send(
309                    client,
310                    session_id,
311                    RemoteCommand::SetThinkingLevel {
312                        level: arg.to_string(),
313                    },
314                    next_id,
315                )
316                .await?
317            }
318            "tools" => {
319                let tools = if session.state.active_tools.is_empty() {
320                    "(default)".to_string()
321                } else {
322                    session.state.active_tools.join(", ")
323                };
324                session.push_notice(format!("active tools: {tools}"));
325            }
326            "help" => session.push_notice(
327                "commands: /abort /state /model <id> /thinking <level> /tools /exit",
328            ),
329            "" => session.push_notice("empty command (try /help)"),
330            other => session.push_notice(format!(
331                "unsupported command: /{other} (session-tree and reload commands are not available in remote mode; try /help)"
332            )),
333        }
334        return Ok(SubmitAction::Continue);
335    }
336
337    session.on_user_prompt(trimmed);
338    send(
339        client,
340        session_id,
341        RemoteCommand::Prompt {
342            content: trimmed.to_string(),
343        },
344        next_id,
345    )
346    .await?;
347    Ok(SubmitAction::Continue)
348}
349
350fn is_ctrl(key: &KeyEvent, ch: char) -> bool {
351    key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char(ch)
352}
353
354/// Append a `--token` hint when the server rejected us for authentication.
355fn auth_hint(error: String) -> String {
356    let lower = error.to_lowercase();
357    if lower.contains("authentication") || lower.contains("token") {
358        format!("{error} (retrieve the token printed by `rpi --server` and pass `--token <token>`)")
359    } else {
360        error
361    }
362}
363
364/// Rebuild the transcript view. Cheap enough for a first cut: the transcript is
365/// bounded and re-rendering on each event keeps the model trivial.
366fn rebuild_transcript(container: &Arc<Container>, transcript: &Transcript) {
367    container.clear();
368    let start = transcript.items.len().saturating_sub(MAX_RENDERED_ITEMS);
369    for item in &transcript.items[start..] {
370        container.add_child(render_item(item));
371    }
372}
373
374fn render_item(item: &TranscriptItem) -> Arc<dyn Component> {
375    match item {
376        TranscriptItem::User(text) => {
377            Arc::new(Text::new(format!("› {text}"), 1, 0)) as Arc<dyn Component>
378        }
379        TranscriptItem::Assistant(text) => {
380            Arc::new(Markdown::new(text.clone(), 1, 0)) as Arc<dyn Component>
381        }
382        TranscriptItem::Thinking(text) => {
383            Arc::new(Text::new(format!("[thinking] {text}"), 1, 0)) as Arc<dyn Component>
384        }
385        TranscriptItem::Tool(tool) => {
386            let status = if tool.done {
387                if tool.is_error {
388                    "error"
389                } else {
390                    "ok"
391                }
392            } else {
393                "running"
394            };
395            let body = tool
396                .result
397                .as_ref()
398                .or(tool.partial.as_ref())
399                .map(String::as_str)
400                .unwrap_or("");
401            let text = if body.is_empty() {
402                format!("[tool {} · {}]", tool.name, status)
403            } else {
404                format!("[tool {} · {}]\n{body}", tool.name, status)
405            };
406            Arc::new(Text::new(text, 1, 0)) as Arc<dyn Component>
407        }
408        TranscriptItem::Notice(text) => {
409            Arc::new(Text::new(format!("· {text}"), 1, 0)) as Arc<dyn Component>
410        }
411    }
412}
413
414fn update_chrome(
415    header: &Arc<Text>,
416    status: &Arc<Text>,
417    footer: &Arc<Text>,
418    addr: &str,
419    session: &RemoteSession,
420) {
421    let model = session.state.model.as_deref().unwrap_or("(default)");
422    let thinking = session
423        .state
424        .thinking_level
425        .as_ref()
426        .and_then(|v| v.as_str())
427        .unwrap_or("off");
428    let connection = if session.ready { "ready" } else { "connecting" };
429    header.set_text(format!(
430        "{}",
431        tui_bold(&format!(
432            "rpi remote · {addr} · {connection} · model {model} · thinking {thinking}"
433        ))
434    ));
435
436    let status_text = if let Some((attempt, max)) = session.retry_attempt {
437        format!("retrying {attempt}/{max}…")
438    } else if session.streaming {
439        "working…".to_string()
440    } else {
441        String::new()
442    };
443    status.set_text(status_text);
444
445    footer.set_text(
446        "Enter: send · Ctrl+C: abort/exit · Esc: /exit · /help · (session tree & reload unavailable remotely)",
447    );
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn ctrl_detection_is_specific() {
456        assert!(is_ctrl(
457            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
458            'c'
459        ));
460        assert!(!is_ctrl(
461            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
462            'c'
463        ));
464    }
465}