1use 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
23const MAX_RENDERED_ITEMS: usize = 400;
25
26pub 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 if let Err(error) = client.authenticate(token.unwrap_or("")).await {
42 return Err(auth_hint(error));
43 }
44
45 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 client
69 .send_command(&session_id, RemoteCommand::GetState.with_id(next_id))
70 .await?;
71 next_id += 1;
72
73 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 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 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
343fn 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
353fn 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}