use crate::command::chat::agent::config::{AgentLoopConfig, AgentLoopSharedState};
use crate::command::chat::app::AskRequest;
use crate::command::chat::app::MainAgentHandle;
use crate::command::chat::app::build_system_prompt_fn;
use crate::command::chat::app::types::{PlanDecision, StreamMsg, ToolResultMsg};
use crate::command::chat::constants::{THINKING_PULSE_MIN_FACTOR, THINKING_PULSE_PERIOD_MS};
use crate::command::chat::context::compact::new_invoked_skills_map;
use crate::command::chat::context::window::select_messages;
use crate::command::chat::handler::run_chat_tui;
use crate::command::chat::infra::hook::{HookContext, HookEvent, HookManager};
use crate::command::chat::infra::skill;
use crate::command::chat::permission::{JcliConfig, generate_allow_rule};
use crate::command::chat::storage::config::ThinkingStyle;
use crate::command::chat::storage::{
AgentConfig, ChatMessage, MessageRole, ModelProvider, SessionEvent, ToolCallItem,
append_session_event, find_latest_session_id, load_agent_config, load_session,
};
use crate::command::chat::tools::ToolRegistry;
use crate::command::chat::tools::background::BackgroundManager;
use crate::command::chat::tools::classification::{ToolCategory, get_result_summary_for_tool};
use crate::command::chat::tools::task::TaskManager;
use crate::command::chat::tools::todo::TodoManager;
use crate::config::YamlConfig;
use crate::theme::Theme;
use crate::{error, info};
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
const TOOL_ARG_PREVIEW_MAX_CHARS: usize = 60;
fn extract_tool_desc(tool_name: &str, arguments: &str) -> Option<String> {
let parsed = serde_json::from_str::<serde_json::Value>(arguments).ok()?;
match tool_name {
"Bash" | "Shell" => parsed.get("description")?.as_str().map(|s| s.to_string()),
"Read" | "Write" | "Edit" | "Glob" | "Grep" => parsed
.get("path")
.or_else(|| parsed.get("file_path"))
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
"Agent" | "Teammate" => parsed
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
"Ask" => parsed
.get("header")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
_ => None,
}
}
fn extract_bash_command(arguments: &str) -> Option<String> {
let parsed = serde_json::from_str::<serde_json::Value>(arguments).ok()?;
parsed
.get("command")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn make_args_preview(arguments: &str) -> String {
let total_len = arguments.chars().count();
if total_len <= TOOL_ARG_PREVIEW_MAX_CHARS {
return arguments.to_string();
}
let closing_bracket = arguments.chars().next().and_then(|c| match c {
'{' => Some('}'),
'[' => Some(']'),
_ => None,
});
let preview_len = if closing_bracket.is_some() {
TOOL_ARG_PREVIEW_MAX_CHARS - 4
} else {
TOOL_ARG_PREVIEW_MAX_CHARS
};
let preview: String = arguments.chars().take(preview_len).collect();
if let Some(bracket) = closing_bracket {
format!("{}...{}", preview, bracket)
} else {
format!("{}…", preview)
}
}
fn term_width() -> usize {
crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80)
}
fn box_width() -> usize {
term_width().saturating_sub(4).clamp(20, 56)
}
fn color_rgb(color: ratatui::style::Color) -> (u8, u8, u8) {
use ratatui::style::Color;
match color {
Color::Rgb(r, g, b) => (r, g, b),
Color::Blue => (0, 0, 255),
Color::Cyan => (0, 255, 255),
Color::Green => (0, 255, 0),
Color::Yellow => (255, 255, 0),
Color::Red => (255, 0, 0),
Color::Magenta => (255, 0, 255),
Color::White => (255, 255, 255),
Color::DarkGray => (169, 169, 169),
Color::LightBlue => (173, 216, 230),
Color::LightCyan => (224, 255, 255),
Color::LightGreen => (144, 238, 144),
Color::LightYellow => (255, 255, 224),
Color::LightRed => (255, 160, 122),
Color::LightMagenta => (255, 182, 193),
_ => (200, 200, 200),
}
}
fn thinking_pulse_color_rgb() -> (u8, u8, u8) {
use std::time::{SystemTime, UNIX_EPOCH};
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let period = THINKING_PULSE_PERIOD_MS as f64;
let phase = (millis % period as u128) as f64 / period;
let t = (phase * std::f64::consts::TAU).sin() * 0.5 + 0.5;
let theme = Theme::terminal();
if let ratatui::style::Color::Rgb(r, g, b) = theme.label_ai {
let min_factor = THINKING_PULSE_MIN_FACTOR;
let factor = min_factor + (1.0 - min_factor) * t;
(
(r as f64 * factor).round().min(255.0) as u8,
(g as f64 * factor).round().min(255.0) as u8,
(b as f64 * factor).round().min(255.0) as u8,
)
} else {
(120, 220, 160) }
}
fn print_tool_call_line(tool_name: &str, arguments: &str) {
use colored::Colorize;
let category = ToolCategory::from_name(tool_name);
let icon = category.icon();
let theme = Theme::terminal();
let (tr, tg, tb) = color_rgb(category.color(&theme));
let desc = if let Some(d) = extract_tool_desc(tool_name, arguments) {
d
} else if !arguments.is_empty() {
make_args_preview(arguments)
} else {
String::new()
};
let desc_colored = if desc.is_empty() {
String::new()
} else {
format!(" {}", desc.dimmed())
};
eprintln!(
" {} {} {}",
icon,
tool_name.truecolor(tr, tg, tb).bold(),
desc_colored
);
}
fn print_tool_result_line(tool_name: &str, is_error: bool, summary: &str, elapsed: &str) {
use colored::Colorize;
let category = ToolCategory::from_name(tool_name);
let theme = Theme::terminal();
let (tr, tg, tb) = color_rgb(category.color(&theme));
let status_icon = if is_error { "✗" } else { "✓" };
let status_style = if is_error { "red" } else { "green" };
eprintln!(
" 🔧 {} {}{} {}",
tool_name.truecolor(tr, tg, tb).bold(),
status_icon.color(status_style),
summary.dimmed(),
elapsed.dimmed(),
);
}
fn start_thinking_animation(thinking_style: ThinkingStyle) -> Arc<AtomicBool> {
let stop = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop);
std::thread::spawn(move || {
use colored::Colorize;
use crossterm::{cursor, execute, terminal};
let mut tick: u64 = 0;
let mut stdout = io::stdout();
while !stop_clone.load(Ordering::Relaxed) {
let (r, g, b) = thinking_pulse_color_rgb();
let frame = thinking_style.frame(tick);
let line = format!(" {} 思考中...", frame);
let colored_line = line.truecolor(r, g, b).bold().to_string();
let _ = execute!(
stdout,
cursor::MoveToColumn(0),
terminal::Clear(terminal::ClearType::CurrentLine),
crossterm::style::Print(&colored_line),
);
let _ = stdout.flush();
tick += 1;
std::thread::sleep(Duration::from_millis(100));
}
let _ = execute!(
stdout,
cursor::MoveToColumn(0),
terminal::Clear(terminal::ClearType::CurrentLine),
);
let _ = stdout.flush();
});
stop
}
fn stop_thinking_animation(stop_flag: &Arc<AtomicBool>) {
stop_flag.store(true, Ordering::Relaxed);
std::thread::sleep(Duration::from_millis(50));
}
fn generate_oneshot_session_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_micros();
let pid = std::process::id();
format!("{:x}-{:x}", ts, pid)
}
fn persist_messages(session_id: &str, messages: &[ChatMessage], start_idx: usize) {
for msg in messages.iter().skip(start_idx) {
append_session_event(session_id, &SessionEvent::msg(msg.clone()));
}
}
pub fn handle_chat(
content: &[String],
cont: bool,
session_id_opt: Option<&str>,
remote: bool,
port: u16,
bypass: bool,
_config: &YamlConfig,
) {
let agent_config = load_agent_config();
if remote
|| content.is_empty() && !cont && session_id_opt.is_none()
|| agent_config.providers.is_empty()
{
run_chat_tui(remote, port);
return;
}
let message = content.join(" ");
let message = message.trim().to_string();
if message.is_empty() && !cont && session_id_opt.is_none() {
error!("消息内容为空");
return;
}
if message.is_empty() {
error!("消息内容为空(--continue / --session 需要附带消息内容)");
return;
}
let session_id = if let Some(id) = session_id_opt {
id.to_string()
} else if cont {
find_latest_session_id().unwrap_or_else(generate_oneshot_session_id)
} else {
generate_oneshot_session_id()
};
let prior_messages = if cont || session_id_opt.is_some() {
let loaded = load_session(&session_id).messages;
if !loaded.is_empty() {
info!("延续会话 {} ({} 条历史消息)", session_id, loaded.len());
} else if session_id_opt.is_some() {
info!("会话 {} 不存在或为空,开始新对话", session_id);
}
loaded
} else {
vec![]
};
let idx = agent_config
.active_index
.min(agent_config.providers.len() - 1);
let provider = &agent_config.providers[idx];
if agent_config.tools_enabled {
run_oneshot_agent(
provider,
&agent_config,
message,
prior_messages,
&session_id,
bypass,
);
} else {
run_oneshot_no_tools(
provider,
&agent_config,
message,
prior_messages,
&session_id,
);
}
}
fn run_oneshot_no_tools(
provider: &ModelProvider,
agent_config: &AgentConfig,
message: String,
prior_messages: Vec<ChatMessage>,
session_id: &str,
) {
use crate::command::chat::agent::api::call_llm_stream;
use colored::Colorize;
let user_msg = ChatMessage::text(MessageRole::User, message.clone());
let mut messages = prior_messages.clone();
messages.push(user_msg.clone());
let thinking_style = agent_config.thinking_style;
let _stop_anim = start_thinking_animation(thinking_style);
let tw = term_width();
let mut cur_col: usize = 0;
let mut raw_lines: usize = 0;
let interrupted = Arc::new(AtomicBool::new(false));
let interrupted2 = Arc::clone(&interrupted);
let _ = ctrlc::set_handler(move || {
interrupted2.store(true, Ordering::Relaxed);
});
let send_messages = select_messages(
&messages,
agent_config.max_history_messages,
agent_config.max_context_tokens,
agent_config.compact.keep_recent,
&agent_config.compact.micro_compact_exempt_tools,
);
match call_llm_stream(
provider,
&send_messages,
crate::command::chat::storage::load_system_prompt().as_deref(),
&mut |chunk| {
if interrupted.load(Ordering::Relaxed) {
return;
}
print!("{}", chunk);
let _ = io::stdout().flush();
for ch in chunk.chars() {
if ch == '\n' {
raw_lines += 1;
cur_col = 0;
} else {
cur_col += 1;
if cur_col >= tw {
raw_lines += 1;
cur_col = 0;
}
}
}
},
) {
Ok(full_text) => {
if !full_text.is_empty() {
redraw_markdown(raw_lines, cur_col, &full_text);
persist_messages(session_id, &[user_msg], 0);
persist_messages(
session_id,
&[ChatMessage::text(MessageRole::Assistant, &full_text)],
0,
);
eprintln!("{} {}", "会话 ID:".dimmed(), session_id.dimmed());
}
}
Err(e) => {
error!("\n{}", e.display_message());
}
}
}
fn redraw_markdown(raw_lines: usize, cur_col: usize, text: &str) {
use crossterm::{cursor, execute, terminal};
let total_raw_lines = if cur_col > 0 {
raw_lines + 1
} else {
raw_lines
};
let mut stdout = io::stdout();
if total_raw_lines > 0 {
let _ = execute!(stdout, cursor::MoveToColumn(0));
if total_raw_lines > 1 {
let _ = execute!(stdout, cursor::MoveUp((total_raw_lines - 1) as u16));
}
let _ = execute!(stdout, terminal::Clear(terminal::ClearType::FromCursorDown));
}
crate::util::md_render::render_md(text);
}
fn interactive_confirm(
tool_name: &str,
arguments: &str,
options: &[&str],
initial: usize,
) -> Option<usize> {
use colored::Colorize;
use crossterm::event::{self, Event, KeyCode};
use crossterm::{cursor, execute, terminal};
let bw = box_width();
let category = ToolCategory::from_name(tool_name);
let icon = category.icon();
let mut stdout = io::stdout();
let mut cursor_pos = initial;
let desc = if tool_name == "Bash" || tool_name == "Shell" {
if let Some(cmd) = extract_bash_command(arguments) {
format!("$ {}", cmd)
} else {
make_args_preview(arguments)
}
} else {
make_args_preview(arguments)
};
let desc_display = if desc.len() > bw.saturating_sub(8) {
format!("{}...", &desc[..bw.saturating_sub(11)])
} else {
desc.clone()
};
let total_lines = (options.len() + 6) as u16;
let border = "─".yellow();
let vbar = "│".yellow();
let draw = |stdout: &mut io::Stdout, cursor_pos: usize, first: bool| -> io::Result<()> {
if !first {
let _ = execute!(stdout, cursor::MoveUp(total_lines));
}
let _ = execute!(stdout, terminal::Clear(terminal::ClearType::FromCursorDown));
let title = format!("{} {} 需要确认", icon, tool_name);
let title_len = title.chars().count() + 2;
let inner_w = bw.saturating_sub(2);
let dash_fill = inner_w.saturating_sub(title_len + 1);
writeln!(
stdout,
"{}\r",
format_args!(
" {}{} {} {}{}",
"┌".yellow().bold(),
border,
title.white().bold(),
border,
format!("{}{}", "─".repeat(dash_fill), "┐").yellow().bold(),
)
)?;
writeln!(stdout, " {} {}\r", vbar, desc_display.white())?;
writeln!(stdout, " {}\r", vbar)?;
for (i, opt) in options.iter().enumerate() {
if cursor_pos == i {
writeln!(
stdout,
" {} {} {}\r",
vbar,
"❯".cyan().bold(),
opt.white().bold()
)?;
} else {
writeln!(stdout, " {} {}\r", vbar, opt.dimmed())?;
}
}
writeln!(stdout, " {}\r", vbar)?;
writeln!(stdout, " {} {}\r", vbar, "• ↑↓ 移动 Enter 确认".dimmed())?;
writeln!(
stdout,
" {}{}{}\r",
"└".yellow().bold(),
"─".repeat(bw.saturating_sub(3)).yellow(),
"┘".yellow().bold(),
)?;
stdout.flush()?;
Ok(())
};
if terminal::enable_raw_mode().is_err() {
return None;
}
let _ = draw(&mut stdout, cursor_pos, true);
let result = loop {
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
cursor_pos = cursor_pos.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if cursor_pos + 1 < options.len() {
cursor_pos += 1;
}
}
KeyCode::Enter => break Some(cursor_pos),
KeyCode::Esc | KeyCode::Char('q') => break None,
_ => continue,
}
let _ = draw(&mut stdout, cursor_pos, false);
}
};
let _ = terminal::disable_raw_mode();
{
let _ = execute!(stdout, cursor::MoveUp(total_lines));
let _ = execute!(stdout, terminal::Clear(terminal::ClearType::FromCursorDown));
}
result
}
fn redraw_streaming_as_markdown(
streaming_content: &Arc<Mutex<String>>,
raw_lines: &mut usize,
cur_col: &mut usize,
) {
let content = streaming_content.lock().unwrap();
if content.is_empty() {
return;
}
let tw = term_width();
let mut rl: usize = 0;
let mut cc: usize = 0;
for ch in content.chars() {
if ch == '\n' {
rl += 1;
cc = 0;
} else {
cc += 1;
if cc >= tw {
rl += 1;
cc = 0;
}
}
}
*raw_lines = rl;
*cur_col = cc;
redraw_markdown(*raw_lines, *cur_col, &content);
}
fn run_oneshot_agent(
provider: &ModelProvider,
agent_config: &AgentConfig,
message: String,
prior_messages: Vec<ChatMessage>,
session_id: &str,
bypass: bool,
) {
use colored::Colorize;
let thinking_style = agent_config.thinking_style;
let hook_manager_loaded = HookManager::load();
let hook_manager_for_end = hook_manager_loaded.clone();
let disabled_hooks: Vec<String> = vec![];
{
if hook_manager_loaded.has_hooks_for(HookEvent::SessionStart) {
let ctx = HookContext {
event: HookEvent::SessionStart,
messages: Some(prior_messages.clone()),
model: Some(provider.model.clone()),
session_id: Some(session_id.to_string()),
..Default::default()
};
hook_manager_loaded.execute(HookEvent::SessionStart, ctx, &disabled_hooks);
}
}
let (ask_tx, ask_rx) = std::sync::mpsc::channel::<AskRequest>();
let background_manager = Arc::new(BackgroundManager::new());
let task_manager = Arc::new(TaskManager::new_with_session(session_id));
let todo_manager = Arc::new(TodoManager::new());
let hook_manager_for_registry = hook_manager_loaded.clone();
let invoked_skills = new_invoked_skills_map();
let tool_registry = Arc::new(ToolRegistry::new(
vec![],
ask_tx,
Arc::clone(&background_manager),
Arc::clone(&task_manager),
Arc::new(Mutex::new(hook_manager_for_registry)),
invoked_skills.clone(),
crate::command::chat::storage::SessionPaths::new(session_id).todos_file(),
));
std::thread::spawn(move || {
use colored::Colorize;
use crossterm::event::{self, Event, KeyCode};
use crossterm::{cursor, execute, terminal};
while let Ok(req) = ask_rx.recv() {
let mut answers = serde_json::Map::new();
for q in &req.questions {
let bw = box_width();
let title = if !q.header.is_empty() {
q.header.clone()
} else {
"选择".to_string()
};
let question_lines: Vec<String> = if q.question.is_empty() {
vec![]
} else {
let max_chars = bw.saturating_sub(6).max(1);
let chars: Vec<char> = q.question.chars().collect();
let mut lines = vec![];
let mut start = 0;
while start < chars.len() {
let end = (start + max_chars).min(chars.len());
let line: String = chars[start..end].iter().collect();
lines.push(line);
start = end;
}
lines
};
if q.multi_select {
let mut selected = vec![false; q.options.len()];
let mut cursor_pos: usize = 0;
let total_lines =
(1 + question_lines.len() + 1 + q.options.len() * 2 + 1 + 1 + 1) as u16;
let draw_multi = |stdout: &mut io::Stdout,
cursor_pos: usize,
selected: &[bool],
first: bool|
-> io::Result<()> {
if !first {
let _ = execute!(stdout, cursor::MoveUp(total_lines));
}
let _ =
execute!(stdout, terminal::Clear(terminal::ClearType::FromCursorDown));
let title_text = format!(" {} ", title);
let inner_w = bw.saturating_sub(2);
let dash_fill = inner_w.saturating_sub(title_text.chars().count() + 2);
let dash_tail = "─".repeat(dash_fill);
writeln!(
stdout,
" {}{}{}{}{}\r",
"┌".yellow().bold(),
"─".yellow(),
title_text.white().bold(),
"─".yellow(),
format!("{}{}", dash_tail, "┐").yellow().bold(),
)?;
for line in &question_lines {
writeln!(stdout, " {} {}\r", "│".yellow(), line.white())?;
}
writeln!(stdout, " {}\r", "│".yellow())?;
for (i, opt) in q.options.iter().enumerate() {
let pointer = if cursor_pos == i { "❯" } else { " " };
let check = if selected[i] { "◉" } else { "○" };
if cursor_pos == i {
writeln!(
stdout,
" {} {} {} {}\r",
"│".yellow(),
pointer.cyan().bold(),
check.cyan().bold(),
opt.label.cyan().bold()
)?;
} else {
writeln!(
stdout,
" {} {} {} {}\r",
"│".yellow(),
pointer,
check.white(),
opt.label.white()
)?;
}
writeln!(
stdout,
" {} {}\r",
"│".yellow(),
opt.description.dimmed()
)?;
}
writeln!(stdout, " {}\r", "│".yellow())?;
writeln!(
stdout,
" {} {}\r",
"│".yellow(),
"• ↑↓ 移动 Space 切换 Enter 确认".dimmed()
)?;
writeln!(
stdout,
" {}{}{}\r",
"└".yellow().bold(),
"─".repeat(bw.saturating_sub(3)).yellow(),
"┘".yellow().bold(),
)?;
stdout.flush()?;
Ok(())
};
let _ = terminal::enable_raw_mode();
let mut stdout = io::stdout();
let _ = draw_multi(&mut stdout, cursor_pos, &selected, true);
loop {
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
cursor_pos = cursor_pos.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if cursor_pos + 1 < q.options.len() {
cursor_pos += 1;
}
}
KeyCode::Char(' ') => {
selected[cursor_pos] = !selected[cursor_pos];
}
KeyCode::Enter => break,
KeyCode::Esc => break,
_ => continue,
}
let _ = draw_multi(&mut stdout, cursor_pos, &selected, false);
}
}
let _ = terminal::disable_raw_mode();
let _ = execute!(stdout, cursor::MoveUp(total_lines));
let _ = execute!(stdout, terminal::Clear(terminal::ClearType::FromCursorDown));
let result: Vec<String> = q
.options
.iter()
.zip(selected.iter())
.filter(|(_, s)| **s)
.map(|(o, _)| o.label.clone())
.collect();
let answer = if result.is_empty() {
"(无选择)".to_string()
} else {
result.join(", ")
};
println!(" → {}", answer.green());
answers.insert(q.header.clone(), serde_json::Value::String(answer));
} else {
let mut cursor_pos: usize = 0;
let total_lines =
(1 + question_lines.len() + 1 + q.options.len() * 2 + 1 + 1 + 1) as u16;
let draw_single = |stdout: &mut io::Stdout,
cursor_pos: usize,
first: bool|
-> io::Result<()> {
if !first {
let _ = execute!(stdout, cursor::MoveUp(total_lines));
}
let _ =
execute!(stdout, terminal::Clear(terminal::ClearType::FromCursorDown));
let title_text = format!(" {} ", title);
let inner_w = bw.saturating_sub(2);
let dash_fill = inner_w.saturating_sub(title_text.chars().count() + 2);
let dash_tail = "─".repeat(dash_fill);
writeln!(
stdout,
" {}{}{}{}{}\r",
"┌".yellow().bold(),
"─".yellow(),
title_text.white().bold(),
"─".yellow(),
format!("{}{}", dash_tail, "┐").yellow().bold(),
)?;
for line in &question_lines {
writeln!(stdout, " {} {}\r", "│".yellow(), line.white())?;
}
writeln!(stdout, " {}\r", "│".yellow())?;
for (i, opt) in q.options.iter().enumerate() {
let pointer = if cursor_pos == i { "❯" } else { " " };
if cursor_pos == i {
writeln!(
stdout,
" {} {} {}\r",
"│".yellow(),
pointer.cyan().bold(),
opt.label.cyan().bold()
)?;
} else {
writeln!(
stdout,
" {} {} {}\r",
"│".yellow(),
pointer,
opt.label.white()
)?;
}
writeln!(
stdout,
" {} {}\r",
"│".yellow(),
opt.description.dimmed()
)?;
}
writeln!(stdout, " {}\r", "│".yellow())?;
writeln!(
stdout,
" {} {}\r",
"│".yellow(),
"• ↑↓ 移动 Enter 确认".dimmed()
)?;
writeln!(
stdout,
" {}{}{}\r",
"└".yellow().bold(),
"─".repeat(bw.saturating_sub(3)).yellow(),
"┘".yellow().bold(),
)?;
stdout.flush()?;
Ok(())
};
let _ = terminal::enable_raw_mode();
let mut stdout = io::stdout();
let _ = draw_single(&mut stdout, cursor_pos, true);
loop {
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
cursor_pos = cursor_pos.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if cursor_pos + 1 < q.options.len() {
cursor_pos += 1;
}
}
KeyCode::Enter => break,
KeyCode::Esc => break,
_ => continue,
}
let _ = draw_single(&mut stdout, cursor_pos, false);
}
}
let _ = terminal::disable_raw_mode();
let _ = execute!(stdout, cursor::MoveUp(total_lines));
let _ = execute!(stdout, terminal::Clear(terminal::ClearType::FromCursorDown));
let answer = q
.options
.get(cursor_pos)
.map(|o| o.label.clone())
.unwrap_or_default();
println!(" → {}", answer.green());
answers.insert(q.header.clone(), serde_json::Value::String(answer));
}
}
let response = serde_json::to_string(&serde_json::json!({ "answers": answers }))
.unwrap_or_default();
let _ = req.response_tx.send(response);
}
});
let user_msg = ChatMessage::text(MessageRole::User, &message);
let prior_len = prior_messages.len();
let mut messages = prior_messages;
messages.push(user_msg);
let tools = tool_registry.to_llm_tools_filtered(&agent_config.disabled_tools);
let loaded_skills = skill::load_all_skills();
let system_prompt_fn = build_system_prompt_fn(
loaded_skills,
agent_config.disabled_skills.clone(),
agent_config.disabled_tools.clone(),
Arc::clone(&tool_registry),
);
let api_messages = select_messages(
&messages,
agent_config.max_history_messages,
agent_config.max_context_tokens,
agent_config.compact.keep_recent,
&agent_config.compact.micro_compact_exempt_tools,
);
let cancel_token = tokio_util::sync::CancellationToken::new();
let streaming_content: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let streaming_reasoning_content: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let pending_user_messages: Arc<Mutex<Vec<ChatMessage>>> = Arc::new(Mutex::new(vec![]));
let display_messages: Arc<Mutex<Vec<ChatMessage>>> = Arc::new(Mutex::new(vec![]));
let context_messages: Arc<Mutex<Vec<ChatMessage>>> = Arc::new(Mutex::new(vec![]));
let estimated_context_tokens: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
let derived_system_prompt: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let agent_config_struct = AgentLoopConfig {
provider: provider.clone(),
max_llm_rounds: agent_config.max_tool_rounds,
compact_config: agent_config.compact.clone(),
hook_manager: hook_manager_loaded,
disabled_hooks: agent_config.disabled_hooks.clone(),
cancel_token: cancel_token.clone(),
};
let agent_shared = AgentLoopSharedState {
streaming_content: Arc::clone(&streaming_content),
streaming_reasoning_content: Arc::clone(&streaming_reasoning_content),
pending_user_messages,
background_manager,
todo_manager,
display_messages: Arc::clone(&display_messages),
context_messages: Arc::clone(&context_messages),
estimated_context_tokens,
invoked_skills,
session_id: session_id.to_string(),
derived_system_prompt,
};
let cancel_for_ctrlc = cancel_token.clone();
let _ = ctrlc::set_handler(move || {
cancel_for_ctrlc.cancel();
});
let (handle, tool_result_tx) = MainAgentHandle::spawn(
agent_config_struct,
agent_shared,
api_messages,
tools,
system_prompt_fn,
);
let tool_result_tx: std::sync::mpsc::SyncSender<ToolResultMsg> = tool_result_tx;
let anim_stop = start_thinking_animation(thinking_style);
let mut anim_running = true;
let mut last_streaming_len: usize = 0;
let mut raw_lines: usize = 0;
let mut cur_col: usize = 0;
let tw = term_width();
let jcli_config = JcliConfig::load();
let cancelled = Arc::new(AtomicBool::new(false));
let mut round: usize = 0;
let mut first_content = true;
loop {
let msgs = handle.poll();
if msgs.is_empty() {
std::thread::sleep(Duration::from_millis(30));
continue;
}
for msg in msgs {
match msg {
StreamMsg::Chunk => {
let content = streaming_content.lock().unwrap();
if content.len() > last_streaming_len {
if anim_running {
stop_thinking_animation(&anim_stop);
anim_running = false;
}
if first_content {
let theme = Theme::terminal();
let (lr, lg, lb) = color_rgb(theme.label_ai);
eprintln!(" {}", "Sprite".truecolor(lr, lg, lb).bold());
first_content = false;
}
let delta = &content[last_streaming_len..];
print!("{}", delta);
let _ = io::stdout().flush();
for ch in delta.chars() {
if ch == '\n' {
raw_lines += 1;
cur_col = 0;
} else {
cur_col += 1;
if cur_col >= tw {
raw_lines += 1;
cur_col = 0;
}
}
}
last_streaming_len = content.len();
}
}
StreamMsg::ToolCallRequest(items) => {
if anim_running {
stop_thinking_animation(&anim_stop);
anim_running = false;
}
if last_streaming_len > 0 {
redraw_streaming_as_markdown(
&streaming_content,
&mut raw_lines,
&mut cur_col,
);
last_streaming_len = streaming_content.lock().unwrap().len();
}
round += 1;
eprintln!();
eprintln!(" {} R{} · {} 工具", "⚙".dimmed(), round, items.len());
for item in items.iter() {
let tool_result = handle_tool_call(
item,
tool_registry.as_ref(),
&jcli_config,
&cancelled,
bypass,
);
let _ = tool_result_tx.send(tool_result);
}
first_content = true;
}
StreamMsg::Done => {
if anim_running {
stop_thinking_animation(&anim_stop);
}
if last_streaming_len > 0 {
redraw_streaming_as_markdown(
&streaming_content,
&mut raw_lines,
&mut cur_col,
);
}
let ctx_msgs = context_messages.lock().unwrap();
let persist_from = if prior_len < ctx_msgs.len() {
prior_len
} else {
0
};
persist_messages(session_id, &ctx_msgs, persist_from);
if round > 0 {
eprintln!();
}
eprintln!("{} {}", "会话 ID:".dimmed(), session_id.dimmed());
fire_session_end(
&hook_manager_for_end,
&disabled_hooks,
&ctx_msgs,
session_id,
&provider.model,
);
return;
}
StreamMsg::Error(e) => {
if anim_running {
stop_thinking_animation(&anim_stop);
}
error!("\n{}", e.display_message());
let ctx_msgs = context_messages.lock().unwrap();
let persist_from = if prior_len < ctx_msgs.len() {
prior_len
} else {
0
};
persist_messages(session_id, &ctx_msgs, persist_from);
fire_session_end(
&hook_manager_for_end,
&disabled_hooks,
&ctx_msgs,
session_id,
&provider.model,
);
return;
}
StreamMsg::Cancelled => {
if anim_running {
stop_thinking_animation(&anim_stop);
}
println!();
let ctx_msgs = context_messages.lock().unwrap();
let persist_from = if prior_len < ctx_msgs.len() {
prior_len
} else {
0
};
persist_messages(session_id, &ctx_msgs, persist_from);
eprintln!("\n {}", "⏹ 已中断".dimmed());
eprintln!(" {} {}", "会话 ID:".dimmed(), session_id.dimmed());
fire_session_end(
&hook_manager_for_end,
&disabled_hooks,
&ctx_msgs,
session_id,
&provider.model,
);
return;
}
StreamMsg::Retrying {
attempt,
max_attempts,
delay_ms,
error,
} => {
if anim_running {
stop_thinking_animation(&anim_stop);
anim_running = false;
}
eprintln!(
" {} 重试中 ({}/{}, {}ms) — {}",
"⟳".yellow(),
attempt,
max_attempts,
delay_ms,
error.dimmed()
);
}
StreamMsg::Compacting => {
eprintln!(" {} 压缩上下文中...", "📦".dimmed());
}
StreamMsg::Compacted { messages_before } => {
eprintln!(" {} 已压缩 {} 条消息", "📦".dimmed(), messages_before);
}
}
}
}
}
fn handle_tool_call(
item: &ToolCallItem,
tool_registry: &ToolRegistry,
jcli_config: &JcliConfig,
cancelled: &Arc<AtomicBool>,
bypass: bool,
) -> ToolResultMsg {
use colored::Colorize;
if jcli_config.is_denied(&item.name, &item.arguments) {
eprintln!(
" {} {} — {}",
"✗".red(),
item.name.red().bold(),
"被权限规则拒绝".red()
);
return ToolResultMsg {
tool_call_id: item.id.clone(),
result: "工具调用被拒绝(deny 规则匹配)".to_string(),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
let needs_confirm = tool_registry
.get(&item.name)
.map(|t| t.requires_confirmation())
.unwrap_or(false)
&& !jcli_config.is_allowed(&item.name, &item.arguments);
if needs_confirm && !bypass {
print_tool_call_line(&item.name, &item.arguments);
let allow_rule = generate_allow_rule(&item.name, &item.arguments);
let options = ["允许执行", "拒绝", &format!("始终允许 ({})", allow_rule)];
let choice = interactive_confirm(&item.name, &item.arguments, &options, 0);
match choice {
Some(0) => {}
Some(2) => {
}
_ => {
eprintln!(
" {} {} — {}",
"⏭".dimmed(),
item.name.dimmed(),
"已跳过".dimmed()
);
return ToolResultMsg {
tool_call_id: item.id.clone(),
result: "用户拒绝执行该工具".to_string(),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
}
} else {
print_tool_call_line(&item.name, &item.arguments);
}
let start = std::time::Instant::now();
let result = tool_registry.execute(&item.name, &item.arguments, cancelled);
let elapsed = start.elapsed();
let elapsed_str = format_duration(elapsed);
let summary = get_result_summary_for_tool(
&result.output,
result.is_error,
&item.name,
Some(&item.arguments),
);
print_tool_result_line(&item.name, result.is_error, &summary, &elapsed_str);
ToolResultMsg {
tool_call_id: item.id.clone(),
result: result.output,
is_error: result.is_error,
images: vec![],
plan_decision: PlanDecision::None,
}
}
fn format_duration(d: std::time::Duration) -> String {
let ms = d.as_millis();
if ms < 1000 {
format!("{}ms", ms)
} else {
format!("{:.1}s", d.as_secs_f64())
}
}
fn fire_session_end(
hook_manager: &HookManager,
disabled_hooks: &[String],
messages: &[ChatMessage],
session_id: &str,
model: &str,
) {
if hook_manager.has_hooks_for(HookEvent::SessionEnd) {
let ctx = HookContext {
event: HookEvent::SessionEnd,
messages: Some(messages.to_vec()),
model: Some(model.to_string()),
session_id: Some(session_id.to_string()),
..Default::default()
};
hook_manager.execute(HookEvent::SessionEnd, ctx, disabled_hooks);
}
}