mod update;
mod update_config;
mod update_misc;
mod update_session;
mod update_tool_interact;
use super::agent_handle::MainAgentHandle;
use super::chat_state::ChatState;
use super::tool_executor::ToolExecutor;
use super::types::AskRequest;
use super::ui_state::{ChatMode, ConfigTab, UIState};
use crate::command::chat::agent_md;
use crate::command::chat::constants::TODO_NAG_INTERVAL_ROUNDS;
use crate::command::chat::context::message_compress::{
DEFAULT_OTHER_AGENT_TOOLCALL_THRESHOLD, compress_other_agent_toolcalls,
};
use crate::command::chat::infra::command;
use crate::command::chat::infra::hook::{HookContext, HookEvent, HookManager, HookResult};
use crate::command::chat::infra::sandbox::Sandbox;
use crate::command::chat::infra::skill;
use crate::command::chat::markdown::image_cache::ImageCache;
use crate::command::chat::permission::JcliConfig;
use crate::command::chat::permission::queue::PermissionQueue;
use crate::command::chat::remote::protocol::WsOutbound;
use crate::command::chat::storage::MessageRole;
use crate::command::chat::storage::{
ChatMessage, ModelProvider, load_agent_config, memory_path, save_agent_config, save_memory,
save_soul, save_system_prompt, soul_path, system_prompt_path,
};
use crate::command::chat::teammate::TeammateManager;
use crate::command::chat::tools::ToolRegistry;
use crate::command::chat::tools::background::{BackgroundManager, build_running_summary};
use crate::command::chat::tools::derived_shared::{
AgentContextConfig, DerivedAgentShared, SubAgentTracker,
};
use crate::command::chat::tools::plan::PlanApprovalQueue;
use crate::command::chat::tools::task::{TaskManager, build_tasks_summary};
use crate::command::chat::tools::todo::TodoManager;
use crate::constants::{CONFIG_FIELDS, TOAST_DURATION_SECS};
use crate::theme::Theme;
use crate::tui::editor_core::text_buffer::TextBuffer;
use crate::util::safe_lock;
use ratatui::widgets::ListState;
use std::sync::{Arc, Mutex, mpsc};
pub struct ChatApp {
pub ui: UIState,
pub state: ChatState,
pub tool_executor: ToolExecutor,
pub main_agent: Option<MainAgentHandle>,
pub tool_registry: Arc<ToolRegistry>,
pub jcli_config: Arc<JcliConfig>,
pub background_manager: Arc<BackgroundManager>,
#[allow(dead_code)]
pub task_manager: Arc<TaskManager>,
pub todo_manager: Arc<TodoManager>,
pub ask_response_tx: Option<mpsc::Sender<String>>,
pub ask_request_rx: Option<mpsc::Receiver<AskRequest>>,
pub hook_manager: Arc<Mutex<HookManager>>,
pub sandbox: Sandbox,
pub session_id: String,
pub shared_session_id: Arc<Mutex<String>>,
pub persisted_message_count: usize,
pub persisted_display_count: usize,
pub ws_bridge: Option<crate::command::chat::remote::bridge::WsBridge>,
pub remote_connected: bool,
pub derived_agent_provider: Arc<Mutex<ModelProvider>>,
pub derived_agent_system_prompt: Arc<Mutex<Option<String>>>,
pub derived_agent_context_config: Arc<Mutex<AgentContextConfig>>,
pub derived_agent_disabled_hooks: Arc<Mutex<Vec<String>>>,
pub display_messages: Arc<Mutex<Vec<ChatMessage>>>,
pub display_read_offset: usize,
pub context_messages: Arc<Mutex<Vec<ChatMessage>>>,
pub context_read_offset: usize,
pub context_tokens: Arc<Mutex<usize>>,
#[allow(dead_code)]
pub teammate_manager: Arc<Mutex<TeammateManager>>,
pub sub_agent_tracker: Arc<SubAgentTracker>,
pub permission_queue: Arc<PermissionQueue>,
pub plan_approval_queue: Arc<PlanApprovalQueue>,
pub invoked_skills: crate::command::chat::context::compact::InvokedSkillsMap,
}
pub fn config_tab_field_count(app: &ChatApp) -> usize {
use crate::constants::CONFIG_GLOBAL_FIELDS_TAB;
match app.ui.config_tab {
ConfigTab::Model => CONFIG_FIELDS.len(),
ConfigTab::Global => CONFIG_GLOBAL_FIELDS_TAB.len(),
ConfigTab::Tools => app.tool_registry.tool_names().len(),
ConfigTab::Skills => app.state.loaded_skills.len(),
ConfigTab::Commands => app.state.loaded_commands.len(),
ConfigTab::Hooks => app
.hook_manager
.lock()
.map(|m| m.list_hooks().len())
.unwrap_or(0),
ConfigTab::Session => app.ui.session_list.len(),
ConfigTab::Teammates => app
.teammate_manager
.lock()
.map(|m| m.teammates.len())
.unwrap_or(0),
ConfigTab::Archive => app.ui.archives.len(),
}
}
impl ChatApp {
pub fn new(session_id: String) -> Self {
let agent_config = load_agent_config();
if !system_prompt_path().exists() {
let _ = save_system_prompt(&crate::assets::default_system_prompt());
}
if !memory_path().exists() {
let _ = save_memory(&crate::assets::default_memory());
}
if !soul_path().exists() {
let _ = save_soul(&crate::assets::default_soul());
}
if !agent_md::agent_md_path().exists() {
let _ = std::fs::write(
agent_md::agent_md_path(),
crate::assets::default_agent_md().as_ref(),
);
}
if let Err(e) = crate::assets::install_default_skills(&skill::skills_dir()) {
crate::util::log::write_error_log(
"[ChatApp::new]",
&format!("安装预设 skills 失败: {}", e),
);
}
if let Err(e) = crate::assets::install_default_commands(&command::commands_dir()) {
crate::util::log::write_error_log(
"[ChatApp::new]",
&format!("安装预设 commands 失败: {}", e),
);
}
let mut model_list_state = ListState::default();
if !agent_config.providers.is_empty() {
model_list_state.select(Some(agent_config.active_index));
}
let theme = Theme::from_name(&agent_config.theme);
let loaded_skills = skill::load_all_skills();
let loaded_commands = command::load_all_commands();
let (ask_req_tx, ask_req_rx) = mpsc::channel::<AskRequest>();
let queued_tasks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let pending_user_messages: Arc<Mutex<Vec<ChatMessage>>> = Arc::new(Mutex::new(Vec::new()));
let display_messages: Arc<Mutex<Vec<ChatMessage>>> = Arc::new(Mutex::new(Vec::new()));
let context_messages: Arc<Mutex<Vec<ChatMessage>>> = Arc::new(Mutex::new(Vec::new()));
let teammate_manager: Arc<Mutex<TeammateManager>> =
Arc::new(Mutex::new(TeammateManager::new(
Arc::clone(&pending_user_messages),
Arc::clone(&display_messages),
Arc::clone(&context_messages),
)));
let background_manager = Arc::new(BackgroundManager::new());
let task_manager = Arc::new(TaskManager::new_with_session(&session_id));
let hook_manager = Arc::new(Mutex::new(HookManager::load()));
let invoked_skills = crate::command::chat::context::compact::new_invoked_skills_map();
let mut tool_registry = ToolRegistry::new(
loaded_skills.clone(),
ask_req_tx,
Arc::clone(&background_manager),
Arc::clone(&task_manager),
Arc::clone(&hook_manager),
Arc::clone(&invoked_skills),
crate::command::chat::storage::SessionPaths::new(&session_id).todos_file(),
);
let todo_manager = Arc::clone(&tool_registry.todo_manager);
let default_provider = agent_config
.providers
.get(agent_config.active_index)
.cloned()
.unwrap_or_else(|| ModelProvider {
name: String::new(),
api_base: String::new(),
api_key: String::new(),
model: String::new(),
supports_vision: false,
});
let agent_provider: Arc<Mutex<ModelProvider>> = Arc::new(Mutex::new(default_provider));
let agent_system_prompt: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
if let Ok(mut mgr) = hook_manager.lock() {
mgr.set_provider(Arc::clone(&agent_provider));
}
let disabled_tools_arc = Arc::new(agent_config.disabled_tools.clone());
let permission_queue = Arc::new(PermissionQueue::new());
let plan_approval_queue = Arc::new(PlanApprovalQueue::new());
let sub_agent_tracker = Arc::new(SubAgentTracker::new());
let shared_session_id = Arc::new(Mutex::new(session_id.clone()));
let agent_context_config = Arc::new(Mutex::new(
crate::command::chat::tools::derived_shared::AgentContextConfig {
max_history_messages: agent_config.max_history_messages,
max_context_tokens: agent_config.max_context_tokens,
compact: agent_config.compact.clone(),
},
));
let shared_disabled_hooks = Arc::new(Mutex::new(agent_config.disabled_hooks.clone()));
let derived_agent_shared = DerivedAgentShared {
background_manager: Arc::clone(&background_manager),
provider: Arc::clone(&agent_provider),
system_prompt: Arc::clone(&agent_system_prompt),
jcli_config: Arc::new(JcliConfig::load()),
hook_manager: Arc::clone(&hook_manager),
task_manager: Arc::clone(&task_manager),
disabled_tools: Arc::clone(&disabled_tools_arc),
permission_queue: Arc::clone(&permission_queue),
plan_approval_queue: Arc::clone(&plan_approval_queue),
sub_agent_tracker: Arc::clone(&sub_agent_tracker),
display_messages: Arc::clone(&display_messages),
context_messages: Arc::clone(&context_messages),
session_id: Arc::clone(&shared_session_id),
plan_mode_state: Arc::clone(&tool_registry.plan_mode_state),
agent_context_config: Arc::clone(&agent_context_config),
disabled_hooks: Arc::clone(&shared_disabled_hooks),
};
tool_registry.register(Box::new(
crate::command::chat::tools::sub_agent::SubAgentTool {
shared: derived_agent_shared.clone(),
},
));
tool_registry.register(Box::new(
crate::command::chat::tools::teammate_tool::TeammateTool {
shared: derived_agent_shared,
teammate_manager: Arc::clone(&teammate_manager),
},
));
tool_registry.register(Box::new(
crate::command::chat::tools::send_message::SendMessageTool {
teammate_manager: Arc::clone(&teammate_manager),
},
));
tool_registry.register(Box::new(
crate::command::chat::tools::ignore_message::IgnoreMessageTool {
teammate_manager: Some(Arc::clone(&teammate_manager)),
},
));
let tool_registry = Arc::new(tool_registry);
let jcli_config = Arc::new(JcliConfig::load());
if let Ok(mut manager) = hook_manager.lock() {
let tasks_tm = Arc::clone(&task_manager);
manager.register_builtin(HookEvent::PreLlmRequest, "tasks_status", move |ctx| {
let summary = build_tasks_summary(&tasks_tm);
if let Some(ref prompt) = ctx.system_prompt
&& prompt.contains("{{.tasks}}")
{
return Some(HookResult {
system_prompt: Some(prompt.replace("{{.tasks}}", &summary)),
..Default::default()
});
}
None
});
let bg_mgr = Arc::clone(&background_manager);
manager.register_builtin(
HookEvent::PreLlmRequest,
"background_status",
move |ctx| {
bg_mgr.cleanup_dead_tasks();
let running_summary =
build_running_summary(&bg_mgr);
let notifications = bg_mgr.drain_notifications();
let mut result = HookResult::default();
if let Some(ref prompt) = ctx.system_prompt
&& prompt.contains("{{.background_tasks}}")
{
result.system_prompt =
Some(prompt.replace("{{.background_tasks}}", &running_summary));
}
if !notifications.is_empty() {
let mut inject = Vec::new();
for notif in notifications {
let body = format!(
"<background_task_completed>\n<task_id>{}</task_id>\n<command>{}</command>\n<status>{}</status>\n<result>\n{}\n</result>\n</background_task_completed>",
notif.task_id, notif.command, notif.status, notif.result
);
inject.push(ChatMessage {
role: MessageRole::User,
content: format!("<system-reminder>\n{}\n</system-reminder>", body),
tool_calls: None,
tool_call_id: None,
images: None,
reasoning_content: None,
sender_name: None,
});
}
result.inject_messages = Some(inject);
}
if result.system_prompt.is_some() || result.inject_messages.is_some() {
Some(result)
} else {
None
}
},
);
let session_tr = Arc::clone(&tool_registry);
manager.register_builtin(HookEvent::PreLlmRequest, "session_state", move |ctx| {
let summary = session_tr.build_session_state_summary();
if let Some(ref prompt) = ctx.system_prompt
&& prompt.contains("{{.session_state}}")
{
return Some(HookResult {
system_prompt: Some(prompt.replace("{{.session_state}}", &summary)),
..Default::default()
});
}
None
});
let tm_mgr = Arc::clone(&teammate_manager);
manager.register_builtin(HookEvent::PreLlmRequest, "teammates_status", move |ctx| {
let summary = tm_mgr.lock().map(|m| m.team_summary()).unwrap_or_default();
if let Some(ref prompt) = ctx.system_prompt
&& prompt.contains("{{.teammates}}")
{
return Some(HookResult {
system_prompt: Some(prompt.replace("{{.teammates}}", &summary)),
..Default::default()
});
}
None
});
let todo_mgr = Arc::clone(&todo_manager);
manager.register_builtin(
HookEvent::PreLlmRequest,
"todo_nag",
move |_ctx| {
if !todo_mgr.has_todos() {
return None;
}
let turns = todo_mgr.turns_since_last_call();
if turns < TODO_NAG_INTERVAL_ROUNDS {
return None;
}
let todos_summary = todo_mgr.format_todos_summary();
let body = format!(
"<todo_reminder>\nYou have an active todo list but haven't updated it in 15+ rounds. Update it if progress has been made, or ignore this reminder if you are currently working on an item.\n<todos>\n{}\n</todos>\n</todo_reminder>",
todos_summary
);
let inject = vec![ChatMessage {
role: MessageRole::User,
content: format!("<system-reminder>\n{}\n</system-reminder>", body),
tool_calls: None,
tool_call_id: None,
images: None,
reasoning_content: None,
sender_name: None,
}];
Some(HookResult {
inject_messages: Some(inject),
..Default::default()
})
},
);
manager.register_builtin(HookEvent::PreLlmRequest, "broadcast_compress", |ctx| {
let messages = ctx.messages.as_ref()?;
let self_name = crate::command::chat::agent::thread_identity::current_agent_name();
let compressed = compress_other_agent_toolcalls(
messages,
&self_name,
DEFAULT_OTHER_AGENT_TOOLCALL_THRESHOLD,
);
if compressed.len() == messages.len() {
return None;
}
Some(HookResult {
messages: Some(compressed),
..Default::default()
})
});
}
let new_app = Self {
ui: UIState {
input_buffer: TextBuffer::new(),
mode: ChatMode::Chat,
scroll_offset: u16::MAX,
auto_scroll: true,
browse_msg_index: 0,
browse_scroll_offset: 0,
browse_filter: String::new(),
browse_role_filter: None,
model_list_state,
theme_list_state: ListState::default(),
toast: None,
msg_lines_cache: None,
cached_mention_ranges: None,
last_rendered_streaming_len: 0,
last_stream_render_time: std::time::Instant::now(),
config_provider_idx: 0,
config_field_idx: 0,
config_editing: false,
config_edit_buf: String::new(),
config_edit_cursor: 0,
theme,
archives: Vec::new(),
archive_list_index: 0,
archive_default_name: String::new(),
archive_custom_name: String::new(),
archive_editing_name: false,
archive_edit_cursor: 0,
restore_confirm_needed: false,
at_popup_active: false,
at_popup_filter: String::new(),
at_popup_start_pos: 0,
at_popup_selected: 0,
file_popup_active: false,
file_popup_start_pos: 0,
file_popup_filter: String::new(),
file_popup_selected: 0,
skill_popup_active: false,
skill_popup_start_pos: 0,
skill_popup_filter: String::new(),
skill_popup_selected: 0,
command_popup_active: false,
command_popup_start_pos: 0,
command_popup_filter: String::new(),
command_popup_selected: 0,
slash_popup_active: false,
slash_popup_filter: String::new(),
slash_popup_selected: 0,
tool_interact_selected: 0,
tool_interact_typing: false,
tool_interact_input: String::new(),
tool_interact_cursor: 0,
tool_ask_mode: false,
tool_ask_questions: Vec::new(),
tool_ask_current_idx: 0,
tool_ask_answers: Vec::new(),
tool_ask_selections: Vec::new(),
tool_ask_cursor: 0,
pending_system_prompt_edit: false,
pending_agent_md_edit: false,
pending_style_edit: false,
image_cache: Arc::new(Mutex::new(ImageCache::new())),
expand_tools: false,
config_scroll_offset: 0,
config_provider_scroll_offset: 0,
config_tab: ConfigTab::Model,
session_list: Vec::new(),
session_list_index: 0,
session_restore_confirm: false,
teammate_list_index: 0,
quote_idx: {
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as usize;
ms % crate::command::chat::ui::quotes::quotes_count()
},
input_wrap_width: 0,
pending_agent_perm: None,
pending_plan_approval: None,
compact_exempt_sublist: false,
compact_exempt_idx: 0,
auto_approve: false,
},
state: ChatState {
agent_config,
streaming_content: Arc::new(Mutex::new(String::new())),
streaming_reasoning_content: Arc::new(Mutex::new(String::new())),
is_loading: false,
loaded_skills,
loaded_commands,
queued_tasks,
pending_user_messages: Arc::clone(&pending_user_messages),
retry_hint: None,
},
tool_executor: ToolExecutor::new(),
main_agent: None,
tool_registry,
jcli_config,
background_manager,
task_manager,
todo_manager,
ask_response_tx: None,
ask_request_rx: Some(ask_req_rx),
hook_manager: Arc::clone(&hook_manager),
sandbox: Sandbox::new(),
session_id,
shared_session_id,
persisted_message_count: 0,
persisted_display_count: 0,
ws_bridge: None,
remote_connected: false,
derived_agent_provider: agent_provider,
derived_agent_system_prompt: agent_system_prompt,
derived_agent_context_config: agent_context_config,
derived_agent_disabled_hooks: shared_disabled_hooks,
display_messages,
display_read_offset: 0,
context_messages,
context_read_offset: 0,
context_tokens: Arc::new(Mutex::new(0)),
teammate_manager,
sub_agent_tracker,
permission_queue,
plan_approval_queue,
invoked_skills,
};
{
let should_fire = new_app
.hook_manager
.lock()
.map(|m| m.has_hooks_for(HookEvent::SessionStart))
.unwrap_or(false);
if should_fire {
let ctx = HookContext {
event: HookEvent::SessionStart,
messages: Some(
safe_lock(&new_app.context_messages, "SessionStart::ctx_msgs").clone(),
),
session_id: Some(new_app.session_id.clone()),
cwd: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".to_string()),
..Default::default()
};
HookManager::execute_fire_and_forget(
Arc::clone(&new_app.hook_manager),
HookEvent::SessionStart,
ctx,
new_app.state.agent_config.disabled_hooks.clone(),
);
}
}
new_app
}
pub fn switch_theme(&mut self) {
self.state.agent_config.theme = self.state.agent_config.theme.next();
self.ui.theme = Theme::from_name(&self.state.agent_config.theme);
self.ui.msg_lines_cache = None;
}
pub fn show_toast(&mut self, msg: impl Into<String>, is_error: bool) {
self.ui.toast = Some((msg.into(), is_error, std::time::Instant::now()));
}
pub fn broadcast_ws(&self, msg: WsOutbound) {
if let Some(ref ws) = self.ws_bridge {
ws.broadcast(msg);
}
}
pub fn build_sync_outbound(&self) -> WsOutbound {
use crate::command::chat::remote::protocol::{SyncMessage, SyncToolCall};
let messages: Vec<SyncMessage> = safe_lock(&self.context_messages, "build_sync_outbound")
.iter()
.map(|m| SyncMessage {
role: m.role.to_string(),
content: m.content.clone(),
tool_calls: m.tool_calls.as_ref().map(|tc| {
tc.iter()
.map(|t| SyncToolCall {
id: t.id.clone(),
name: t.name.clone(),
arguments: t.arguments.clone(),
})
.collect()
}),
tool_call_id: m.tool_call_id.clone(),
})
.collect();
let status = if self.state.is_loading {
"loading"
} else if self.ui.mode == ChatMode::ToolConfirm {
"tool_confirm"
} else {
"idle"
};
let model = self.active_model_name().to_string();
WsOutbound::SessionSync {
messages,
status: status.to_string(),
model,
}
}
pub fn inject_remote_message(&mut self, content: &str) {
use crate::command::chat::infra::command;
use crate::command::chat::storage::{ChatMessage, MessageRole};
let text = content.trim().to_string();
if text.is_empty() {
return;
}
let text = command::expand_command_mentions(
&text,
&self.state.loaded_commands,
&self.state.agent_config.disabled_commands,
);
if self.state.is_loading {
let user_msg = ChatMessage::text(MessageRole::User, &text);
self.push_both_channels(user_msg);
{
let mut pending = crate::util::safe_lock(
&self.state.pending_user_messages,
"inject_remote_message::pending",
);
pending.push(ChatMessage::text(MessageRole::User, &text));
}
self.ui.msg_lines_cache = None;
self.ui.auto_scroll = true;
self.ui.scroll_offset = u16::MAX;
} else {
self.send_message_internal(text);
}
}
pub fn tick_toast(&mut self) {
if let Some((_, _, created)) = &self.ui.toast
&& created.elapsed().as_secs() >= TOAST_DURATION_SECS
{
self.ui.toast = None;
}
}
pub fn active_provider(&self) -> Option<&ModelProvider> {
if self.state.agent_config.providers.is_empty() {
return None;
}
let idx = self
.state
.agent_config
.active_index
.min(self.state.agent_config.providers.len() - 1);
Some(&self.state.agent_config.providers[idx])
}
pub fn active_model_name(&self) -> String {
self.active_provider()
.map(|p| p.name.clone())
.unwrap_or_else(|| "未配置".to_string())
}
pub fn cancel_tools_only(&mut self) {
self.tool_executor.cancel();
self.tool_executor.tools_executing_count = 0;
self.tool_executor.active_tool_calls.clear();
self.tool_executor.pending_tool_execution = false;
self.show_toast("工具已取消", false);
}
pub fn cancel_stream(&mut self) {
self.finish_loading(false, true);
}
pub fn switch_model(&mut self) {
if let Some(sel) = self.ui.model_list_state.selected() {
self.state.agent_config.active_index = sel;
let _ = save_agent_config(&self.state.agent_config);
let name = self.active_model_name();
self.show_toast(format!("已切换到: {}", name), false);
}
self.ui.mode = ChatMode::Chat;
}
pub fn scroll_up(&mut self) {
self.ui.scroll_offset = self.ui.scroll_offset.saturating_sub(3);
self.ui.auto_scroll = false;
}
pub fn scroll_down(&mut self) {
self.ui.scroll_offset = self.ui.scroll_offset.saturating_add(3);
}
pub fn execute_pending_tool(&mut self) {
if let Some(new_mode) = self.tool_executor.execute_current(&self.tool_registry) {
self.ui.mode = new_mode;
}
}
pub fn reject_pending_tool(&mut self, reason: &str) {
if let Some(new_mode) = self.tool_executor.reject_current(reason) {
self.ui.mode = new_mode;
}
}
pub fn allow_and_execute_pending_tool(&mut self) {
if let Some(new_mode) = self
.tool_executor
.allow_and_execute(&self.tool_registry, &mut self.jcli_config)
{
self.ui.mode = new_mode;
}
}
}