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 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
351fn 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
361fn 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}