use std::sync::{Arc, atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}};
use std::time::Duration;
use stynx_code_commands::expand_message_content;
use stynx_code_engine::{EngineEvent, QueryEngine};
use stynx_code_errors::AppError;
use stynx_code_permission::{ConfigAwarePermissionChecker, PromptBridge, PromptChoice, PromptRequest};
use stynx_code_tools::{QuestionBridge, QuestionRequest, SharedQuestionBridge};
use super::agent_tool::InternTool;
use stynx_code_provider::AnthropicProvider;
use stynx_code_types::{Conversation, Message, PermissionMode, Role};
use stynx_code_tui::state::app_state::SessionSummary;
use stynx_code_tui::state::InputKind;
use stynx_code_tui::{DisplayMessage, EventHandler, PermissionChoice, TuiApp, UiAction};
use tokio::sync::oneshot;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use super::app_actions::{expand_with_pins, save_session};
use super::command_handler::handle_slash_command;
use super::command_types::CommandAction;
use super::run_engine::run_engine_tui;
use super::skills::Skill;
use super::terminal::set_current_model;
type EngineTask = JoinHandle<Result<Conversation, AppError>>;
type EngineSlot = Option<(EngineTask, mpsc::UnboundedReceiver<EngineEvent>, Conversation)>;
fn conv_to_tui(conversation: &Conversation) -> Vec<DisplayMessage> {
conversation.messages.iter().filter_map(|m| {
let role = match m.role { Role::User => "user", Role::Assistant => "assistant" };
let text = m.content.iter().filter_map(|b| {
if let stynx_code_types::ContentBlock::Text { text } = b { Some(text.as_str()) } else { None }
}).collect::<Vec<_>>().join("");
if text.is_empty() { return None; }
Some(DisplayMessage { role: role.to_string(), content: text, thinking: String::new(), tool_uses: Vec::new(), is_streaming: false })
}).collect()
}
fn spawn_engine(
text: &str, conversation: &mut Conversation, tui: &mut TuiApp,
engine: &Arc<QueryEngine>, total_input: &Arc<AtomicU64>, total_output: &Arc<AtomicU64>,
) -> EngineSlot {
let pre = conversation.clone();
conversation.push(Message { role: Role::User, content: expand_message_content(text) });
tui.state.push_user_message(text);
let (ev_tx, ev_rx) = mpsc::unbounded_channel();
let task = tokio::spawn({
let (eng, conv, ti, to) = (engine.clone(), conversation.clone(), total_input.clone(), total_output.clone());
async move { run_engine_tui(&eng, conv, &ti, &to, ev_tx).await }
});
Some((task, ev_rx, pre))
}
#[allow(clippy::too_many_arguments)]
pub async fn run_loop(
engine: Arc<QueryEngine>,
_conductor_engine: Arc<QueryEngine>,
_reflect_engine: Arc<QueryEngine>,
session_repo: Arc<dyn stynx_code_memory::SessionRepository>,
provider: Arc<AnthropicProvider>,
config: stynx_code_config::Settings,
mode_flag: Arc<AtomicU8>,
cwd: String,
system_prompt: String,
mut conversation: Conversation,
skills: Vec<Skill>,
pause_flag: Arc<AtomicBool>,
permission: Arc<ConfigAwarePermissionChecker>,
intern_tools: Vec<Arc<InternTool>>,
ask_user_bridge_handle: SharedQuestionBridge,
) {
let total_input = Arc::new(AtomicU64::new(0));
let total_output = Arc::new(AtomicU64::new(0));
let mut pinned_files: Vec<String> = Vec::new();
let model_id = provider.model_name();
let mut tui = match TuiApp::new() {
Ok(t) => t,
Err(e) => { eprintln!("TUI init failed: {e}"); return; }
};
tui.state.model_name = model_id.clone();
tui.state.git_branch = super::terminal::git_branch();
tui.state.cwd = cwd.clone();
tui.state.conversation.messages = conv_to_tui(&conversation);
refresh_sidebar_sessions(&session_repo, &mut tui).await;
let persisted = stynx_code_tui::persistence::load();
stynx_code_tui::persistence::apply_to(&mut tui.state, &persisted);
set_current_model(&model_id);
provider.toggle_thinking();
let (key_tx, mut key_rx) = mpsc::unbounded_channel::<crossterm::event::Event>();
let stop = Arc::new(AtomicBool::new(false));
let stop2 = stop.clone();
let pause_for_keys = pause_flag.clone();
tokio::task::spawn_blocking(move || {
while !stop2.load(Ordering::Relaxed) {
if pause_for_keys.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(10));
continue;
}
if crossterm::event::poll(Duration::from_millis(30)).unwrap_or(false) {
if let Ok(ev) = crossterm::event::read() { if key_tx.send(ev).is_err() { break; } }
}
}
});
let mut engine_task: EngineSlot = None;
let mut engine_started: Option<std::time::Instant> = None;
let mut tokens_at_start: (u64, u64) = (0, 0);
let perm = permission.clone();
let (prompt_bridge, mut prompt_rx) = PromptBridge::new();
permission.install_prompt_bridge(prompt_bridge);
let mut pending_prompt: Option<oneshot::Sender<PromptChoice>> = None;
let (question_bridge, mut question_rx) = QuestionBridge::new();
ask_user_bridge_handle.set(question_bridge);
let mut pending_question: Option<oneshot::Sender<Option<String>>> = None;
loop {
tui.sync_pause(&pause_flag);
if let Some((task, ev_rx, _)) = &mut engine_task {
while let Ok(ev) = ev_rx.try_recv() { tui.state.apply_engine_event(ev); }
if task.is_finished() {
let (task, _, pre) = engine_task.take().unwrap();
if pending_prompt.take().is_some() || pending_question.take().is_some() {
if matches!(&tui.state.modal.active,
Some(stynx_code_tui::ModalKind::Permission { .. })
| Some(stynx_code_tui::ModalKind::Input { .. }))
{
tui.state.modal.close();
}
}
let elapsed = engine_started.take()
.map(|t| t.elapsed())
.unwrap_or(std::time::Duration::ZERO);
let (start_in, start_out) = std::mem::take(&mut tokens_at_start);
let in_delta = total_input.load(Ordering::Relaxed).saturating_sub(start_in);
let out_delta = total_output.load(Ordering::Relaxed).saturating_sub(start_out);
match task.await {
Ok(Ok(updated)) => {
conversation = updated;
save_session(&session_repo, &conversation).await;
tui.state.toasts.success(format!(
"done · {} · {} in / {} out",
fmt_elapsed(elapsed),
fmt_tokens(in_delta),
fmt_tokens(out_delta),
));
}
Ok(Err(e)) if e.is_interrupted() => {
conversation = pre;
if let Some(m) = tui.state.conversation.messages.last_mut() { m.is_streaming = false; }
}
Ok(Err(e)) => {
tui.state.push_system_message(format!("Error: {e}"));
tui.state.toasts.error(format!("failed · {}", fmt_elapsed(elapsed)));
conversation = pre;
}
Err(_) => { conversation = pre; }
}
tui.state.is_streaming = false;
}
}
if pending_prompt.is_none() {
if let Ok(req) = prompt_rx.try_recv() {
let PromptRequest { tool_name, description, responder } = req;
tui.state.modal.open_permission(tool_name, description);
pending_prompt = Some(responder);
}
}
if pending_question.is_some() && tui.state.modal.active.is_none() {
if let Some(responder) = pending_question.take() {
let _ = responder.send(None);
}
}
if pending_question.is_none() && tui.state.modal.active.is_none() {
if let Ok(req) = question_rx.try_recv() {
let QuestionRequest { question, responder } = req;
tui.state.modal.open_input(
"Question",
question,
String::new(),
InputKind::AskUserQuestion,
);
pending_question = Some(responder);
}
}
tui.tick_spinner();
if tui.is_in_alt() { tui.draw().ok(); }
while let Ok(ev) = key_rx.try_recv() {
match EventHandler::handle(ev, &mut tui.state) {
UiAction::Submit(text) if engine_task.is_none() => {
let trimmed = text.trim().to_string();
if let Some(rest) = trimmed.strip_prefix("/intern") {
let rest = rest.trim();
if intern_tools.is_empty() {
tui.state.push_system_message(
"no interns configured. add `interns` to .stynx/settings.json, \
or set DEEPSEEK_API_KEY / OPENROUTER_API_KEY in .env and restart.",
);
continue;
}
if rest.is_empty() {
let names: Vec<String> = intern_tools.iter()
.map(|t| t.label().to_string()).collect();
tui.state.push_system_message(format!(
"usage: /intern [<name>] <task description>\navailable interns: {}",
names.join(", "),
));
continue;
}
let (intern, task) = match rest.split_once(' ') {
Some((first, tail)) => {
let first_t = first.trim();
let pick = intern_tools.iter()
.find(|t| t.label().eq_ignore_ascii_case(first_t))
.cloned();
match pick {
Some(t) => (t, tail.trim().to_string()),
None => (intern_tools[0].clone(), rest.to_string()),
}
}
None => (intern_tools[0].clone(), rest.to_string()),
};
if task.is_empty() {
tui.state.push_system_message("usage: /intern [<name>] <task description>");
continue;
}
tui.state.push_system_message(format!(
"🧑🎓 {label} intern working on: {task}",
label = intern.label(),
));
match intern.run_task(&task).await {
Ok(output) => {
tui.state.push_system_message(format!(
"🧑🎓 {label} intern result:\n{output}",
label = intern.label(),
));
}
Err(e) => {
tui.state.push_system_message(format!(
"{label} intern failed: {e}",
label = intern.label(),
));
}
}
continue;
}
if trimmed.starts_with('/') {
let action = handle_tui_slash(&trimmed, &provider, &config, &mode_flag,
&system_prompt, &cwd, &conversation, &skills, &mut pinned_files, &mut tui).await;
match action {
Some(CommandAction::ReplaceConversation(c)) => {
tui.state.conversation.messages = conv_to_tui(&c);
conversation = c;
}
Some(CommandAction::SendToEngine(msg, tools)) => {
if !tools.is_empty() { perm.set_skill_allow_rules(tools); }
engine_task = spawn_engine(&msg, &mut conversation, &mut tui, &engine, &total_input, &total_output);
engine_started = Some(std::time::Instant::now());
tokens_at_start = (total_input.load(Ordering::Relaxed), total_output.load(Ordering::Relaxed));
perm.clear_skill_allow_rules();
}
Some(CommandAction::Output(t)) => tui.state.push_system_message(t.trim_end()),
Some(CommandAction::Quit) => { stop.store(true, Ordering::Relaxed); break; }
Some(CommandAction::Continue) | None => {}
}
} else {
let expanded = expand_with_pins(&trimmed, &pinned_files);
engine_task = spawn_engine(&expanded, &mut conversation, &mut tui, &engine, &total_input, &total_output);
engine_started = Some(std::time::Instant::now());
tokens_at_start = (total_input.load(Ordering::Relaxed), total_output.load(Ordering::Relaxed));
}
}
UiAction::CyclePermissionMode => {
let next = PermissionMode::load(&mode_flag).next();
next.store(&mode_flag);
tui.state.permission_mode = next.label().to_string();
}
UiAction::SelectModel(model_id) => {
provider.set_model(&model_id);
let effective = provider.model_name();
tui.state.model_name = effective.clone();
tui.state.push_recent_model(&effective);
set_current_model(&effective);
tui.state.toasts.success(format!("model → {effective}"));
}
UiAction::SelectSession(session_id) => {
match session_repo.load(&session_id).await {
Ok(Some(loaded)) => {
tui.state.conversation.messages = conv_to_tui(&loaded);
conversation = loaded;
let _ = session_repo.set_current(&session_id).await;
tui.state.sidebar.session_id = session_id.clone();
refresh_sidebar_sessions(&session_repo, &mut tui).await;
tui.state.toasts.success(format!("switched → {}", &tui.state.sidebar.title));
}
Ok(None) => tui.state.toasts.warn("session not found"),
Err(e) => tui.state.toasts.error(format!("load failed: {e}")),
}
}
UiAction::ToggleSidebar => {
tui.state.sidebar.visible = !tui.state.sidebar.visible;
let label = if tui.state.sidebar.visible { "sidebar shown" } else { "sidebar hidden" };
tui.state.toasts.info(label);
}
UiAction::PermissionDecision(choice) => {
if let Some(responder) = pending_prompt.take() {
let mapped = match choice {
PermissionChoice::Once => PromptChoice::AllowOnce,
PermissionChoice::Always => PromptChoice::AllowAlways,
PermissionChoice::Reject => PromptChoice::Deny,
};
let _ = responder.send(mapped);
}
}
UiAction::RunCommand(name) => {
match name.as_str() {
"session.new" => {
conversation = stynx_code_types::Conversation {
system: Some(system_prompt.clone()),
..Default::default()
};
tui.state.conversation.messages.clear();
tui.state.total_cost = 0.0;
tui.state.total_input = 0;
tui.state.total_output = 0;
match session_repo.new_session_id().await {
Ok(id) => {
let _ = session_repo.set_current(&id).await;
if let Err(e) = session_repo.save(Some(&id), &conversation).await {
tracing::warn!("save new session failed: {e}");
}
tui.state.sidebar.title = "New session".to_string();
tui.state.sidebar.session_id = id;
refresh_sidebar_sessions(&session_repo, &mut tui).await;
tui.state.toasts.success("new session");
}
Err(e) => {
tui.state.toasts.error(format!("new session failed: {e}"));
}
}
}
"session.compact" => {
tui.state.push_system_message("compacting…");
let provider_dyn: Arc<dyn stynx_code_types::Provider> = provider.clone();
let mut on_event = |_ev: stynx_code_engine::EngineEvent| {};
match stynx_code_engine::application::compactor::compact(
&provider_dyn,
conversation.clone(),
&mut on_event,
)
.await
{
Ok(compacted) => {
let new_count = compacted.messages.len();
tui.state.conversation.messages = conv_to_tui(&compacted);
conversation = compacted;
save_session(&session_repo, &conversation).await;
tui.state.push_system_message(format!(
"compacted → {new_count} messages"
));
}
Err(e) => tui.state.push_system_message(format!("compact failed: {e}")),
}
}
"session.export" => {
match export_transcript(&conversation, &cwd).await {
Ok(path) => tui.state.toasts.success(format!("exported → {path}")),
Err(e) => tui.state.toasts.error(format!("export failed: {e}")),
}
}
"model.cycle_recent" => {
if let Some(next) = tui.state.cycle_recent_model() {
provider.set_model(&next);
let effective = provider.model_name();
tui.state.model_name = effective.clone();
set_current_model(&effective);
tui.state.push_system_message(format!("model → {effective}"));
} else {
tui.state.push_system_message("no other recent model");
}
}
"help.show" => {
let skill_pairs: Vec<(String, String)> = skills
.iter()
.filter(|s| s.user_invocable)
.map(|s| (s.name.clone(), s.description.clone()))
.collect();
stynx_code_tui::dialogs::open_help(&mut tui.state, &skill_pairs);
}
"status.show" => {
let intern_labels: Vec<String> = intern_tools.iter()
.map(|t| t.label().to_string()).collect();
stynx_code_tui::dialogs::open_status(&mut tui.state, &intern_labels);
}
"skills.show" => {
let skill_pairs: Vec<(String, String)> = skills
.iter()
.filter(|s| s.user_invocable)
.map(|s| (s.name.clone(), s.description.clone()))
.collect();
stynx_code_tui::dialogs::open_skill_picker(&mut tui.state, &skill_pairs);
}
"session.rename" => {
let current = tui.state.sidebar.title.clone();
tui.state.modal.open_input(
"Rename session",
"New title:",
current,
InputKind::SessionRename,
);
}
_ => {}
}
}
UiAction::InputConfirmed { kind, value } => match kind {
InputKind::SessionRename => {
let id = tui.state.sidebar.session_id.clone();
if !id.is_empty() {
if let Err(e) = session_repo.rename(&id, &value).await {
tracing::warn!("rename failed: {e}");
}
}
tui.state.sidebar.title = value.clone();
refresh_sidebar_sessions(&session_repo, &mut tui).await;
tui.state.toasts.success(format!("renamed → {value}"));
}
InputKind::AskUserQuestion => {
if let Some(responder) = pending_question.take() {
let _ = responder.send(Some(value));
}
}
},
UiAction::Interrupt => {
if let Some((task, _, _)) = engine_task.as_ref() {
task.abort();
tui.state.is_streaming = false;
tui.state.is_paused = true;
tui.state.sub_agents.clear();
tui.state.toasts.warn("interrupted");
}
pending_prompt.take();
pending_question.take();
tui.state.modal.close();
}
UiAction::Quit => { stop.store(true, Ordering::Relaxed); break; }
_ => {}
}
}
if stop.load(Ordering::Relaxed) { break; }
tokio::time::sleep(Duration::from_millis(16)).await;
}
save_session(&session_repo, &conversation).await;
stynx_code_tui::persistence::save(&stynx_code_tui::persistence::snapshot(&tui.state));
}
async fn export_transcript(conversation: &Conversation, cwd: &str) -> Result<String, String> {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let filename = format!("transcript-{ts}.md");
let path = std::path::Path::new(cwd).join(&filename);
let mut out = String::new();
out.push_str(&format!("# stynx-code transcript — {ts}\n\n"));
for msg in &conversation.messages {
let header = match msg.role {
Role::User => "## User",
Role::Assistant => "## Assistant",
};
out.push_str(header);
out.push_str("\n\n");
for block in &msg.content {
if let stynx_code_types::ContentBlock::Text { text } = block {
out.push_str(text);
out.push_str("\n\n");
}
}
}
tokio::fs::write(&path, out)
.await
.map_err(|e| format!("write failed: {e}"))?;
Ok(path.display().to_string())
}
async fn refresh_sidebar_sessions(
repo: &Arc<dyn stynx_code_memory::SessionRepository>,
tui: &mut TuiApp,
) {
let summaries = repo.list().await.unwrap_or_default();
let current = repo.current().await.ok().flatten();
tui.state.sidebar.sessions = summaries
.iter()
.map(|s| SessionSummary {
id: s.id.clone(),
title: s.title.clone(),
updated_at: s.updated_at,
pinned: false,
})
.collect();
if let Some(id) = current {
if let Some(s) = summaries.iter().find(|s| s.id == id) {
tui.state.sidebar.session_id = s.id.clone();
tui.state.sidebar.title = s.title.clone();
} else if let Some(first) = summaries.first() {
tui.state.sidebar.session_id = first.id.clone();
tui.state.sidebar.title = first.title.clone();
}
} else if let Some(first) = summaries.first() {
tui.state.sidebar.session_id = first.id.clone();
tui.state.sidebar.title = first.title.clone();
}
}
async fn handle_tui_slash(
cmd: &str,
provider: &Arc<AnthropicProvider>,
config: &stynx_code_config::Settings,
mode_flag: &Arc<AtomicU8>,
system_prompt: &str,
cwd: &str,
conversation: &Conversation,
skills: &[Skill],
pinned_files: &mut Vec<String>,
tui: &mut TuiApp,
) -> Option<CommandAction> {
use super::app_actions::{copy_last_response, handle_add, show_files, show_skills};
use super::app_help::print_help;
match cmd {
"/quit" | "/exit" => return Some(CommandAction::Quit),
"/version" => { tui.state.push_system_message(format!("stynx-code v{}", env!("CARGO_PKG_VERSION"))); return None; }
"/files" => { show_files(pinned_files); return None; }
"/copy" => { copy_last_response(conversation); return None; }
"/help" => {
tui.leave_alt(); print_help(skills); let _ = std::io::stdin().read_line(&mut String::new()); tui.enter_alt();
return None;
}
"/skills" => {
tui.leave_alt(); show_skills(skills); let _ = std::io::stdin().read_line(&mut String::new()); tui.enter_alt();
return None;
}
_ => {}
}
if let Some(path) = cmd.strip_prefix("/add ") { handle_add(path, pinned_files); return None; }
tui.leave_alt();
let result = handle_slash_command(cmd, provider, config, mode_flag, system_prompt, cwd, conversation, skills).await;
tui.enter_alt();
result
}
fn fmt_elapsed(d: std::time::Duration) -> String {
let secs = d.as_secs();
let ms = d.subsec_millis();
if secs >= 60 {
let m = secs / 60;
let s = secs % 60;
format!("{m}m {s}s")
} else if secs >= 10 {
format!("{secs}s")
} else {
format!("{secs}.{:01}s", ms / 100)
}
}
fn fmt_tokens(n: u64) -> String {
if n >= 1_000_000 { format!("{:.1}M", n as f64 / 1_000_000.0) }
else if n >= 1_000 { format!("{:.1}k", n as f64 / 1_000.0) }
else { n.to_string() }
}