recursive/tui/app/
state.rs1use 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 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 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 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 pub fn input(&self) -> &str {
76 &self.prompt.buffer
77 }
78
79 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 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 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 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 pub fn set_pending_permission(&mut self, req: crate::tui::events::PermissionRequest) {
125 if self.auto_allowed_tools.contains(&req.tool_name) {
126 let _ = req.reply.send(true);
128 return;
129 }
130 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#[cfg(test)]
155mod tests {
156 use crate::tui::app::{App, AppScreen};
157 use crate::tui::cost::{detect_model_name, estimate_cost};
158
159 #[test]
162 fn app_new_creates_empty_state() {
163 let app = App::new();
164 assert!(app.input().is_empty());
165 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 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 #[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 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 assert_eq!(detect_model_name(), "claude-sonnet-4-6");
215
216 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 std::env::set_var("RECURSIVE_MODEL", "from-env");
228 assert_eq!(detect_model_name(), "from-env");
229
230 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 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 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}