Skip to main content

nms_copilot/
prompt.rs

1//! Context-aware REPL prompt.
2//!
3//! Displays current galaxy, active biome filter, and model size.
4//! Format: `[Euclid | Lush | 644 planets] 🚀 `
5
6use std::borrow::Cow;
7
8use reedline::{Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus};
9
10use crate::session::SessionState;
11
12/// A snapshot of session state used to render the prompt.
13///
14/// We take a snapshot rather than holding a reference to SessionState
15/// because reedline's Prompt trait requires `&self` (not mutable),
16/// and the session state changes between prompts.
17#[derive(Debug, Clone)]
18pub struct PromptState {
19    pub galaxy_name: String,
20    pub biome_filter: Option<String>,
21    pub planet_count: usize,
22}
23
24impl PromptState {
25    /// Build a prompt state snapshot from the current session.
26    pub fn from_session(session: &SessionState) -> Self {
27        Self {
28            galaxy_name: session.galaxy.name.to_string(),
29            biome_filter: session.biome_filter.map(|b| format!("{b:?}")),
30            planet_count: session.planet_count,
31        }
32    }
33}
34
35/// Custom REPL prompt.
36pub struct CopilotPrompt {
37    state: PromptState,
38}
39
40impl CopilotPrompt {
41    pub fn new(state: PromptState) -> Self {
42        Self { state }
43    }
44
45    /// Update the prompt state (called before each read_line).
46    pub fn update(&mut self, state: PromptState) {
47        self.state = state;
48    }
49
50    fn render_left(&self) -> String {
51        let mut parts = vec![self.state.galaxy_name.clone()];
52
53        if let Some(ref biome) = self.state.biome_filter {
54            parts.push(biome.clone());
55        }
56
57        parts.push(format!("{} planets", self.state.planet_count));
58
59        format!("[{}] 🚀", parts.join(" | "))
60    }
61}
62
63impl Prompt for CopilotPrompt {
64    fn render_prompt_left(&self) -> Cow<'_, str> {
65        Cow::Owned(self.render_left())
66    }
67
68    fn render_prompt_right(&self) -> Cow<'_, str> {
69        Cow::Borrowed("")
70    }
71
72    fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow<'_, str> {
73        Cow::Borrowed(" ")
74    }
75
76    fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
77        Cow::Borrowed("... ")
78    }
79
80    fn render_prompt_history_search_indicator(
81        &self,
82        history_search: PromptHistorySearch,
83    ) -> Cow<'_, str> {
84        let prefix = match history_search.status {
85            PromptHistorySearchStatus::Passing => "",
86            PromptHistorySearchStatus::Failing => "(failed) ",
87        };
88        Cow::Owned(format!("{prefix}(search: {}) ", history_search.term))
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn test_prompt_basic() {
98        let state = PromptState {
99            galaxy_name: "Euclid".into(),
100            biome_filter: None,
101            planet_count: 644,
102        };
103        let prompt = CopilotPrompt::new(state);
104        let left = prompt.render_prompt_left();
105        assert_eq!(left.as_ref(), "[Euclid | 644 planets] 🚀");
106    }
107
108    #[test]
109    fn test_prompt_with_biome_filter() {
110        let state = PromptState {
111            galaxy_name: "Euclid".into(),
112            biome_filter: Some("Lush".into()),
113            planet_count: 42,
114        };
115        let prompt = CopilotPrompt::new(state);
116        let left = prompt.render_prompt_left();
117        assert_eq!(left.as_ref(), "[Euclid | Lush | 42 planets] 🚀");
118    }
119
120    #[test]
121    fn test_prompt_different_galaxy() {
122        let state = PromptState {
123            galaxy_name: "Hilbert Dimension".into(),
124            biome_filter: None,
125            planet_count: 100,
126        };
127        let prompt = CopilotPrompt::new(state);
128        let left = prompt.render_prompt_left();
129        assert!(left.contains("Hilbert Dimension"));
130    }
131
132    #[test]
133    fn test_prompt_indicator_is_space() {
134        let state = PromptState {
135            galaxy_name: "Euclid".into(),
136            biome_filter: None,
137            planet_count: 0,
138        };
139        let prompt = CopilotPrompt::new(state);
140        assert_eq!(
141            prompt
142                .render_prompt_indicator(PromptEditMode::Default)
143                .as_ref(),
144            " "
145        );
146    }
147
148    #[test]
149    fn test_prompt_update() {
150        let state1 = PromptState {
151            galaxy_name: "Euclid".into(),
152            biome_filter: None,
153            planet_count: 100,
154        };
155        let mut prompt = CopilotPrompt::new(state1);
156        assert!(prompt.render_prompt_left().contains("100 planets"));
157
158        let state2 = PromptState {
159            galaxy_name: "Euclid".into(),
160            biome_filter: Some("Toxic".into()),
161            planet_count: 200,
162        };
163        prompt.update(state2);
164        let left = prompt.render_prompt_left();
165        assert!(left.contains("200 planets"));
166        assert!(left.contains("Toxic"));
167    }
168
169    #[test]
170    fn test_prompt_state_from_session() {
171        let json = r#"{
172            "Version": 4720, "Platform": "Mac|Final", "ActiveContext": "Main",
173            "CommonStateData": {"SaveName": "Test", "TotalPlayTime": 100},
174            "BaseContext": {
175                "GameMode": 1,
176                "PlayerStateData": {
177                    "UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 1, "PlanetIndex": 0}},
178                    "Units": 0, "Nanites": 0, "Specials": 0,
179                    "PersistentPlayerBases": []
180                }
181            },
182            "ExpeditionContext": {"GameMode": 6, "PlayerStateData": {"UniverseAddress": {"RealityIndex": 0, "GalacticAddress": {"VoxelX": 0, "VoxelY": 0, "VoxelZ": 0, "SolarSystemIndex": 0, "PlanetIndex": 0}}, "Units": 0, "Nanites": 0, "Specials": 0, "PersistentPlayerBases": []}},
183            "DiscoveryManagerData": {"DiscoveryData-v1": {"ReserveStore": 0, "ReserveManaged": 0, "Store": {"Record": [
184                {"DD": {"UA": "0x050003AB8C07", "DT": "SolarSystem", "VP": []}, "DM": {}, "OWS": {"LID":"","UID":"1","USN":"","PTK":"ST","TS":0}, "FL": {"U": 1}}
185            ]}}}
186        }"#;
187        let save = nms_save::parse_save(json.as_bytes()).unwrap();
188        let model = nms_graph::GalaxyModel::from_save(&save);
189        let session = crate::session::SessionState::from_model(&model);
190        let ps = PromptState::from_session(&session);
191        assert_eq!(ps.galaxy_name, "Euclid");
192        assert!(ps.biome_filter.is_none());
193    }
194}