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(
70 &session_id,
71 RemoteCommand::GetState.with_id(next_id),
72 )
73 .await?;
74 next_id += 1;
75
76 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 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 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
354fn 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
364fn 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}