Skip to main content

recursive/tui/app/
state.rs

1//! Constructors, basic accessors and transcript-mutation helpers for [`App`].
2
3use std::collections::HashSet;
4use std::sync::{atomic::AtomicBool, Arc};
5use std::time::Instant;
6
7use super::{
8    default_offline_tool_catalog, detect_model_name, App, AppScreen, DoublePressTracker, InputMode,
9    PendingPermission, PromptInputState, TranscriptBlock, TurnState, UsageStats,
10};
11
12impl App {
13    pub fn new() -> Self {
14        // Goal-169: load workspace skill commands at startup.
15        let workspace = crate::config::Config::from_env()
16            .map(|c| c.workspace)
17            .unwrap_or_else(|_| std::path::PathBuf::from("."));
18        let skills = crate::tui::skill_commands::SkillCommandLoader::load(&workspace);
19        let commands =
20            crate::tui::commands::CommandRegistry::default_set().with_skill_commands(skills);
21        Self {
22            prompt: PromptInputState::new(),
23            // Empty transcript — the bordered "Messages" panel and the
24            // "Welcome to Recursive TUI…" system block were removed;
25            // the chat starts on a clean canvas. New turns push their
26            // own User block via the message-submit path.
27            blocks: Vec::new(),
28            should_quit: false,
29            session_id: None,
30            connected: false,
31            scroll_offset: 0,
32            screen: AppScreen::Chat,
33            start_time: Instant::now(),
34            usage: UsageStats::default(),
35            turn: TurnState::default(),
36            turn_count: 0,
37            pending_latency_ms: None,
38            model_name: detect_model_name(),
39            spinner_frame: 0,
40            modals: Vec::new(),
41            commands,
42            tool_catalog: default_offline_tool_catalog(),
43            command_menu_selected: None,
44            planning_mode_on: false,
45            plan_awaiting_approval: false,
46            plan_mode_request_pending: false,
47            double_press: DoublePressTracker::default(),
48            atfile_query: String::new(),
49            atfile_suggestions: Vec::new(),
50            atfile_selected: None,
51            hsearch_query: String::new(),
52            hsearch_matches: Vec::new(),
53            hsearch_selected: 0,
54            pending_permission: None,
55            auto_allowed_tools: HashSet::new(),
56            permission_hook_enabled: Arc::new(AtomicBool::new(false)),
57            current_todos: Vec::new(),
58            active_goal: None,
59            workspace_path: workspace,
60            theme: &crate::tui::ui::theme::DARK,
61            last_printed_idx: 0,
62            print_queue: Vec::new(),
63            modal_scroll: 0,
64        }
65    }
66
67    /// Push a modal onto the stack and reset the modal scroll to the top.
68    pub fn push_modal(&mut self, modal: crate::tui::ui::modal::Modal) {
69        self.modal_scroll = 0;
70        self.modals.push(modal);
71    }
72
73    /// Backwards-compat shim for legacy code paths that still expect
74    /// a single `input` string. Reads the prompt buffer.
75    pub fn input(&self) -> &str {
76        &self.prompt.buffer
77    }
78
79    /// Replace the prompt buffer (used by PlanReview's `e`-edit path
80    /// and a handful of unit tests). Resets cursor to end and mode to
81    /// Prompt.
82    pub fn set_input<S: Into<String>>(&mut self, value: S) {
83        self.prompt.buffer = value.into();
84        self.prompt.cursor = self.prompt.buffer.len();
85        self.prompt.mode = InputMode::Prompt;
86        self.prompt.history_idx = None;
87    }
88
89    pub fn scroll_to_bottom(&mut self) {
90        self.scroll_offset = 0;
91    }
92
93    /// Push a System block onto the transcript and scroll to bottom.
94    /// Public so [`crate::commands`] handlers can use it directly.
95    pub fn push_system(&mut self, text: impl Into<String>) {
96        self.blocks
97            .push(TranscriptBlock::System { text: text.into() });
98        self.scroll_to_bottom();
99    }
100
101    /// Push an Error block onto the transcript and scroll to bottom.
102    pub fn push_error(&mut self, text: impl Into<String>) {
103        self.blocks
104            .push(TranscriptBlock::Error { text: text.into() });
105        self.scroll_to_bottom();
106    }
107
108    /// Reset the transcript to a single fresh welcome block and zero
109    /// out per-session usage. Called by `/clear`.
110    pub fn reset_transcript(&mut self) {
111        self.blocks.clear();
112        self.blocks.push(TranscriptBlock::System {
113            text: "Conversation cleared.".into(),
114        });
115        self.usage = UsageStats::default();
116        self.turn_count = 0;
117        self.pending_latency_ms = None;
118        self.scroll_to_bottom();
119    }
120
121    /// Receive a pending permission request from the backend side-channel.
122    /// Auto-allow if the tool is in the `auto_allowed_tools` set;
123    /// otherwise store it so the UI can display the modal on the next render.
124    pub fn set_pending_permission(&mut self, req: crate::tui::events::PermissionRequest) {
125        if self.auto_allowed_tools.contains(&req.tool_name) {
126            // Auto-allow: resolve immediately without showing the modal.
127            let _ = req.reply.send(true);
128            return;
129        }
130        // If a previous request is somehow still pending (shouldn't happen
131        // in practice — the backend serialises tool calls), deny it so the
132        // oneshot is consumed and the worker can unblock.
133        if let Some(old) = self.pending_permission.take() {
134            let _ = old.reply.send(false);
135        }
136        self.pending_permission = Some(PendingPermission {
137            tool_name: req.tool_name,
138            args_preview: req.args_preview,
139            reply: req.reply,
140        });
141    }
142}
143
144impl Default for App {
145    fn default() -> Self {
146        Self::new()
147    }
148}
149
150// ──────────────────────────────────────────────────────────────────────
151// Tests
152// ──────────────────────────────────────────────────────────────────────
153
154#[cfg(test)]
155mod tests {
156    use crate::tui::app::{App, AppScreen};
157    use crate::tui::cost::{detect_model_name, estimate_cost};
158
159    // ── construction ────────────────────────────────────────────────
160
161    #[test]
162    fn app_new_creates_empty_state() {
163        let app = App::new();
164        assert!(app.input().is_empty());
165        // The welcome system block was removed; a fresh App starts
166        // with an empty transcript so the chat opens on a clean canvas.
167        assert!(app.blocks.is_empty());
168        assert!(!app.should_quit);
169    }
170
171    #[test]
172    fn app_new_starts_in_chat_screen() {
173        let app = App::new();
174        assert_eq!(app.screen, AppScreen::Chat);
175    }
176
177    #[test]
178    fn app_new_starts_with_empty_transcript() {
179        // The "Welcome to Recursive TUI" block was removed so the chat
180        // opens on a clean canvas — the first system block is whatever
181        // a `/clear` resets to.
182        let app = App::new();
183        assert!(app.session_id.is_none());
184        assert!(
185            app.blocks.is_empty(),
186            "fresh App::new() should not seed a welcome block; got {:?}",
187            app.blocks
188        );
189    }
190
191    // ── pricing / model detection ──────────────────────────────────
192
193    /// Goal-150: status bar must read `~/.recursive/config.toml` when
194    /// no env var is set, otherwise it lies about the model the
195    /// runtime is actually using. Extended for the preset-config goal
196    /// to also cover `provider.preset` resolution.
197    ///
198    /// All checks share one test body so they share the `PinnedHome`
199    /// lock (HOME mutation is process-global; cf. lesson 17).
200    #[test]
201    fn detect_model_name_falls_back_to_config_file() {
202        let home = tempfile::tempdir().expect("tempdir");
203        let _pin = crate::test_util::PinnedHome::new(home.path());
204
205        // Snapshot env so we can clear / restore.
206        let prev_recursive_model = std::env::var("RECURSIVE_MODEL").ok();
207        let prev_openai_model = std::env::var("OPENAI_MODEL").ok();
208        std::env::remove_var("RECURSIVE_MODEL");
209        std::env::remove_var("OPENAI_MODEL");
210
211        // Part A: no env, no config.toml → Config::from_env hardcoded default
212        // (changed from the legacy "gpt-4o-mini" placeholder — the status
213        // bar now shows what the runtime will actually use).
214        assert_eq!(detect_model_name(), "claude-sonnet-4-6");
215
216        // Part B: write a config.toml under HOME → that wins
217        let cfg_dir = home.path().join(".recursive");
218        std::fs::create_dir_all(&cfg_dir).expect("mkdir");
219        std::fs::write(
220            cfg_dir.join("config.toml"),
221            "[provider]\nmodel = \"deepseek-v4-flash\"\n",
222        )
223        .expect("write");
224        assert_eq!(detect_model_name(), "deepseek-v4-flash");
225
226        // Part C: env var overrides config.toml
227        std::env::set_var("RECURSIVE_MODEL", "from-env");
228        assert_eq!(detect_model_name(), "from-env");
229
230        // Part D: preset resolves default_model when no explicit field
231        std::env::remove_var("RECURSIVE_MODEL");
232        std::fs::write(
233            cfg_dir.join("config.toml"),
234            "[provider]\npreset = \"deepseek\"\n",
235        )
236        .expect("rewrite");
237        assert_eq!(detect_model_name(), "deepseek-chat");
238
239        // Restore env.
240        std::env::remove_var("RECURSIVE_MODEL");
241        if let Some(v) = prev_recursive_model {
242            std::env::set_var("RECURSIVE_MODEL", v);
243        }
244        if let Some(v) = prev_openai_model {
245            std::env::set_var("OPENAI_MODEL", v);
246        }
247    }
248
249    #[test]
250    fn estimate_cost_for_known_model() {
251        // gpt-4o-mini: $0.15/M in, $0.60/M out
252        // 1000 in = 0.00015, 1000 out = 0.00060 → total 0.00075
253        let c = estimate_cost("gpt-4o-mini", 1000, 1000).unwrap();
254        assert!((c - 0.00075).abs() < 1e-9);
255    }
256
257    #[test]
258    fn estimate_cost_unknown_model_is_none() {
259        assert!(estimate_cost("foo-9000", 1000, 1000).is_none());
260    }
261
262    #[test]
263    fn estimate_cost_minimax_m3_is_known() {
264        assert!(estimate_cost("MiniMax-M3", 1000, 1000).is_some());
265    }
266}