use std::sync::mpsc::{Receiver, SyncSender};
use std::sync::{Arc, Mutex};
use crate::{
compact_session, estimate_session_tokens, CompactionConfig, ContentBlock, ConversationRuntime,
Session,
};
use crate::api::{tui_text_callback, OllamaApiClient};
use crate::commands::{
dispatch_slash_command, parse_slash_command, ReplState, SlashCommand, SlashOutcome,
};
use crate::executor::SecretaryToolExecutor;
use crate::memory::try_load_memory;
use crate::prompt::secretary_system_prompt_with_memory;
use crate::run::{
build_permission_policy, compact_threshold, current_model, index_turn_for_recall,
probe_recall_at_startup, recall_index_allowed, save_session,
};
use crate::tool_groups::{ToolGroup, ToolRegistry};
use crate::tui_events::{TuiEvent, UserInput};
use crate::tui_executor::TuiToolExecutor;
type TuiRuntime = ConversationRuntime<OllamaApiClient, TuiToolExecutor>;
fn build_tui_runtime(session: Session, tui_tx: SyncSender<TuiEvent>) -> TuiRuntime {
let reg = ToolRegistry::new();
let registry = Arc::new(Mutex::new(reg));
let api_client = OllamaApiClient::with_registry(current_model(), registry.clone())
.with_text_callback(tui_text_callback(tui_tx.clone()));
let hinter_registry = Arc::clone(®istry);
let inner = SecretaryToolExecutor::with_registry(registry);
let executor = TuiToolExecutor::new(inner, tui_tx);
let policy = build_permission_policy();
let memory = try_load_memory();
ConversationRuntime::new(
session,
api_client,
executor,
policy,
secretary_system_prompt_with_memory(memory.as_deref(), false),
)
.with_max_iterations(crate::run::max_iterations())
.with_auto_compaction_input_tokens_threshold(u32::MAX)
.with_unknown_tool_hinter(move |name: &str| {
ToolGroup::parse(name).map_or_else(Vec::new, |group| {
let reg = match hinter_registry.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
reg.group_tool_names(group)
})
})
}
fn handle_tui_slash(
cmd: &str,
runtime: &mut TuiRuntime,
tui_tx: &SyncSender<TuiEvent>,
) -> SlashOutcome {
let line = format!("/{}", cmd.trim());
let Some(parsed) = parse_slash_command(&line) else {
let _ = tui_tx.send(TuiEvent::TurnError(format!(
"could not parse slash command: {line}"
)));
return SlashOutcome::Continue;
};
let reset_history = matches!(parsed, SlashCommand::Clear | SlashCommand::Load(_));
let tx_for_rebuild = tui_tx.clone();
let rebuild = move |s: Session| build_tui_runtime(s, tx_for_rebuild.clone());
let state = ReplState::default();
let mut buf: Vec<u8> = Vec::new();
let outcome = dispatch_slash_command(parsed, runtime, &state, &mut buf, &rebuild);
if reset_history {
let _ = tui_tx.send(TuiEvent::SessionReset);
}
if !buf.is_empty() {
let raw = String::from_utf8_lossy(&buf);
let text = strip_ansi_escapes(&raw);
let _ = tui_tx.send(TuiEvent::Info(text));
}
outcome
}
fn format_rehydrate_outcome_for_tui(outcome: &crate::missions::RehydrateOutcome) -> Option<String> {
use crate::missions::RehydrateOutcome;
match outcome {
RehydrateOutcome::None => None,
RehydrateOutcome::Rehydrated(m) => Some(format!(
"resumed mission: {} ({})\n\
clear it with /mission_exit (or the mission_exit tool) if you didn't intend this",
m.slug,
m.path.display(),
)),
RehydrateOutcome::Cleared { reason, path } => Some(format!(
"cleared stale active-mission pointer at {} — {reason}",
path.display(),
)),
}
}
fn strip_ansi_escapes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\x1b' {
out.push(c);
continue;
}
if let Some('[') = chars.next() {
for nc in chars.by_ref() {
if matches!(nc, '\x40'..='\x7E') {
break;
}
}
}
}
out
}
fn maybe_compact(runtime: &mut TuiRuntime, tui_tx: &SyncSender<TuiEvent>) -> Option<usize> {
let estimated = estimate_session_tokens(runtime.session());
let (_, preserve, _) = crate::run::pick_compact_plan(
estimated,
compact_threshold(),
crate::run::soft_compact_threshold(),
)?;
let result = compact_session(
runtime.session(),
CompactionConfig {
preserve_recent_messages: preserve,
max_estimated_tokens: 0,
},
);
if result.removed_message_count == 0 {
return None;
}
let removed = result.removed_message_count;
*runtime = build_tui_runtime(result.compacted_session, tui_tx.clone());
Some(removed)
}
pub fn spawn_worker(
session: Session,
user_rx: Receiver<UserInput>,
tui_tx: SyncSender<TuiEvent>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut runtime = build_tui_runtime(session, tui_tx.clone());
probe_recall_at_startup();
let outcome = crate::missions::try_rehydrate_active_mission();
if let Some(line) = format_rehydrate_outcome_for_tui(&outcome) {
let _ = tui_tx.send(TuiEvent::Info(line));
}
while let Ok(input) = user_rx.recv() {
match input {
UserInput::Quit => break,
UserInput::SlashCommand(cmd) => {
if handle_tui_slash(&cmd, &mut runtime, &tui_tx) == SlashOutcome::Exit {
break;
}
}
UserInput::Message { text, images } => {
let _ = tui_tx.send(TuiEvent::Working(true));
crate::tools::set_current_turn_paths(crate::tools::extract_user_prompt_paths(
&text,
));
let image_pairs: Vec<(String, String)> = images
.into_iter()
.map(|att| (att.media_type, att.data_b64))
.collect();
let turn_result = if image_pairs.is_empty() {
runtime.run_turn(&text, None)
} else {
runtime.run_turn_with_images(&text, image_pairs, None)
};
match turn_result {
Ok(summary) => {
let response = summary
.assistant_messages
.last()
.and_then(|m| {
m.blocks.iter().find_map(|b| {
if let ContentBlock::Text { text } = b {
Some(text.clone())
} else {
None
}
})
})
.unwrap_or_default();
let _ = tui_tx.send(TuiEvent::TurnComplete {
text: response,
iterations: summary.iterations as u32,
in_tok: summary.usage.input_tokens,
out_tok: summary.usage.output_tokens,
});
if recall_index_allowed() {
index_turn_for_recall(&text, &runtime);
}
}
Err(e) => {
let _ = tui_tx.send(TuiEvent::TurnError(e.to_string()));
}
}
if let Some(removed) = maybe_compact(&mut runtime, &tui_tx) {
let _ = tui_tx.send(TuiEvent::Compacted { removed });
}
let estimated = estimate_session_tokens(runtime.session());
let _ = tui_tx.send(TuiEvent::TokensUpdate {
estimated,
threshold: compact_threshold(),
});
if let Err(e) = save_session(runtime.session()) {
eprintln!("tui worker: session save failed: {e:#}");
} else {
let _ = tui_tx.send(TuiEvent::Saved);
}
let _ = tui_tx.send(TuiEvent::Working(false));
}
}
}
})
}
#[cfg(test)]
mod tests {
use super::strip_ansi_escapes;
#[test]
fn strip_ansi_passthrough_when_no_escapes() {
assert_eq!(strip_ansi_escapes("hello world"), "hello world");
assert_eq!(strip_ansi_escapes(""), "");
}
#[test]
fn strip_ansi_removes_csi_sgr() {
assert_eq!(strip_ansi_escapes("\x1b[36mhello\x1b[0m"), "hello");
assert_eq!(
strip_ansi_escapes("\x1b[1;31mERR\x1b[0m: oops"),
"ERR: oops"
);
}
#[test]
fn strip_ansi_preserves_newlines_and_emoji() {
let input = "✓ \x1b[32mok\x1b[0m\nnext line 🤖";
assert_eq!(strip_ansi_escapes(input), "✓ ok\nnext line 🤖");
}
}