pub mod compaction;
pub mod conversation;
mod request_builder;
mod response_handler;
pub mod sessions;
pub mod tui;
pub mod turn;
pub use conversation::ConversationHistory;
pub use request_builder::RequestBuilder;
pub use response_handler::ResponseHandler;
use std::io::IsTerminal;
use crate::api::LlmClient::Anthropic;
use crate::api::{CreateMessageRequest, LlmClient, MorphClient};
use crate::config::{
ApprovalPolicy, ModelConfig, PermissionPreset, SandboxMode, readonly_mode_message,
sandbox_off_message, sandbox_on_message,
};
use crate::error::{Result, SofosError};
use crate::mcp::McpManager;
use crate::session::{HistoryManager, SessionState};
use crate::tools::ToolExecutor;
use crate::ui::{UI, set_default_cursor_style, set_readonly_cursor_style};
use colored::Colorize;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;
pub type SteerBuffer = Arc<Mutex<Vec<String>>>;
fn mode_preamble_for(mode: SandboxMode, policy: ApprovalPolicy) -> String {
match mode {
SandboxMode::ReadOnly => readonly_mode_message(),
SandboxMode::Sandboxed => sandbox_on_message(policy),
SandboxMode::Unsandboxed => sandbox_off_message(),
}
}
pub(crate) fn permission_preset_notice(preset: PermissionPreset) -> String {
let headline = format!("Permissions: {} — {}", preset.label(), preset.description());
let headline = match preset.mode() {
SandboxMode::ReadOnly => headline.bright_yellow(),
SandboxMode::Unsandboxed => headline.bright_red(),
SandboxMode::Sandboxed => headline.bright_green(),
};
if preset.mode() == SandboxMode::Sandboxed {
let note = "Writes outside the project are blocked; out-of-project reads are not \
confined by default — add a Read(...) deny rule to protect a path."
.dimmed();
format!("{headline}\n{note}")
} else {
headline.to_string()
}
}
pub struct ReplConfig {
pub model: String,
pub max_tokens: u32,
pub reasoning_effort: crate::api::ReasoningEffort,
pub reasoning_mode: crate::api::ReasoningMode,
pub mode: SandboxMode,
pub approval_policy: ApprovalPolicy,
}
impl ReplConfig {
pub fn new(
model: String,
max_tokens: u32,
reasoning_effort: crate::api::ReasoningEffort,
reasoning_mode: crate::api::ReasoningMode,
mode: SandboxMode,
approval_policy: ApprovalPolicy,
) -> Self {
Self {
model,
max_tokens,
reasoning_effort,
reasoning_mode,
mode,
approval_policy,
}
}
}
pub struct Repl {
pub(super) client: LlmClient,
pub(super) tool_executor: ToolExecutor,
pub(super) history_manager: HistoryManager,
pub(super) ui: UI,
pub(super) model_config: ModelConfig,
pub(super) session_state: SessionState,
pub(super) mode: SandboxMode,
pub(super) approval_policy: ApprovalPolicy,
pub(super) available_tools: Vec<crate::api::Tool>,
pub(super) interrupt_flag: Arc<AtomicBool>,
pub(super) steer_buffer: SteerBuffer,
pub(super) startup_banner: String,
pub(super) mcp_init_lines: String,
pub(super) runtime: tokio::runtime::Runtime,
}
impl Repl {
pub fn new(
client: LlmClient,
config: ReplConfig,
workspace: PathBuf,
morph_client: Option<MorphClient>,
) -> Result<Self> {
let runtime = tokio::runtime::Runtime::new()
.map_err(|e| SofosError::Config(format!("Failed to create async runtime: {}", e)))?;
let (mcp_manager, mcp_init_lines) = runtime.block_on(async {
match McpManager::new(workspace.clone()).await {
Ok((manager, block)) => (Some(manager), block),
Err(e) => {
tracing::warn!(error = %e, "failed to initialize MCP manager");
(None, String::new())
}
}
});
let mut tool_executor = ToolExecutor::new(
workspace.clone(),
morph_client,
mcp_manager,
config.mode,
std::io::stdin().is_terminal(),
)?;
tool_executor.set_approval_policy(config.approval_policy);
let has_morph = tool_executor.has_morph();
let has_code_search = tool_executor.has_code_search();
let history_manager = HistoryManager::new(workspace.clone())?;
let custom_instructions = history_manager.load_custom_instructions()?;
if custom_instructions.is_some() {
tracing::info!("loaded custom instructions");
}
if config.max_tokens <= crate::api::anthropic::LEGACY_THINKING_BUDGET_HIGH {
return Err(SofosError::Config(format!(
"max_tokens ({}) must exceed the legacy thinking-budget ceiling ({}). \
Use a higher --max-tokens.",
config.max_tokens,
crate::api::anthropic::LEGACY_THINKING_BUDGET_HIGH
)));
}
if let Some(msg) =
crate::api::model_info::effort_support_error(&config.model, config.reasoning_effort)
{
return Err(SofosError::Config(msg));
}
if let Some(msg) =
crate::api::model_info::mode_support_error(&config.model, config.reasoning_mode)
{
return Err(SofosError::Config(msg));
}
let mut conversation =
ConversationHistory::with_features(has_morph, has_code_search, custom_instructions);
conversation.set_max_context_tokens(crate::config::max_context_tokens_for(&config.model));
conversation.set_auto_compact_token_limit(crate::config::auto_compact_token_limit_for(
&config.model,
));
conversation.add_user_message(mode_preamble_for(config.mode, config.approval_policy));
let readonly_mcp_note = if config.mode.is_readonly() {
set_readonly_cursor_style()?;
format_mcp_readonly_summary(
&tool_executor.mcp_servers_excluded_from_readonly(),
&tool_executor.mcp_servers_included_in_readonly(),
)
} else {
String::new()
};
let mcp_init_lines = if readonly_mcp_note.is_empty() {
mcp_init_lines
} else if mcp_init_lines.is_empty() {
readonly_mcp_note
} else {
format!("{}{}", mcp_init_lines, readonly_mcp_note)
};
let session_id = history_manager.generate_unique_session_id();
let session_state = SessionState::new(session_id, conversation);
let model_config = ModelConfig::new(
config.model,
config.max_tokens,
config.reasoning_effort,
config.reasoning_mode,
);
let ui = UI::new();
let available_tools = runtime.block_on(async { tool_executor.get_available_tools().await });
Ok(Self {
client,
tool_executor,
history_manager,
ui,
model_config,
session_state,
mode: config.mode,
approval_policy: config.approval_policy,
available_tools,
interrupt_flag: Arc::new(AtomicBool::new(false)),
steer_buffer: Arc::new(Mutex::new(Vec::new())),
startup_banner: String::new(),
mcp_init_lines,
runtime,
})
}
pub fn run(self) -> Result<()> {
tui::run(self)
}
pub fn set_startup_banner(&mut self, text: String) {
self.startup_banner = text;
}
pub(crate) fn take_startup_banner(&mut self) -> String {
std::mem::take(&mut self.startup_banner)
}
pub(crate) fn take_mcp_init_lines(&mut self) -> String {
std::mem::take(&mut self.mcp_init_lines)
}
pub fn install_interrupt_flag(&mut self, flag: Arc<AtomicBool>) {
self.interrupt_flag = Arc::clone(&flag);
self.tool_executor.install_interrupt_flag(flag);
}
pub fn install_steer_buffer(&mut self, buffer: SteerBuffer) {
self.steer_buffer = buffer;
}
pub fn model_label(&self) -> String {
self.model_config.model.clone()
}
pub fn current_reasoning_effort(&self) -> crate::api::ReasoningEffort {
self.model_config.reasoning_effort
}
pub fn current_reasoning_mode(&self) -> crate::api::ReasoningMode {
self.model_config.reasoning_mode
}
pub fn status_snapshot(&self) -> tui::event::StatusSnapshot {
let effort = self.model_config.reasoning_effort;
let reasoning = if self.uses_adaptive_thinking() {
format!("effort: {}", crate::api::anthropic::effort_label(effort))
} else if matches!(self.client, Anthropic(_)) {
let budget = crate::api::anthropic::legacy_thinking_budget(effort);
format!("thinking: {} tok", budget)
} else {
format!("effort: {}", effort.as_label())
};
let reasoning_mode = crate::api::model_info::supports_pro_mode(&self.model_config.model)
.then_some(self.model_config.reasoning_mode);
tui::event::StatusSnapshot {
model: self.model_config.model.clone(),
mode: self.mode,
approval: self.approval_policy,
reasoning,
reasoning_mode,
input_tokens: self.session_state.total_input_tokens,
output_tokens: self.session_state.total_output_tokens,
cache_read_tokens: self.session_state.total_cache_read_tokens,
cache_creation_tokens: self.session_state.total_cache_creation_tokens,
}
}
pub(super) fn build_initial_request(&self) -> CreateMessageRequest {
RequestBuilder::new(
&self.client,
&self.model_config.model,
self.model_config.max_tokens,
&self.session_state.conversation,
self.get_available_tools(),
self.model_config.reasoning_effort,
&self.session_state.session_id,
)
.with_reasoning_mode(self.model_config.reasoning_mode)
.build()
}
pub fn process_single_prompt(&mut self, prompt: &str) -> Result<()> {
let symbol = match self.mode {
SandboxMode::ReadOnly => ":",
SandboxMode::Sandboxed => ">",
SandboxMode::Unsandboxed => "#",
};
println!("{} {}", symbol.bright_green().bold(), prompt);
println!();
let turn_result = self.process_message(prompt, vec![]);
if let Err(e) = self.save_current_session() {
UI::print_warning(&format!(
"failed to save session after non-interactive turn: {}",
e
));
}
turn_result?;
UI::display_session_summary(
&self.model_config.model,
self.session_state.total_input_tokens,
self.session_state.total_output_tokens,
self.session_state.total_cache_read_tokens,
self.session_state.total_cache_creation_tokens,
self.session_state.peak_single_turn_input_tokens,
);
Ok(())
}
pub fn get_session_summary(&self) -> tui::event::ExitSummary {
tui::event::ExitSummary {
model: self.model_config.model.clone(),
input_tokens: self.session_state.total_input_tokens,
output_tokens: self.session_state.total_output_tokens,
cache_read_tokens: self.session_state.total_cache_read_tokens,
cache_creation_tokens: self.session_state.total_cache_creation_tokens,
peak_single_turn_input_tokens: self.session_state.peak_single_turn_input_tokens,
panicked: false,
}
}
pub fn handle_clear_command(&mut self) -> Result<()> {
let new_session_id = self.history_manager.generate_unique_session_id();
self.session_state.conversation.clear();
self.session_state.clear(new_session_id);
self.session_state
.conversation
.add_user_message(mode_preamble_for(self.mode, self.approval_policy));
self.session_state
.conversation
.add_user_message("[SYSTEM: The session history has been cleared.]".to_string());
println!("\n{}\n", "Conversation history cleared.".bright_yellow());
Ok(())
}
fn uses_adaptive_thinking(&self) -> bool {
matches!(self.client, Anthropic(_))
&& crate::api::anthropic::requires_adaptive_thinking(&self.model_config.model)
}
fn print_reasoning_state(&self) {
let effort = self.model_config.reasoning_effort;
if self.uses_adaptive_thinking() {
println!(
"\n{} {}\n",
"Adaptive thinking effort:".bright_green(),
crate::api::anthropic::effort_label(effort)
);
} else if matches!(self.client, Anthropic(_)) {
let budget = crate::api::anthropic::legacy_thinking_budget(effort);
println!(
"\n{} (budget: {} tokens)\n",
"Extended thinking: enabled".bright_green(),
budget
);
} else {
println!(
"\n{} {}\n",
"Reasoning effort:".bright_green(),
effort.as_label()
);
}
}
pub fn handle_effort_set(&mut self, effort: crate::api::ReasoningEffort) {
if let Some(msg) =
crate::api::model_info::effort_support_error(&self.model_config.model, effort)
{
println!();
UI::print_error(&msg);
println!();
return;
}
if matches!(self.client, Anthropic(_))
&& !self.uses_adaptive_thinking()
&& self.model_config.max_tokens <= crate::api::anthropic::LEGACY_THINKING_BUDGET_HIGH
{
println!();
UI::print_error(&format!(
"Cannot enable extended thinking on the legacy budget — max_tokens \
({}) must exceed {}. Relaunch with a higher --max-tokens or pick a \
lower effort.",
self.model_config.max_tokens,
crate::api::anthropic::LEGACY_THINKING_BUDGET_HIGH
));
println!();
return;
}
self.model_config.set_reasoning_effort(effort);
self.print_reasoning_state();
}
pub fn handle_mode_set(&mut self, mode: crate::api::ReasoningMode) {
if let Some(msg) =
crate::api::model_info::mode_support_error(&self.model_config.model, mode)
{
println!();
UI::print_error(&msg);
println!();
return;
}
self.model_config.set_reasoning_mode(mode);
println!(
"\n{} {}\n",
"Reasoning mode:".bright_green(),
mode.as_label()
);
}
pub fn handle_mode_picker_fallback(&self) {
println!();
println!(
"{} {}",
"Reasoning mode:".bright_green(),
self.model_config.reasoning_mode.as_label().bright_white()
);
if crate::api::model_info::supports_pro_mode(&self.model_config.model) {
println!(
"{} standard, pro (switch with `/mode <standard|pro>`)",
"Available:".bright_cyan()
);
} else {
println!(
"{} pro mode is available on: {}",
"Note:".bright_yellow(),
crate::api::model_info::pro_capable_models_label()
);
}
println!();
}
pub fn handle_effort_picker_fallback(&self) {
let info = crate::api::model_info::lookup(&self.model_config.model);
let current = self.model_config.reasoning_effort;
println!();
println!(
"{} {}",
"Current effort:".bright_green(),
current.as_label().bright_white()
);
println!("{}", "Supported levels:".bright_cyan());
for effort in info.supported_efforts {
let marker = if *effort == current { "❯" } else { " " };
println!(
" {} {}",
marker.bright_green(),
effort.as_label().bright_white()
);
}
println!();
println!(
"{}",
"Use `/effort <level>` to switch, or open an interactive session for the picker."
.dimmed()
);
println!();
}
pub fn handle_model_set(&mut self, name: &str) {
let Some(choice) = crate::api::model_info::canonical_model(name) else {
println!();
UI::print_error(
&crate::api::model_info::model_support_error(name)
.unwrap_or_else(|| format!("Model `{}` is not supported.", name)),
);
println!();
return;
};
if choice.name == self.model_config.model {
println!(
"\n{} already active: {}\n",
"Model:".dimmed(),
choice.name.bright_green()
);
return;
}
let current_provider = crate::api::model_info::provider_for(&self.model_config.model);
if choice.provider != current_provider {
println!();
UI::print_error(&format!(
"Cannot switch to `{}` ({}) from the current {} session. \
Re-launch with `--model {}` to use it.",
choice.name,
choice.provider.label(),
current_provider.label(),
choice.name
));
println!();
return;
}
if let Some(msg) = crate::api::model_info::effort_support_error(
choice.name,
self.model_config.reasoning_effort,
) {
println!();
UI::print_error(&format!(
"{} Run `/effort <level>` to pick a supported level before switching.",
msg
));
println!();
return;
}
if let Some(msg) = crate::api::model_info::mode_support_error(
choice.name,
self.model_config.reasoning_mode,
) {
println!();
UI::print_error(&format!("{} Run `/mode standard` before switching.", msg));
println!();
return;
}
self.model_config.model = choice.name.to_string();
self.session_state
.conversation
.set_max_context_tokens(crate::config::max_context_tokens_for(choice.name));
self.session_state
.conversation
.set_auto_compact_token_limit(crate::config::auto_compact_token_limit_for(choice.name));
println!(
"\n{} {}\n",
"Model:".bright_green(),
choice.name.bright_white()
);
}
pub fn handle_model_picker_fallback(&self) {
println!();
println!(
"{} {}",
"Current model:".bright_green(),
self.model_config.model.bright_white()
);
println!("{}", "Available models:".bright_cyan());
for choice in crate::api::model_info::SUPPORTED_MODELS {
let marker = if choice.name == self.model_config.model {
"❯"
} else {
" "
};
println!(
" {} {:<20} {}",
marker.bright_green(),
choice.name.bright_white(),
choice.description.dimmed()
);
}
println!();
println!(
"{}",
"Use `/model <name>` to switch, or open an interactive session for the picker."
.dimmed()
);
println!();
}
pub fn apply_permission_preset(&mut self, preset: PermissionPreset) {
if !preset.is_available(self.sandbox_available()) {
let current = PermissionPreset::current(self.mode, self.approval_policy);
println!(
"\n{}\n",
format!(
"{} is unavailable here: this platform has no operating-system \
sandbox. Staying in {}.",
preset.label(),
current.label()
)
.bright_yellow()
);
return;
}
let mode = preset.mode();
let target_policy = preset.escalation();
let mode_changed = self.mode != mode;
let policy_changed = target_policy.is_some_and(|p| p != self.approval_policy);
if !mode_changed && !policy_changed {
println!(
"\n{}\n",
format!("Already set to {}", preset.label()).dimmed()
);
return;
}
if mode_changed {
self.mode = mode;
self.tool_executor.set_mode(mode);
self.refresh_available_tools();
let _ = if mode.is_readonly() {
set_readonly_cursor_style()
} else {
set_default_cursor_style()
};
}
if let Some(policy) = target_policy {
self.approval_policy = policy;
self.tool_executor.set_approval_policy(policy);
}
self.session_state
.conversation
.add_user_message(mode_preamble_for(self.mode, self.approval_policy));
println!("\n{}\n", permission_preset_notice(preset));
if mode.is_readonly() {
self.print_mcp_readonly_summary();
}
}
pub fn sandbox_available(&self) -> bool {
crate::tools::bash::sandbox::is_available()
}
pub fn handle_permissions_picker_fallback(&self) {
let current = PermissionPreset::current(self.mode, self.approval_policy);
let available = self.sandbox_available();
println!();
println!(
"{} {}",
"Current permissions:".bright_green(),
current.label().bright_white()
);
println!("{}", "Available presets:".bright_cyan());
for preset in crate::config::PERMISSION_PRESETS {
let marker = if preset == current { "❯" } else { " " };
let note = if preset.is_available(available) {
""
} else {
" (unavailable on this platform)"
};
println!(
" {} {}{}",
marker.bright_green(),
preset.label().bright_white(),
note.dimmed()
);
}
println!();
println!(
"{}",
"Use `/permissions <preset>` to switch, or open an interactive session for the picker."
.dimmed()
);
}
fn print_mcp_readonly_summary(&self) {
let summary = format_mcp_readonly_summary(
&self.tool_executor.mcp_servers_excluded_from_readonly(),
&self.tool_executor.mcp_servers_included_in_readonly(),
);
if !summary.is_empty() {
print!("{}", summary);
println!();
}
}
fn refresh_available_tools(&mut self) {
let tools = self
.runtime
.block_on(async { self.tool_executor.get_available_tools().await });
self.available_tools = tools;
}
fn get_available_tools(&self) -> Vec<crate::api::Tool> {
self.available_tools.clone()
}
pub(super) async fn wait_for_interrupt(flag: Arc<AtomicBool>) {
while !flag.load(Ordering::Relaxed) {
sleep(Duration::from_millis(50)).await;
}
}
}
fn format_mcp_readonly_summary(excluded: &[String], included: &[String]) -> String {
if excluded.is_empty() && included.is_empty() {
return String::new();
}
let mut out = String::new();
if !excluded.is_empty() {
out.push_str(&format!(
" {} Read-only mode hides MCP servers: {}\n",
"•".bright_yellow(),
excluded.join(", ")
));
out.push_str(
" Set `readonly = \"read_only\"` on a server to make its tools available.\n",
);
}
if !included.is_empty() {
out.push_str(&format!(
" {} Read-only mode allows MCP servers: {}\n",
"•".bright_green(),
included.join(", ")
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sandboxed_preset_notice_explains_that_reads_are_not_confined() {
const NOTE: &str = "out-of-project reads are not confined by default";
for preset in [
PermissionPreset::SandboxedAsk,
PermissionPreset::SandboxedRetry,
PermissionPreset::SandboxedStrict,
] {
let notice = permission_preset_notice(preset);
assert!(
notice.contains(NOTE),
"{} must explain reads are not confined; got: {notice:?}",
preset.label()
);
assert!(
notice.contains('\n'),
"{} note must be a second line, not appended inline",
preset.label()
);
}
for preset in [PermissionPreset::ReadOnly, PermissionPreset::Unsandboxed] {
let notice = permission_preset_notice(preset);
assert!(
!notice.contains(NOTE),
"{} must not carry the sandbox read/write note; got: {notice:?}",
preset.label()
);
}
}
}