Skip to main content

rpi_cli/
extras.rs

1//! Extras & easter-egg wiring — thin host wrappers over the rpi-tui components.
2//!
3//! Hosts the chat-container push helpers for the armin XBM art and the earendil
4//! announcement, plus the first-time-setup sentinel logic. These are the
5//! `interactive_tui.rs`-side glue (`/armin`, `/earendil`, and the first-launch
6//! gate) that depend on `rpi_tui::Container` + `Arc` — kept out of the library
7//! crate so rpi-tui stays cli-free (project constraint).
8
9use std::sync::Arc;
10
11use rpi_tui::{Container, EarendilAnnouncementComponent, ArminComponent, Spacer};
12
13use crate::config;
14
15/// Push the armin XBM art block (+ a trailing spacer) into the chat transcript.
16/// Triggered by `/armin`.
17pub fn add_armin(chat: &Arc<Container>) {
18    chat.add_child(Arc::new(ArminComponent::new()));
19    chat.add_child(Arc::new(Spacer::new(1)));
20}
21
22/// Push the earendil announcement block (+ a trailing spacer) into the chat
23/// transcript, and mark it seen via the `~/.rpi/agent/.earendil_seen` sentinel.
24/// Triggered by `/earendil` or the first-launch gate.
25pub fn add_earendil(chat: &Arc<Container>) {
26    chat.add_child(Arc::new(EarendilAnnouncementComponent::new()));
27    chat.add_child(Arc::new(Spacer::new(1)));
28    let _ = mark_earendil_seen();
29}
30
31/// Path of the "earendil announcement seen" sentinel, under the agent dir
32/// (`~/.rpi/agent/.earendil_seen`). Delegates to [`config::agent_dir`] so an
33/// `RPI_CODING_AGENT_DIR` override is honored (the old `rpi_dir()` ignored it).
34/// Returns `None` when the home dir can't be resolved.
35pub fn earendil_seen_path() -> Option<std::path::PathBuf> {
36    config::agent_dir()
37        .ok()
38        .map(|d| d.join(".earendil_seen"))
39}
40
41/// Whether the earendil announcement has already been shown (sentinel present).
42pub fn earendil_seen() -> bool {
43    earendil_seen_path()
44        .map(|p| p.exists())
45        .unwrap_or(false)
46}
47
48/// Write the `~/.rpi/agent/.earendil_seen` sentinel so the announcement isn't
49/// shown again on later launches. Best-effort: a missing agent dir is created.
50fn mark_earendil_seen() -> std::io::Result<()> {
51    let path = earendil_seen_path().ok_or_else(|| {
52        std::io::Error::new(std::io::ErrorKind::NotFound, "no home dir for .rpi/agent")
53    })?;
54    if let Some(parent) = path.parent() {
55        std::fs::create_dir_all(parent)?;
56    }
57    std::fs::write(&path, b"1")
58}
59
60/// Path of the "first-time setup done" sentinel, under the agent dir
61/// (`~/.rpi/agent/.setup_done`).
62pub fn setup_done_path() -> Option<std::path::PathBuf> {
63    config::agent_dir()
64        .ok()
65        .map(|d| d.join(".setup_done"))
66}
67
68/// Whether first-time setup has already been completed (sentinel present).
69pub fn setup_done() -> bool {
70    setup_done_path()
71        .map(|p| p.exists())
72        .unwrap_or(false)
73}
74
75/// Mark first-time setup complete (write the sentinel). Best-effort.
76pub fn mark_setup_done() -> std::io::Result<()> {
77    let path = setup_done_path().ok_or_else(|| {
78        std::io::Error::new(std::io::ErrorKind::NotFound, "no home dir for .rpi/agent")
79    })?;
80    if let Some(parent) = path.parent() {
81        std::fs::create_dir_all(parent)?;
82    }
83    std::fs::write(&path, b"1")
84}
85
86/// If first-time setup hasn't run yet, show a brief setup note + the earendil
87/// announcement in the chat container. The TS original is a multi-step dialog
88/// (theme picker, analytics opt-in); this v1 simplifies to a one-shot banner
89/// + the theme remains pickable via `/theme`. Analytics is deferred (no
90/// telemetry wiring). Returns `true` if anything was shown.
91pub fn maybe_first_time_setup(chat: &Arc<Container>) -> bool {
92    use rpi_tui::{Text, DynamicBorder, Spacer};
93    use rpi_tui::Component;
94    if setup_done() {
95        return false;
96    }
97    let accent = rpi_tui::theme().colors.accent;
98    let muted = rpi_tui::theme().colors.muted;
99    let border = DynamicBorder::with_color(accent);
100    // Use the Component trait method explicitly for the border/Text render.
101    let mut lines: Vec<String> = Vec::new();
102    lines.extend(border.render(80));
103    lines.push(format!(" {} Welcome to rpi!", accent.fg(&bold("Welcome to rpi!"))));
104    lines.push(format!(" {} Pick a theme with /theme (dark/light/monochrome).", muted.fg("Pick a theme with /theme (dark/light/monochrome).")));
105    lines.push(format!(" {} Type /help for commands.", muted.fg("Type /help for commands.")));
106    lines.extend(border.render(80));
107    for line in lines {
108        chat.add_child(Arc::new(Text::new(line, 1, 0)));
109    }
110    chat.add_child(Arc::new(Spacer::new(1)));
111    add_earendil(chat);
112    let _ = mark_setup_done();
113    true
114}
115
116fn bold(s: &str) -> String {
117    format!("\x1b[1m{s}\x1b[22m")
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_add_armin_pushes_component() {
126        let chat = Arc::new(Container::new());
127        let before = chat.child_count();
128        add_armin(&chat);
129        assert_eq!(chat.child_count(), before + 2); // component + spacer
130    }
131
132    #[test]
133    fn test_add_earendil_pushes_component() {
134        let chat = Arc::new(Container::new());
135        let before = chat.child_count();
136        add_earendil(&chat);
137        assert_eq!(chat.child_count(), before + 2);
138    }
139
140    #[test]
141    fn test_sentinel_paths_under_agent_dir() {
142        // The sentinels live directly under the resolved agent dir and honor
143        // the `RPI_CODING_AGENT_DIR` override (delegated to `config::agent_dir`).
144        // With the override set, agent_dir() returns the override verbatim, so
145        // the sentinel's parent must equal agent_dir() — not end in a literal
146        // "agent" segment (that only holds for the default nested path).
147        let _guard = crate::config::test_support::env_lock().lock().unwrap();
148        let prev = std::env::var_os(crate::config::CONFIG_DIR_ENV);
149        let tmp = tempfile::TempDir::new().unwrap();
150        std::env::set_var(crate::config::CONFIG_DIR_ENV, tmp.path());
151        let agent = crate::config::agent_dir().unwrap();
152        assert_eq!(agent.as_path(), tmp.path());
153        if let Some(p) = earendil_seen_path() {
154            assert!(p.ends_with(".earendil_seen"));
155            assert_eq!(p.parent().unwrap(), agent);
156        }
157        if let Some(p) = setup_done_path() {
158            assert!(p.ends_with(".setup_done"));
159            assert_eq!(p.parent().unwrap(), agent);
160        }
161        match prev {
162            Some(v) => std::env::set_var(crate::config::CONFIG_DIR_ENV, v),
163            None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
164        }
165    }
166}