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