use std::sync::mpsc::{Receiver, SyncSender};
use std::sync::{Arc, Mutex};
use crate::{
compact_session, estimate_session_tokens, CompactionConfig, ContentBlock, ConversationRuntime,
PermissionPromptDecision, PermissionPrompter, PermissionRequest, 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)
})
})
}
pub struct TuiPrompter {
tui_tx: SyncSender<TuiEvent>,
}
impl TuiPrompter {
pub fn new(tui_tx: SyncSender<TuiEvent>) -> Self {
Self { tui_tx }
}
}
impl PermissionPrompter for TuiPrompter {
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
let (resp_tx, resp_rx) = std::sync::mpsc::sync_channel::<bool>(0);
let sent = self.tui_tx.send(TuiEvent::PermissionRequest {
tool_name: request.tool_name.clone(),
input: request.input.clone(),
required_mode: request.required_mode.as_str().to_string(),
resp_tx,
});
if sent.is_err() {
return PermissionPromptDecision::Deny {
reason: "could not read user input".to_string(),
};
}
match resp_rx.recv() {
Ok(true) => PermissionPromptDecision::Allow,
Ok(false) => PermissionPromptDecision::Deny {
reason: "user denied permission".to_string(),
},
Err(_) => PermissionPromptDecision::Deny {
reason: "could not read user input".to_string(),
},
}
}
}
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 mission_state action=exit) 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());
let mut prompter = TuiPrompter::new(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, Some(&mut prompter))
} else {
runtime.run_turn_with_images(&text, image_pairs, Some(&mut prompter))
};
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, TuiPrompter};
use crate::tui_events::TuiEvent;
use crate::{
PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
PermissionPrompter, PermissionRequest,
};
use std::sync::mpsc;
fn danger_request(input: &str) -> PermissionRequest {
PermissionRequest {
tool_name: "bash".to_string(),
input: input.to_string(),
current_mode: PermissionMode::WorkspaceWrite,
required_mode: PermissionMode::DangerFullAccess,
}
}
fn spawn_render_stub(
tui_rx: mpsc::Receiver<TuiEvent>,
answer: Option<bool>,
) -> std::thread::JoinHandle<(String, String, String)> {
std::thread::spawn(move || match tui_rx.recv().expect("no event") {
TuiEvent::PermissionRequest {
tool_name,
input,
required_mode,
resp_tx,
} => {
if let Some(ans) = answer {
resp_tx.send(ans).expect("worker hung up");
}
(tool_name, input, required_mode)
}
other => panic!("unexpected event: {other:?}"),
})
}
#[test]
fn tui_prompter_allows_on_true_and_ships_full_input() {
let (tui_tx, tui_rx) = mpsc::sync_channel(8);
let stub = spawn_render_stub(tui_rx, Some(true));
let mut p = TuiPrompter::new(tui_tx);
let decision = p.decide(&danger_request("rm -rf ./target && echo done"));
assert_eq!(decision, PermissionPromptDecision::Allow);
let (tool, input, mode) = stub.join().unwrap();
assert_eq!(tool, "bash");
assert_eq!(input, "rm -rf ./target && echo done");
assert_eq!(mode, "danger-full-access");
}
#[test]
fn tui_prompter_denies_on_false_with_cli_parity_reason() {
let (tui_tx, tui_rx) = mpsc::sync_channel(8);
let stub = spawn_render_stub(tui_rx, Some(false));
let mut p = TuiPrompter::new(tui_tx);
let decision = p.decide(&danger_request("git push --force"));
assert_eq!(
decision,
PermissionPromptDecision::Deny {
reason: "user denied permission".to_string(),
}
);
stub.join().unwrap();
}
#[test]
fn tui_prompter_denies_when_render_loop_drops_the_answer() {
let (tui_tx, tui_rx) = mpsc::sync_channel(8);
let stub = spawn_render_stub(tui_rx, None);
let mut p = TuiPrompter::new(tui_tx);
let decision = p.decide(&danger_request("bash payload"));
assert_eq!(
decision,
PermissionPromptDecision::Deny {
reason: "could not read user input".to_string(),
}
);
stub.join().unwrap();
}
#[test]
fn tui_prompter_denies_when_render_loop_is_gone_entirely() {
let (tui_tx, tui_rx) = mpsc::sync_channel(8);
drop(tui_rx); let mut p = TuiPrompter::new(tui_tx);
let decision = p.decide(&danger_request("anything"));
assert_eq!(
decision,
PermissionPromptDecision::Deny {
reason: "could not read user input".to_string(),
}
);
}
#[test]
fn authorize_consults_tui_prompter_for_danger_tools() {
let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
.with_tool_requirement("bash", PermissionMode::DangerFullAccess);
let (tui_tx, tui_rx) = mpsc::sync_channel(8);
let stub = spawn_render_stub(tui_rx, Some(true));
let mut p = TuiPrompter::new(tui_tx);
let outcome = policy.authorize("bash", "cargo test", Some(&mut p));
assert_eq!(outcome, PermissionOutcome::Allow);
stub.join().unwrap();
let (tui_tx, tui_rx) = mpsc::sync_channel(8);
let stub = spawn_render_stub(tui_rx, Some(false));
let mut p = TuiPrompter::new(tui_tx);
let outcome = policy.authorize("bash", "cargo test", Some(&mut p));
assert_eq!(
outcome,
PermissionOutcome::Deny {
reason: "user denied permission".to_string(),
}
);
stub.join().unwrap();
}
#[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 🤖");
}
}