rpi-cli 0.6.0

Terminal coding-agent CLI (the `rpi` binary) built on the rpi-* library crates
Documentation
use super::*;
use rpi_ai::providers::faux::{FauxProvider, FauxScript};
use rpi_harness::types::AgentHarnessOptions;
use rpi_tui::terminal::{InputEvent, Terminal, TerminalInfo};
use std::sync::atomic::AtomicUsize;
use std::time::Duration;

struct TestTerminal(Arc<AtomicUsize>);
impl Terminal for TestTerminal {
    fn info(&self) -> TerminalInfo {
        TerminalInfo {
            columns: self.0.load(Ordering::Relaxed),
            rows: 24,
            ..Default::default()
        }
    }
    fn write(&self, _: &str) {}
    fn hide_cursor(&self) {}
    fn show_cursor(&self) {}
    fn move_cursor(&self, _: usize, _: usize) {}
    fn clear_screen(&self) {}
    fn set_title(&self, _: &str) {}
    fn enable_mouse(&self) {}
    fn disable_mouse(&self) {}
    fn enter_raw_mode(&self) {}
    fn exit_raw_mode(&self) {}
    fn refresh_size(&self) {}
    fn start(&self, _: Box<dyn Fn(InputEvent) + Send + Sync>, _: Box<dyn Fn() + Send + Sync>) {}
    fn stop(&self) {}
    fn is_tty(&self) -> bool {
        false
    }
    fn set_progress(&self, _: bool) {}
    fn flush(&self) {}
}
struct Fixture {
    ui: SideQuestionUi,
    tui: Arc<TuiAltScreen>,
    host: Arc<dyn RuntimeActionHost>,
    parent: AgentHarness,
    width: Arc<AtomicUsize>,
    keys: Arc<rpi_tui::keybindings::Keybindings>,
    main_scroll: Arc<ScrollView>,
}
impl Fixture {
    async fn new(slow: bool) -> Self {
        let script = if slow {
            FauxScript::new()
                .with_text("slow answer")
                .with_tokens_per_second(0.1)
        } else {
            FauxScript::new().with_text("answer")
        };
        let provider = FauxProvider::new(script);
        let parent = AgentHarness::create(AgentHarnessOptions {
            model: provider.default_model().clone(),
            models: vec![provider],
            ..Default::default()
        })
        .await
        .unwrap();
        let (host, cell) = crate::extensions_actions::HarnessActionHost::new_empty(
            vec![],
            std::env::temp_dir(),
            tokio::runtime::Handle::current(),
            Default::default(),
            rpi_extensions::ToolCallContext::new("."),
        );
        cell.set(parent.downgrade()).ok().unwrap();
        let host: Arc<dyn RuntimeActionHost> = Arc::new(host);
        host.side_question(
            7,
            json!({"op":"show"}),
            Arc::new(|_| Ok(json!({"ok":true}))),
        )
        .await
        .unwrap();
        let width = Arc::new(AtomicUsize::new(120));
        let tui = Arc::new(TuiAltScreen::new(
            Box::new(TestTerminal(width.clone())),
            false,
            None,
        ));
        let keys = Arc::new(rpi_tui::keybindings::Keybindings::new());
        let editor = Arc::new(Editor::simple());
        editor.set_text("main draft must survive");
        let main_scroll = Arc::new(ScrollView::new(
            Arc::new(Text::new("MAIN\n".repeat(100), 0, 0)),
            ScrollViewOptions::default(),
        ));
        let main = Arc::new(VStack::from_entries(vec![
            StackEntry::new(main_scroll.clone()).basis(0).grow(1),
            StackEntry::new(editor.clone()).shrink(0),
        ]));
        let ui = SideQuestionUi::new(main, editor, false);
        let mut fixture = Self {
            ui,
            tui,
            host,
            parent,
            width,
            keys,
            main_scroll,
        };
        fixture.sync(true);
        fixture
    }
    fn sync(&mut self, allowed: bool) {
        self.ui.sync_host(
            &self.tui,
            Some(self.host.clone()),
            tokio::runtime::Handle::current(),
            self.parent.session().storage().metadata().id.clone(),
            self.keys.clone(),
            allowed,
        );
    }
    fn frame(&self) -> String {
        let frame = rpi_tui::layout::render_layout_frame(
            self.ui.root(),
            self.width.load(Ordering::Relaxed),
            24,
        );
        for line in &frame.lines {
            assert!(rpi_tui::ansi::visible_width(line) <= self.width.load(Ordering::Relaxed));
        }
        strip_ansi(&frame.lines.join("\n"))
    }
}
fn snapshot(text: &str) -> Value {
    json!({"status":"running","turns":[{"requestId":"test","question":"问题 **question**","text":text,"status":"running"}],"queued":[]})
}
#[tokio::test]
async fn native_markdown_full_answer_is_scrollable_without_jumping_on_stream_updates() {
    let fixture = Fixture::new(false).await;
    let view = fixture.ui.view().unwrap();
    let text = format!(
        "# START\n\n**bold text**\n\n```rust\nlet 中文 = 42;\n```\n\n{}\nLAST-LINE",
        "中文内容 long line to wrap naturally\n".repeat(120)
    );
    view.update(&snapshot(&text));
    fixture.frame();
    assert!(view.scroll.content_height() > 120);
    view.scroll.scroll_to_start();
    let top = fixture.frame();
    assert!(top.contains("START"));
    assert!(top.contains("bold text"));
    assert!(!top.contains("**bold text**"));
    view.key(KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL));
    assert!(fixture.frame().contains("LAST-LINE"));
    view.scroll.scroll_by(-12);
    fixture.frame();
    let position = view.scroll.scroll_top();
    assert!(!view.scroll.is_following_end());
    view.update(&snapshot(&format!(
        "{text}\nMORE-OUTPUT\n{}",
        "more\n".repeat(40)
    )));
    fixture.frame();
    assert_eq!(view.scroll.scroll_top(), position);
    assert!(!view.scroll.is_following_end());
}
#[tokio::test]
async fn resize_focus_and_paste_preserve_independent_drafts_and_restore_main() {
    use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
    let mut f = Fixture::new(false).await;
    assert!(f.ui.focused());
    assert_eq!(f.ui.root().get_children().len(), 3);
    f.ui.paste("side draft", &f.tui);
    let view = f.ui.view().unwrap();
    assert_eq!(view.editor.get_expanded_text(), "side draft");
    assert_eq!(
        f.ui.main_editor.get_expanded_text(),
        "main draft must survive"
    );
    f.width.store(80, Ordering::Relaxed);
    f.sync(true);
    assert_eq!(f.ui.root().get_children().len(), 1);
    assert!(!f.frame().contains("main draft must survive"));
    f.width.store(120, Ordering::Relaxed);
    f.sync(true);
    assert_eq!(f.ui.root().get_children().len(), 3);
    assert!(Arc::ptr_eq(&view, &f.ui.view().unwrap()));
    let click = MouseEvent {
        kind: MouseEventKind::Down(MouseButton::Left),
        column: 2,
        row: 22,
        modifiers: KeyModifiers::NONE,
    };
    assert!(f.ui.mouse(&click, &f.tui, &f.main_scroll));
    assert!(!f.ui.focused());
    assert!(!f.ui.paste("not a side paste", &f.tui));
    assert_eq!(view.editor.get_expanded_text(), "side draft");
    f.host
        .side_question_ui(7, view.id.clone(), json!({"op":"hide"}))
        .await
        .unwrap();
    f.sync(true);
    assert_eq!(f.ui.root().get_children().len(), 1);
    assert!(f.frame().contains("main draft must survive"));
    assert!(f.ui.main_editor.is_focused());
}
#[tokio::test]
async fn modal_dialogs_suspend_side_input_without_overwriting_their_focus() {
    let mut f = Fixture::new(false).await;
    let modal: Arc<dyn Component> = Arc::new(Text::new("permission dialog", 0, 0));
    f.tui.set_focus(Some(modal.clone()));
    f.sync(false);
    assert!(!f.ui.focused());
    assert_eq!(f.ui.root().get_children().len(), 1);
    assert!(Arc::ptr_eq(&modal, &f.tui.get_focus().unwrap()));
    assert!(!f.ui.key(
        KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
        &f.tui
    ));
    f.sync(true);
    assert!(f.ui.focused());
    assert_eq!(f.ui.root().get_children().len(), 3);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn follow_up_editor_submits_and_queue_rejection_preserves_draft() {
    let f = Fixture::new(true).await;
    let view = f.ui.view().unwrap();
    view.editor.set_text("first question");
    view.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    tokio::time::timeout(Duration::from_secs(3), async {
        while view.submitting.load(Ordering::Acquire) {
            tokio::time::sleep(Duration::from_millis(5)).await;
        }
    })
    .await
    .unwrap();
    assert_eq!(view.editor.get_expanded_text(), "");
    tokio::time::timeout(Duration::from_secs(3), async {
        while f.host.side_question_views()[0]["status"] != "running" {
            tokio::time::sleep(Duration::from_millis(5)).await;
        }
    })
    .await
    .unwrap();
    for n in 0..8 {
        f.host
            .side_question_ui(
                7,
                view.id.clone(),
                json!({"op":"start","question":format!("queued {n}")}),
            )
            .await
            .unwrap();
    }
    view.editor.set_text("do not lose this rejected draft");
    view.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
    tokio::time::timeout(Duration::from_secs(3), async {
        while view.submitting.load(Ordering::Acquire) {
            tokio::time::sleep(Duration::from_millis(5)).await;
        }
    })
    .await
    .unwrap();
    assert_eq!(
        view.editor.get_expanded_text(),
        "do not lose this rejected draft"
    );
    assert!(strip_ansi(&view.notice.render(80).join("\n")).contains("queue is full"));
    f.host
        .side_question_ui(7, view.id.clone(), json!({"op":"cancel"}))
        .await
        .unwrap();
    assert!(f.parent.get_tip_id().await.unwrap().is_none());
}
#[tokio::test]
async fn mouse_wheel_targets_pointer_pane_without_stealing_keyboard_focus() {
    use crossterm::event::{MouseEvent, MouseEventKind};
    let f = Fixture::new(false).await;
    let mut ui = f.ui;
    let view = ui.view().unwrap();
    view.update(&snapshot(&"content\n".repeat(100)));
    rpi_tui::layout::render_layout_frame(ui.root(), 120, 24);
    let main_position = f.main_scroll.scroll_top();
    let side_position = view.scroll.scroll_top();
    let event = MouseEvent {
        kind: MouseEventKind::ScrollUp,
        column: 100,
        row: 3,
        modifiers: KeyModifiers::NONE,
    };
    assert!(ui.mouse(&event, &f.tui, &f.main_scroll));
    assert!(view.scroll.scroll_top() < side_position);
    assert_eq!(f.main_scroll.scroll_top(), main_position);
    assert!(ui.focused());
    let event = MouseEvent { column: 2, ..event };
    ui.mouse(&event, &f.tui, &f.main_scroll);
    assert!(f.main_scroll.scroll_top() < main_position);
    assert!(ui.focused());
}

#[tokio::test]
async fn regular_mode_uses_a_height_bounded_full_screen_and_restores_main_draft() {
    let mut f = Fixture::new(false).await;
    let main = f.ui.main.clone();
    let editor = f.ui.main_editor.clone();
    f.ui = SideQuestionUi::new(main, editor, true);
    f.sync(true);
    let view = f.ui.view().unwrap();
    view.update(&snapshot(&format!(
        "{}\nLAST-REGULAR-LINE",
        "regular content\n".repeat(120)
    )));
    let rows = f.ui.root().render(120);
    assert!(rows.len() <= 24);
    assert!(strip_ansi(&rows.join("\n")).contains("LAST-REGULAR-LINE"));
    assert!(!strip_ansi(&rows.join("\n")).contains("main draft must survive"));
    view.scroll.scroll_to_start();
    assert!(!strip_ansi(&f.ui.root().render(120).join("\n")).contains("LAST-REGULAR-LINE"));
    f.host
        .side_question_ui(7, view.id.clone(), json!({"op":"hide"}))
        .await
        .unwrap();
    f.sync(true);
    assert!(strip_ansi(&f.ui.root().render(120).join("\n")).contains("main draft must survive"));
    assert!(f.ui.empty_draft() == view.editor.get_expanded_text().is_empty());
}