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