use super::action::{Action, CursorDirection};
use super::agent_handle::AgentHandle;
use super::chat_state::ChatState;
use super::tool_executor::ToolExecutor;
use super::types::{
AskAnswer, AskRequest, PlanDecision, StreamMsg, ToolCallStatus, ToolExecStatus, ToolResultMsg,
};
use super::ui_state::{ChatMode, ConfigTab, UIState};
use crate::command::chat::agent_config::{AgentLoopConfig, AgentSharedState};
use crate::command::chat::command;
use crate::command::chat::constants::{INPUT_BUFFER_MAX_LEN, ROLE_ASSISTANT, ROLE_TOOL, ROLE_USER};
use crate::command::chat::hook::{HookContext, HookEvent, HookManager, HookResult};
use crate::command::chat::markdown::image_cache::ImageCache;
use crate::command::chat::permission::JcliConfig;
use crate::command::chat::sandbox::Sandbox;
use crate::command::chat::skill::{self, skills_dir};
use crate::command::chat::storage::{
ChatMessage, ChatSession, ModelProvider, SessionEvent, append_session_event, delete_session,
generate_session_id, list_sessions, load_agent_config, load_session, 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::theme::{Theme, ThemeName};
use crate::command::chat::tools::ToolRegistry;
use crate::command::chat::tools::background::BackgroundManager;
use crate::constants::{CONFIG_FIELDS, TOAST_DURATION_SECS};
use crate::tui::editor_core::text_buffer::TextBuffer;
use crate::util::log::write_info_log;
use crate::util::safe_lock;
use ratatui::widgets::ListState;
use std::sync::{Arc, Mutex, mpsc};
use tokio_util::sync::CancellationToken;
pub struct ChatApp {
pub ui: UIState,
pub state: ChatState,
pub tool_executor: ToolExecutor,
pub agent: Option<AgentHandle>,
pub tool_registry: Arc<ToolRegistry>,
pub jcli_config: Arc<JcliConfig>,
pub background_manager: Arc<BackgroundManager>,
#[allow(dead_code)]
pub task_manager: Arc<crate::command::chat::tools::task::TaskManager>,
pub todo_manager: Arc<crate::command::chat::tools::todo::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 last_persisted_len: usize,
pub ws_bridge: Option<crate::command::chat::remote::bridge::WsBridge>,
pub remote_connected: bool,
pub agent_tool_provider: Arc<Mutex<ModelProvider>>,
#[allow(dead_code)]
pub agent_tool_system_prompt: Arc<Mutex<Option<String>>>,
pub shared_agent_messages: Arc<Mutex<Vec<ChatMessage>>>,
pub shared_messages_read_cursor: usize,
pub context_tokens: Arc<Mutex<usize>>,
#[allow(dead_code)]
pub teammate_manager: Arc<Mutex<TeammateManager>>,
pub sub_agent_tracker: Arc<crate::command::chat::tools::agent_shared::SubAgentTracker>,
pub permission_queue: Arc<crate::command::chat::permission_queue::PermissionQueue>,
pub plan_approval_queue: Arc<crate::command::chat::tools::plan::PlanApprovalQueue>,
pub invoked_skills: crate::command::chat::compact::InvokedSkillsMap,
}
#[allow(clippy::too_many_arguments)]
fn apply_static_placeholders(
template: &str,
skills_summary: &str,
tools_summary: &str,
style_text: &str,
memory_text: &str,
soul_text: &str,
agent_md_text: &str,
current_dir: &str,
skill_dir: &str,
project_skill_dir: &str,
) -> String {
template
.replace("{{.current_dir}}", current_dir)
.replace("{{.skills}}", skills_summary)
.replace("{{.skill_dir}}", skill_dir)
.replace("{{.project_skill_dir}}", project_skill_dir)
.replace("{{.tools}}", tools_summary)
.replace("{{.style}}", style_text)
.replace("{{.memory}}", memory_text)
.replace("{{.soul}}", soul_text)
.replace("{{.agent_md}}", agent_md_text)
}
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 => 0,
ConfigTab::Session => app.ui.session_list.len(),
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 !crate::command::chat::agent_md::agent_md_path().exists() {
let _ = std::fs::write(
crate::command::chat::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 session = ChatSession::default();
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 shared_agent_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(&shared_agent_messages),
)));
let background_manager = Arc::new(BackgroundManager::new());
let task_manager = Arc::new(crate::command::chat::tools::task::TaskManager::new());
let hook_manager = Arc::new(Mutex::new(HookManager::load()));
let invoked_skills = crate::command::chat::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),
);
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));
let disabled_tools_arc = Arc::new(agent_config.disabled_tools.clone());
let permission_queue =
Arc::new(crate::command::chat::permission_queue::PermissionQueue::new());
let plan_approval_queue =
Arc::new(crate::command::chat::tools::plan::PlanApprovalQueue::new());
let sub_agent_tracker =
Arc::new(crate::command::chat::tools::agent_shared::SubAgentTracker::new());
let agent_tool_shared = crate::command::chat::tools::agent_shared::AgentToolShared {
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),
};
tool_registry.register(Box::new(crate::command::chat::tools::agent::AgentTool {
shared: agent_tool_shared.clone(),
}));
tool_registry.register(Box::new(
crate::command::chat::tools::agent_team::AgentTeamTool {
shared: agent_tool_shared,
teammate_manager: 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 = crate::command::chat::tools::task::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| {
let running_summary =
crate::command::chat::tools::background::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(crate::command::chat::storage::ChatMessage {
role: crate::command::chat::constants::ROLE_USER.to_string(),
content: format!("<system-reminder>\n{}\n</system-reminder>", body),
tool_calls: None,
tool_call_id: None,
images: 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 < crate::command::chat::constants::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![crate::command::chat::storage::ChatMessage {
role: crate::command::chat::constants::ROLE_USER.to_string(),
content: format!("<system-reminder>\n{}\n</system-reminder>", body),
tool_calls: None,
tool_call_id: None,
images: None,
}];
Some(HookResult {
inject_messages: Some(inject),
..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,
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,
},
state: ChatState {
agent_config,
session,
streaming_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(),
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,
last_persisted_len: 0,
ws_bridge: None,
remote_connected: false,
agent_tool_provider: agent_provider,
agent_tool_system_prompt: agent_system_prompt,
shared_agent_messages,
shared_messages_read_cursor: 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(new_app.state.session.messages.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
}
pub fn update(&mut self, action: Action) {
match action {
Action::SendMessage => self.send_message(),
Action::InsertChar(ch) => {
if self.ui.input_text().len() < INPUT_BUFFER_MAX_LEN {
self.ui.input_buffer.insert_char(ch);
}
}
Action::DeleteChar => {
self.ui.input_buffer.backspace();
}
Action::DeleteForward => {
self.ui.input_buffer.delete_char();
}
Action::MoveCursor(dir) => match dir {
CursorDirection::Up => {
self.ui.input_buffer.move_cursor_up();
}
CursorDirection::Down => {
self.ui.input_buffer.move_cursor_down();
}
},
Action::ClearInput => {
self.ui.clear_input();
}
Action::AtPopupActivate => {
self.ui.at_popup_active = true;
self.ui.at_popup_filter.clear();
self.ui.at_popup_selected = 0;
}
Action::AtPopupClose => {
self.ui.at_popup_active = false;
}
Action::AtPopupFilter(text) => {
self.ui.at_popup_filter = text;
self.ui.at_popup_selected = 0;
}
Action::AtPopupNavigate(dir) => {
match dir {
CursorDirection::Up => {
if self.ui.at_popup_selected > 0 {
self.ui.at_popup_selected -= 1;
}
}
CursorDirection::Down => {
self.ui.at_popup_selected += 1;
}
}
}
Action::AtPopupConfirm => {
}
Action::FilePopupActivate => {
self.ui.file_popup_active = true;
self.ui.file_popup_filter.clear();
self.ui.file_popup_selected = 0;
}
Action::FilePopupClose => {
self.ui.file_popup_active = false;
}
Action::FilePopupFilter(text) => {
self.ui.file_popup_filter = text;
self.ui.file_popup_selected = 0;
}
Action::FilePopupNavigate(_dir) => {
}
Action::FilePopupConfirm => {
}
Action::SkillPopupActivate => {
self.ui.skill_popup_active = true;
self.ui.skill_popup_filter.clear();
self.ui.skill_popup_selected = 0;
}
Action::SkillPopupClose => {
self.ui.skill_popup_active = false;
}
Action::SkillPopupFilter(text) => {
self.ui.skill_popup_filter = text;
self.ui.skill_popup_selected = 0;
}
Action::SkillPopupNavigate(dir) => match dir {
CursorDirection::Up => {
if self.ui.skill_popup_selected > 0 {
self.ui.skill_popup_selected -= 1;
}
}
CursorDirection::Down => {
self.ui.skill_popup_selected += 1;
}
},
Action::SkillPopupConfirm => {}
Action::StreamChunk => {
if self.ui.auto_scroll {
self.ui.scroll_offset = u16::MAX;
}
if self.ws_bridge.is_some() {
let content =
safe_lock(&self.state.streaming_content, "ws_stream_chunk").clone();
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::StreamChunk { content },
);
}
}
Action::ToolCallRequest(_tool_calls) => {
}
Action::StreamDone => {
self.state.retry_hint = None;
if self.ws_bridge.is_some() {
if let Some(last_msg) = self.state.session.messages.last()
&& last_msg.role == "assistant"
{
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::Message {
role: "assistant".to_string(),
content: last_msg.content.clone(),
},
);
}
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Status {
state: "idle".to_string(),
});
}
self.finish_loading(false, false);
}
Action::StreamError(ref e) => {
self.state.retry_hint = None;
let msg = e.display_message();
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Error {
message: format!("请求失败: {}", msg),
});
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Status {
state: "idle".to_string(),
});
self.show_toast(format!("请求失败: {}", msg), true);
self.finish_loading(true, false);
}
Action::StreamCancelled => {
self.state.retry_hint = None;
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Status {
state: "idle".to_string(),
});
self.finish_loading(false, true);
}
Action::StreamRetrying {
attempt,
max_attempts,
delay_ms,
error,
} => {
let delay_s = delay_ms.div_ceil(1000);
self.state.retry_hint = Some(format!(
"⟳ 重试 {}/{} · {}s · {}",
attempt, max_attempts, delay_s, error
));
}
Action::ExecutePendingTool => {
self.execute_pending_tool();
}
Action::RejectPendingTool => {
self.reject_pending_tool("");
}
Action::RejectPendingToolWithReason(ref reason) => {
self.reject_pending_tool(reason);
}
Action::AllowAndExecutePendingTool => {
self.allow_and_execute_pending_tool();
}
Action::AskNavigate(dir) => {
let total = self.ui.tool_ask_questions.len();
match dir {
CursorDirection::Up => {
if self.ui.tool_ask_current_idx > 0 {
self.ui.tool_ask_current_idx -= 1;
if self.ui.tool_ask_answers.len() > self.ui.tool_ask_current_idx {
self.ui
.tool_ask_answers
.truncate(self.ui.tool_ask_current_idx);
}
self.init_ask_question_state();
}
}
CursorDirection::Down => {
if self.ui.tool_ask_current_idx < total - 1
&& self.ui.tool_ask_current_idx < self.ui.tool_ask_answers.len()
{
self.ui.tool_ask_current_idx += 1;
self.init_ask_question_state();
}
}
}
}
Action::AskOptionNavigate(dir) => {
if let Some(q) = self.ui.tool_ask_questions.get(self.ui.tool_ask_current_idx) {
let option_count = q.options.len() + 1; let free_input_idx = q.options.len();
let new_cursor = match dir {
CursorDirection::Up => {
if self.ui.tool_ask_cursor > 0 {
self.ui.tool_ask_cursor - 1
} else {
self.ui.tool_ask_cursor
}
}
CursorDirection::Down => {
if self.ui.tool_ask_cursor < option_count - 1 {
self.ui.tool_ask_cursor + 1
} else {
self.ui.tool_ask_cursor
}
}
};
if new_cursor != self.ui.tool_ask_cursor {
self.ui.tool_ask_cursor = new_cursor;
if new_cursor == free_input_idx {
self.ui.tool_interact_typing = true;
self.ui.tool_interact_input.clear();
self.ui.tool_interact_cursor = 0;
} else {
self.ui.tool_interact_typing = false;
self.ui.tool_interact_input.clear();
self.ui.tool_interact_cursor = 0;
}
}
}
}
Action::AskSingleSelect => {
if let Some(q) = self
.ui
.tool_ask_questions
.get(self.ui.tool_ask_current_idx)
.cloned()
{
let cursor = self.ui.tool_ask_cursor;
if cursor == q.options.len() {
self.ui.tool_interact_typing = true;
self.ui.tool_interact_input.clear();
self.ui.tool_interact_cursor = 0;
} else {
self.ask_submit_answer(AskAnswer::Selected(vec![cursor]));
}
}
}
Action::AskToggleMultiSelect => {
if let Some(q) = self.ui.tool_ask_questions.get(self.ui.tool_ask_current_idx)
&& self.ui.tool_ask_cursor < q.options.len()
{
let idx = self.ui.tool_ask_cursor;
if idx < self.ui.tool_ask_selections.len() {
self.ui.tool_ask_selections[idx] = !self.ui.tool_ask_selections[idx];
}
}
}
Action::AskInputChar(c) => {
let byte_idx = self
.ui
.tool_interact_input
.char_indices()
.nth(self.ui.tool_interact_cursor)
.map(|(i, _)| i)
.unwrap_or(self.ui.tool_interact_input.len());
self.ui.tool_interact_input.insert(byte_idx, c);
self.ui.tool_interact_cursor += 1;
}
Action::AskDeleteChar => {
if self.ui.tool_interact_cursor > 0 {
let start = self
.ui
.tool_interact_input
.char_indices()
.nth(self.ui.tool_interact_cursor - 1)
.map(|(i, _)| i)
.unwrap_or(0);
let end = self
.ui
.tool_interact_input
.char_indices()
.nth(self.ui.tool_interact_cursor)
.map(|(i, _)| i)
.unwrap_or(self.ui.tool_interact_input.len());
self.ui.tool_interact_input.drain(start..end);
self.ui.tool_interact_cursor -= 1;
}
}
Action::AskSubmitAnswer => {
let input_text = self.ui.tool_interact_input.trim().to_string();
let answer = if input_text.is_empty() {
AskAnswer::FreeText("(空)".to_string())
} else {
AskAnswer::FreeText(input_text)
};
self.ask_submit_answer(answer);
self.ui.tool_interact_input.clear();
self.ui.tool_interact_cursor = 0;
self.ui.tool_interact_typing = false;
}
Action::AskCancel => {
if let Some(tx) = self.ask_response_tx.take() {
let _ = tx.send("用户取消了问答".to_string());
}
self.ui.tool_ask_mode = false;
self.ui.tool_ask_questions.clear();
self.ui.tool_ask_current_idx = 0;
self.ui.tool_ask_answers.clear();
self.ui.tool_ask_selections.clear();
self.ui.tool_ask_cursor = 0;
if !self.tool_executor.has_pending_confirm() {
self.ui.mode = ChatMode::Chat;
}
}
Action::ToolInteractNavigate(dir) => match dir {
CursorDirection::Up => {
if self.ui.tool_interact_selected > 0 {
self.ui.tool_interact_selected -= 1;
}
}
CursorDirection::Down => {
if self.ui.tool_interact_selected < 3 {
self.ui.tool_interact_selected += 1;
}
}
},
Action::ToolInteractInputChar(c) => {
let byte_idx = self
.ui
.tool_interact_input
.char_indices()
.nth(self.ui.tool_interact_cursor)
.map(|(i, _)| i)
.unwrap_or(self.ui.tool_interact_input.len());
self.ui.tool_interact_input.insert(byte_idx, c);
self.ui.tool_interact_cursor += 1;
}
Action::ToolInteractDeleteChar => {
if self.ui.tool_interact_cursor > 0 {
let start = self
.ui
.tool_interact_input
.char_indices()
.nth(self.ui.tool_interact_cursor - 1)
.map(|(i, _)| i)
.unwrap_or(0);
let end = self
.ui
.tool_interact_input
.char_indices()
.nth(self.ui.tool_interact_cursor)
.map(|(i, _)| i)
.unwrap_or(self.ui.tool_interact_input.len());
self.ui.tool_interact_input.drain(start..end);
self.ui.tool_interact_cursor -= 1;
}
}
Action::ToolInteractConfirm => match self.ui.tool_interact_selected {
0 => self.execute_pending_tool(),
1 => self.allow_and_execute_pending_tool(),
2 => self.reject_pending_tool(""),
3 => {
self.ui.tool_interact_typing = true;
self.ui.tool_interact_input.clear();
self.ui.tool_interact_cursor = 0;
}
_ => {}
},
Action::EnterMode(mode) => {
if mode == ChatMode::ToolConfirm && self.ws_bridge.is_some() {
let tools: Vec<crate::command::chat::remote::protocol::ToolConfirmInfo> = self
.tool_executor
.active_tool_calls
.iter()
.filter(|tc| matches!(tc.status, ToolExecStatus::PendingConfirm))
.map(
|tc| crate::command::chat::remote::protocol::ToolConfirmInfo {
id: tc.tool_call_id.clone(),
name: tc.tool_name.clone(),
arguments: tc.arguments.clone(),
confirm_message: tc.confirm_message.clone(),
},
)
.collect();
if !tools.is_empty() {
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::ToolConfirmRequest { tools },
);
}
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Status {
state: "tool_confirm".to_string(),
});
}
if mode == ChatMode::Browse {
self.ui.browse_filter.clear();
self.ui.browse_role_filter = None;
}
self.ui.mode = mode;
}
Action::ExitToChat => {
self.ui.mode = ChatMode::Chat;
self.ui.browse_filter.clear();
self.ui.browse_role_filter = None;
}
Action::Scroll(dir) => match dir {
CursorDirection::Up => self.scroll_up(),
CursorDirection::Down => self.scroll_down(),
},
Action::PageScroll(dir) => match dir {
CursorDirection::Up => {
for _ in 0..10 {
self.scroll_up();
}
}
CursorDirection::Down => {
for _ in 0..10 {
self.scroll_down();
}
}
},
Action::BrowseNavigate(dir) => {
let filtered = self.browse_filtered_indices();
if filtered.is_empty() {
self.ui.mode = ChatMode::Chat;
self.ui.msg_lines_cache = None;
return;
}
let current_in_filtered =
filtered.iter().position(|&i| i == self.ui.browse_msg_index);
match dir {
CursorDirection::Up => {
let new_idx = match current_in_filtered {
Some(pos) if pos > 0 => filtered[pos - 1],
Some(_) => filtered[filtered.len() - 1],
None => *filtered.last().unwrap(),
};
self.ui.browse_msg_index = new_idx;
self.ui.browse_scroll_offset = 0;
self.ui.msg_lines_cache = None;
}
CursorDirection::Down => {
let new_idx = match current_in_filtered {
Some(pos) if pos < filtered.len() - 1 => filtered[pos + 1],
Some(_) => filtered[0],
None => filtered[0],
};
self.ui.browse_msg_index = new_idx;
self.ui.browse_scroll_offset = 0;
self.ui.msg_lines_cache = None;
}
}
}
Action::BrowseFineScroll(dir) => match dir {
CursorDirection::Up => {
self.ui.browse_scroll_offset = self.ui.browse_scroll_offset.saturating_sub(3);
}
CursorDirection::Down => {
self.ui.browse_scroll_offset = self.ui.browse_scroll_offset.saturating_add(3);
}
},
Action::BrowseCopyMessage => {
use crate::command::chat::render_cache::copy_to_clipboard;
if let Some(msg) = self.state.session.messages.get(self.ui.browse_msg_index) {
let content = msg.content.clone();
let filtered = self.browse_filtered_indices();
let pos_in_filtered =
filtered.iter().position(|&i| i == self.ui.browse_msg_index);
let role_label = if msg.role == ROLE_ASSISTANT {
"AI"
} else if msg.role == ROLE_USER {
"用户"
} else {
"系统"
};
let extra = if let Some(pos) = pos_in_filtered {
format!(" ({}/{})", pos + 1, filtered.len())
} else {
String::new()
};
if copy_to_clipboard(&content) {
self.show_toast(format!("已复制{}消息{}", role_label, extra), false);
} else {
self.show_toast("复制到剪切板失败", true);
}
}
}
Action::BrowseInputChar(c) => {
self.ui.browse_filter.push(c);
self.browse_jump_to_first_match();
self.ui.msg_lines_cache = None;
}
Action::BrowseDeleteChar => {
self.ui.browse_filter.pop();
self.browse_jump_to_first_match();
self.ui.msg_lines_cache = None;
}
Action::BrowseClearFilter => {
self.ui.browse_filter.clear();
self.ui.browse_role_filter = None;
self.ui.msg_lines_cache = None;
}
Action::BrowseToggleRole => {
self.ui.browse_role_filter = match &self.ui.browse_role_filter {
None => Some("ai".to_string()),
Some(r) if r == "ai" => Some("user".to_string()),
_ => None,
};
self.browse_jump_to_first_match();
self.ui.msg_lines_cache = None;
}
Action::ConfigNavigate(dir) => {
let total_fields = config_tab_field_count(self);
if total_fields == 0 {
return;
}
match dir {
CursorDirection::Up => {
if self.ui.config_field_idx > 0 {
self.ui.config_field_idx -= 1;
}
}
CursorDirection::Down => {
if self.ui.config_field_idx < total_fields - 1 {
self.ui.config_field_idx += 1;
}
}
}
}
Action::ConfigSwitchProvider(dir) => {
if self.ui.config_tab != ConfigTab::Model {
return;
}
let count = self.state.agent_config.providers.len();
if count > 1 {
match dir {
CursorDirection::Down => {
self.ui.config_provider_idx = (self.ui.config_provider_idx + 1) % count;
}
CursorDirection::Up => {
if self.ui.config_provider_idx == 0 {
self.ui.config_provider_idx = count - 1;
} else {
self.ui.config_provider_idx -= 1;
}
}
}
}
}
Action::ConfigEnter => {
use crate::command::chat::ui_helpers::{
config_field_raw_value_global, config_field_raw_value_model,
};
use crate::constants::{CONFIG_FIELDS, CONFIG_GLOBAL_FIELDS_TAB};
match self.ui.config_tab {
ConfigTab::Model => {
if self.state.agent_config.providers.is_empty() {
self.show_toast("还没有 Provider,按 a 新增", true);
return;
}
if self.ui.config_field_idx < CONFIG_FIELDS.len()
&& CONFIG_FIELDS[self.ui.config_field_idx] == "supports_vision"
&& let Some(p) = self
.state
.agent_config
.providers
.get_mut(self.ui.config_provider_idx)
{
p.supports_vision = !p.supports_vision;
let status = if p.supports_vision {
"开启"
} else {
"关闭"
};
self.show_toast(format!("当前 Provider 支持视觉已{}", status), false);
return;
}
self.ui.config_edit_buf =
config_field_raw_value_model(self, self.ui.config_field_idx);
self.ui.config_edit_cursor = self.ui.config_edit_buf.chars().count();
self.ui.config_editing = true;
}
ConfigTab::Global => {
let idx = self.ui.config_field_idx;
if idx < CONFIG_GLOBAL_FIELDS_TAB.len() {
let field = CONFIG_GLOBAL_FIELDS_TAB[idx];
if field == "auto_restore_session" {
self.state.agent_config.auto_restore_session =
!self.state.agent_config.auto_restore_session;
let status = if self.state.agent_config.auto_restore_session {
"开启"
} else {
"关闭"
};
self.show_toast(format!("自动恢复会话已{}", status), false);
return;
}
if field == "theme" {
self.switch_theme();
return;
}
if field == "system_prompt" {
self.ui.pending_system_prompt_edit = true;
return;
}
if field == "agent_md" {
self.ui.pending_agent_md_edit = true;
return;
}
if field == "style" {
self.ui.pending_style_edit = true;
return;
}
self.ui.config_edit_buf = config_field_raw_value_global(self, idx);
self.ui.config_edit_cursor = self.ui.config_edit_buf.chars().count();
self.ui.config_editing = true;
}
}
ConfigTab::Tools => {
self.update(Action::ToggleMenuToggle);
}
ConfigTab::Skills => {
self.update(Action::ToggleMenuToggle);
}
ConfigTab::Commands => {
self.update(Action::ToggleMenuToggle);
}
_ => {}
}
}
Action::ConfigEditChar(c) => {
let byte_idx = self
.ui
.config_edit_buf
.char_indices()
.nth(self.ui.config_edit_cursor)
.map(|(i, _)| i)
.unwrap_or(self.ui.config_edit_buf.len());
self.ui.config_edit_buf.insert(byte_idx, c);
self.ui.config_edit_cursor += 1;
}
Action::ConfigEditDelete => {
if self.ui.config_edit_cursor > 0 {
let idx = self
.ui
.config_edit_buf
.char_indices()
.nth(self.ui.config_edit_cursor - 1)
.map(|(i, _)| i)
.unwrap_or(0);
let end_idx = self
.ui
.config_edit_buf
.char_indices()
.nth(self.ui.config_edit_cursor)
.map(|(i, _)| i)
.unwrap_or(self.ui.config_edit_buf.len());
self.ui.config_edit_buf = format!(
"{}{}",
&self.ui.config_edit_buf[..idx],
&self.ui.config_edit_buf[end_idx..]
);
self.ui.config_edit_cursor -= 1;
}
}
Action::ConfigEditDeleteForward => {
let char_count = self.ui.config_edit_buf.chars().count();
if self.ui.config_edit_cursor < char_count {
let idx = self
.ui
.config_edit_buf
.char_indices()
.nth(self.ui.config_edit_cursor)
.map(|(i, _)| i)
.unwrap_or(self.ui.config_edit_buf.len());
let end_idx = self
.ui
.config_edit_buf
.char_indices()
.nth(self.ui.config_edit_cursor + 1)
.map(|(i, _)| i)
.unwrap_or(self.ui.config_edit_buf.len());
self.ui.config_edit_buf = format!(
"{}{}",
&self.ui.config_edit_buf[..idx],
&self.ui.config_edit_buf[end_idx..]
);
}
}
Action::ConfigEditMoveCursor(dir) => match dir {
CursorDirection::Up => {
self.ui.config_edit_cursor = self.ui.config_edit_cursor.saturating_sub(1);
}
CursorDirection::Down => {
let char_count = self.ui.config_edit_buf.chars().count();
if self.ui.config_edit_cursor < char_count {
self.ui.config_edit_cursor += 1;
}
}
},
Action::ConfigEditMoveHome => {
self.ui.config_edit_cursor = 0;
}
Action::ConfigEditMoveEnd => {
self.ui.config_edit_cursor = self.ui.config_edit_buf.chars().count();
}
Action::ConfigEditClearLine => {
self.ui.config_edit_buf.clear();
self.ui.config_edit_cursor = 0;
}
Action::ConfigEditSubmit => {
use crate::command::chat::ui_helpers::{
config_field_set_global, config_field_set_model,
};
let val = self.ui.config_edit_buf.clone();
match self.ui.config_tab {
ConfigTab::Model => {
config_field_set_model(self, self.ui.config_field_idx, &val);
}
ConfigTab::Global => {
config_field_set_global(self, self.ui.config_field_idx, &val);
}
_ => {}
}
self.ui.config_editing = false;
}
Action::ConfigAddProvider => {
let new_provider = ModelProvider {
name: format!("Provider-{}", self.state.agent_config.providers.len() + 1),
api_base: "https://api.openai.com/v1".to_string(),
api_key: String::new(),
model: String::new(),
supports_vision: false,
};
self.state.agent_config.providers.push(new_provider);
self.ui.config_provider_idx = self.state.agent_config.providers.len() - 1;
self.ui.config_field_idx = 0;
self.show_toast("已新增 Provider,请填写配置", false);
}
Action::ConfigDeleteProvider => {
let count = self.state.agent_config.providers.len();
if count == 0 {
self.show_toast("没有可删除的 Provider", true);
} else {
let removed_name = self.state.agent_config.providers
[self.ui.config_provider_idx]
.name
.clone();
self.state
.agent_config
.providers
.remove(self.ui.config_provider_idx);
if self.ui.config_provider_idx >= self.state.agent_config.providers.len()
&& self.ui.config_provider_idx > 0
{
self.ui.config_provider_idx -= 1;
}
if self.state.agent_config.active_index
>= self.state.agent_config.providers.len()
&& self.state.agent_config.active_index > 0
{
self.state.agent_config.active_index -= 1;
}
self.show_toast(format!("已删除 Provider: {}", removed_name), false);
}
}
Action::ConfigSetActiveProvider => {
if !self.state.agent_config.providers.is_empty() {
self.state.agent_config.active_index = self.ui.config_provider_idx;
let name = self.state.agent_config.providers[self.ui.config_provider_idx]
.name
.clone();
self.show_toast(format!("已设为活跃模型: {}", name), false);
}
}
Action::ConfigSwitchTab(dir) => {
self.ui.config_tab = match dir {
CursorDirection::Down => self.ui.config_tab.next(),
CursorDirection::Up => self.ui.config_tab.prev(),
};
self.ui.config_field_idx = 0;
self.ui.config_scroll_offset = 0;
self.ui.config_editing = false;
if self.ui.config_tab == ConfigTab::Session {
self.update(Action::LoadSessionList);
}
if self.ui.config_tab == ConfigTab::Archive {
use crate::command::chat::archive::list_archives;
self.ui.archives = list_archives();
self.ui.archive_list_index = 0;
self.ui.restore_confirm_needed = false;
}
}
Action::ToggleMenuNavigate(dir) => {
let total = config_tab_field_count(self);
if total == 0 {
return;
}
match dir {
CursorDirection::Up => {
if self.ui.config_field_idx == 0 {
self.ui.config_field_idx = total - 1;
} else {
self.ui.config_field_idx -= 1;
}
}
CursorDirection::Down => {
self.ui.config_field_idx = (self.ui.config_field_idx + 1) % total;
}
}
}
Action::ToggleMenuToggle => {
if self.ui.config_tab == ConfigTab::Tools {
let tool_names = self.tool_registry.tool_names();
if let Some(name) = tool_names.get(self.ui.config_field_idx) {
let name = name.to_string();
if let Some(pos) = self
.state
.agent_config
.disabled_tools
.iter()
.position(|d| d == &name)
{
self.state.agent_config.disabled_tools.remove(pos);
} else {
self.state.agent_config.disabled_tools.push(name);
}
}
} else if self.ui.config_tab == ConfigTab::Skills
&& let Some(skill) = self.state.loaded_skills.get(self.ui.config_field_idx)
{
let name = skill.frontmatter.name.clone();
if let Some(pos) = self
.state
.agent_config
.disabled_skills
.iter()
.position(|d| d == &name)
{
self.state.agent_config.disabled_skills.remove(pos);
} else {
self.state.agent_config.disabled_skills.push(name);
}
} else if self.ui.config_tab == ConfigTab::Commands
&& let Some(cmd) = self.state.loaded_commands.get(self.ui.config_field_idx)
{
let name = cmd.frontmatter.name.clone();
if let Some(pos) = self
.state
.agent_config
.disabled_commands
.iter()
.position(|d| d == &name)
{
self.state.agent_config.disabled_commands.remove(pos);
} else {
self.state.agent_config.disabled_commands.push(name);
}
}
}
Action::ToggleMenuEnableAll => {
if self.ui.config_tab == ConfigTab::Tools {
self.state.agent_config.disabled_tools.clear();
self.show_toast("已启用全部工具", false);
} else if self.ui.config_tab == ConfigTab::Skills {
self.state.agent_config.disabled_skills.clear();
self.show_toast("已启用全部 Skills", false);
} else if self.ui.config_tab == ConfigTab::Commands {
self.state.agent_config.disabled_commands.clear();
self.show_toast("已启用全部命令", false);
}
}
Action::ToggleMenuDisableAll => {
if self.ui.config_tab == ConfigTab::Tools {
self.state.agent_config.disabled_tools = self
.tool_registry
.tool_names()
.iter()
.map(|n| n.to_string())
.collect();
self.show_toast("已禁用全部工具", false);
} else if self.ui.config_tab == ConfigTab::Skills {
self.state.agent_config.disabled_skills = self
.state
.loaded_skills
.iter()
.map(|s| s.frontmatter.name.clone())
.collect();
self.show_toast("已禁用全部 Skills", false);
} else if self.ui.config_tab == ConfigTab::Commands {
self.state.agent_config.disabled_commands = self
.state
.loaded_commands
.iter()
.map(|c| c.frontmatter.name.clone())
.collect();
self.show_toast("已禁用全部命令", false);
}
}
Action::ModelSelectNavigate(dir) => {
let count = self.state.agent_config.providers.len();
if count > 0 {
match dir {
CursorDirection::Up => {
let i = self
.ui
.model_list_state
.selected()
.map(|i| if i == 0 { count - 1 } else { i - 1 })
.unwrap_or(0);
self.ui.model_list_state.select(Some(i));
}
CursorDirection::Down => {
let i = self
.ui
.model_list_state
.selected()
.map(|i| if i >= count - 1 { 0 } else { i + 1 })
.unwrap_or(0);
self.ui.model_list_state.select(Some(i));
}
}
}
}
Action::ModelSelectConfirm => {
self.switch_model();
}
Action::ThemeSelectNavigate(dir) => {
let count = ThemeName::all().len();
if count > 0 {
match dir {
CursorDirection::Up => {
let i = self
.ui
.theme_list_state
.selected()
.map(|i| if i == 0 { count - 1 } else { i - 1 })
.unwrap_or(0);
self.ui.theme_list_state.select(Some(i));
}
CursorDirection::Down => {
let i = self
.ui
.theme_list_state
.selected()
.map(|i| if i >= count - 1 { 0 } else { i + 1 })
.unwrap_or(0);
self.ui.theme_list_state.select(Some(i));
}
}
}
}
Action::ThemeSelectConfirm => {
if let Some(sel) = self.ui.theme_list_state.selected() {
let all = ThemeName::all();
if sel < all.len() {
self.state.agent_config.theme = all[sel].clone();
self.ui.theme = Theme::from_name(&all[sel]);
self.ui.msg_lines_cache = None;
let _ = save_agent_config(&self.state.agent_config);
let name = all[sel].display_name();
self.show_toast(format!("已切换主题: {}", name), false);
}
}
self.ui.mode = ChatMode::Chat;
}
Action::StartArchiveConfirm => {
self.start_archive_confirm();
}
Action::ArchiveConfirmEditName => {
self.ui.archive_editing_name = true;
}
Action::ArchiveConfirmMoveCursor(dir) => match dir {
CursorDirection::Up => {
self.ui.archive_edit_cursor = self.ui.archive_edit_cursor.saturating_sub(1);
}
CursorDirection::Down => {
let char_count = self.ui.archive_custom_name.chars().count();
if self.ui.archive_edit_cursor < char_count {
self.ui.archive_edit_cursor += 1;
}
}
},
Action::ArchiveConfirmInputChar(c) => {
let chars: Vec<char> = self.ui.archive_custom_name.chars().collect();
self.ui.archive_custom_name = chars[..self.ui.archive_edit_cursor]
.iter()
.chain(std::iter::once(&c))
.chain(chars[self.ui.archive_edit_cursor..].iter())
.collect();
self.ui.archive_edit_cursor += 1;
}
Action::ArchiveConfirmDeleteChar => {
if self.ui.archive_edit_cursor > 0 {
let chars: Vec<char> = self.ui.archive_custom_name.chars().collect();
self.ui.archive_custom_name = chars[..self.ui.archive_edit_cursor - 1]
.iter()
.chain(chars[self.ui.archive_edit_cursor..].iter())
.collect();
self.ui.archive_edit_cursor -= 1;
}
}
Action::ArchiveWithDefault => {
self.do_archive(&self.ui.archive_default_name.clone());
}
Action::ArchiveWithCustom => {
self.do_archive(&self.ui.archive_custom_name.clone());
}
Action::ClearSession => {
self.clear_session();
}
Action::ListSessions => {
let sessions = list_sessions();
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::SessionList { sessions },
);
}
Action::SwitchSession { session_id } => {
if self.state.is_loading {
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Error {
message: "AI 正在回复中,无法切换会话".to_string(),
});
} else if self.ui.mode == ChatMode::ToolConfirm {
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Error {
message: "等待工具确认中,无法切换会话".to_string(),
});
} else {
let target_path = crate::command::chat::storage::session_file_path(&session_id);
if !target_path.exists() {
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::Error {
message: "会话不存在".to_string(),
},
);
} else {
self.persist_new_messages();
let session = load_session(&session_id);
self.session_id = session_id.clone();
self.last_persisted_len = session.messages.len();
self.state.session = session;
self.ui.scroll_offset = 0;
self.ui.msg_lines_cache = None;
if let Ok(mut ct) = self.context_tokens.lock() {
*ct = 0;
}
let sync = self.build_sync_outbound();
self.broadcast_ws(sync);
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::SessionSwitched {
session_id,
},
);
}
}
}
Action::NewSession => {
if self.state.is_loading {
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Error {
message: "AI 正在回复中,无法新建会话".to_string(),
});
} else if self.ui.mode == ChatMode::ToolConfirm {
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Error {
message: "等待工具确认中,无法新建会话".to_string(),
});
} else {
self.persist_new_messages();
let new_id = generate_session_id();
self.session_id = new_id.clone();
self.state.session.messages.clear();
self.last_persisted_len = 0;
self.ui.scroll_offset = 0;
self.ui.msg_lines_cache = None;
if let Ok(mut ct) = self.context_tokens.lock() {
*ct = 0;
}
let sync = self.build_sync_outbound();
self.broadcast_ws(sync);
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::SessionSwitched {
session_id: new_id,
},
);
}
}
Action::LoadSessionList => {
let mut sessions = list_sessions();
sessions.retain(|s| s.id != self.session_id);
self.ui.session_list = sessions;
self.ui.session_list_index = 0;
self.ui.session_restore_confirm = false;
}
Action::SessionListNavigate(dir) => {
let count = self.ui.session_list.len();
if count > 0 {
match dir {
CursorDirection::Up => {
self.ui.session_list_index = if self.ui.session_list_index == 0 {
count - 1
} else {
self.ui.session_list_index - 1
};
}
CursorDirection::Down => {
self.ui.session_list_index = (self.ui.session_list_index + 1) % count;
}
}
}
}
Action::RestoreSession => {
if self.ui.session_list.is_empty() {
return;
}
let idx = self.ui.session_list_index;
if let Some(meta) = self.ui.session_list.get(idx) {
let target_id = meta.id.clone();
self.persist_new_messages();
let session = load_session(&target_id);
self.last_persisted_len = session.messages.len();
self.session_id = target_id;
self.state.session = session;
self.ui.scroll_offset = u16::MAX;
self.ui.msg_lines_cache = None;
self.ui.session_restore_confirm = false;
if let Ok(mut ct) = self.context_tokens.lock() {
*ct = 0;
}
self.ui.mode = ChatMode::Chat;
self.show_toast("会话已恢复".to_string(), false);
}
}
Action::DeleteSession => {
if self.ui.session_list.is_empty() {
return;
}
let idx = self.ui.session_list_index;
if let Some(meta) = self.ui.session_list.get(idx) {
let id = meta.id.clone();
if delete_session(&id) {
self.ui.session_list.remove(idx);
if self.ui.session_list_index >= self.ui.session_list.len()
&& self.ui.session_list_index > 0
{
self.ui.session_list_index -= 1;
}
self.show_toast("会话已删除".to_string(), false);
} else {
self.show_toast("删除失败".to_string(), true);
}
}
}
Action::NewSessionFromList => {
self.persist_new_messages();
let new_id = generate_session_id();
self.session_id = new_id;
self.state.session.messages.clear();
self.last_persisted_len = 0;
self.ui.scroll_offset = 0;
self.ui.msg_lines_cache = None;
if let Ok(mut ct) = self.context_tokens.lock() {
*ct = 0;
}
self.ui.mode = ChatMode::Chat;
self.show_toast("已新建会话".to_string(), false);
}
Action::StartArchiveList => {
self.start_archive_list();
}
Action::ArchiveListNavigate(dir) => {
let count = self.ui.archives.len();
if count > 0 {
match dir {
CursorDirection::Up => {
self.ui.archive_list_index = if self.ui.archive_list_index == 0 {
count - 1
} else {
self.ui.archive_list_index - 1
};
}
CursorDirection::Down => {
self.ui.archive_list_index = if self.ui.archive_list_index >= count - 1
{
0
} else {
self.ui.archive_list_index + 1
};
}
}
}
}
Action::RestoreArchive => {
self.do_restore();
}
Action::DeleteArchive => {
self.do_delete_archive();
}
Action::SwitchModel => {
self.ui.mode = ChatMode::SelectModel;
}
Action::SwitchTheme => {
self.switch_theme();
}
Action::CancelStream => {
self.cancel_stream();
self.permission_queue.deny_all();
self.plan_approval_queue.deny_all();
if let Some(req) = self.ui.pending_agent_perm.take() {
req.resolve(false);
}
if let Some(req) = self.ui.pending_plan_approval.take() {
req.resolve(crate::command::chat::app::types::PlanDecision::Reject);
}
if matches!(
self.ui.mode,
ChatMode::AgentPermConfirm | ChatMode::PlanApprovalConfirm
) {
self.ui.mode = ChatMode::Chat;
}
}
Action::CancelToolsOnly => {
self.cancel_tools_only();
}
Action::ShowToast(msg, is_error) => {
self.show_toast(msg, is_error);
}
Action::TickToast => {
self.tick_toast();
}
Action::SaveConfig => {
let _ = save_agent_config(&self.state.agent_config);
self.ui.mode = ChatMode::Chat;
}
Action::CopyLastAiReply => {
use crate::command::chat::render_cache::copy_to_clipboard;
if let Some(last_ai) = self
.state
.session
.messages
.iter()
.rev()
.find(|m| m.role == ROLE_ASSISTANT)
{
if copy_to_clipboard(&last_ai.content) {
self.show_toast("已复制最后一条 AI 回复", false);
} else {
self.show_toast("复制到剪切板失败", true);
}
} else {
self.show_toast("暂无 AI 回复可复制", true);
}
}
Action::ShowHelp => {
self.ui.mode = ChatMode::Help;
}
Action::OpenLogWindows => {
use crate::constants::{AGENT_DIR, AGENT_LOG_DIR, AGENT_LOG_ERROR, AGENT_LOG_INFO};
let log_dir = crate::config::YamlConfig::data_dir()
.join(AGENT_DIR)
.join(AGENT_LOG_DIR);
let info_log = log_dir.join(AGENT_LOG_INFO);
let error_log = log_dir.join(AGENT_LOG_ERROR);
let info_cmd = format!("tail -f '{}'; exit", info_log.to_string_lossy())
.replace('\\', "\\\\")
.replace('"', "\\\"");
let error_cmd = format!("tail -f '{}'; exit", error_log.to_string_lossy())
.replace('\\', "\\\\")
.replace('"', "\\\"");
let apple_script = format!(
"tell application \"Terminal\"\n\
do script \"{}\"\n\
do script \"{}\"\n\
activate\n\
end tell",
info_cmd, error_cmd
);
let _ = std::process::Command::new("osascript")
.arg("-e")
.arg(&apple_script)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
Action::Quit => {
}
Action::ToggleExpandTools => {
self.ui.expand_tools = !self.ui.expand_tools;
self.ui.msg_lines_cache = None;
self.ui.auto_scroll = true;
self.ui.scroll_offset = u16::MAX;
self.show_toast(
if self.ui.expand_tools {
"展开工具详情"
} else {
"折叠工具详情"
},
false,
);
}
}
}
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 browse_filtered_indices(&self) -> Vec<usize> {
let filter_lower = self.ui.browse_filter.to_lowercase();
self.state
.session
.messages
.iter()
.enumerate()
.filter(|(_, m)| {
match &self.ui.browse_role_filter {
Some(r) if r == "ai" && m.role != ROLE_ASSISTANT => return false,
Some(r) if r == "user" && m.role != ROLE_USER => return false,
_ => {}
}
if !filter_lower.is_empty() {
return m.content.to_lowercase().contains(&filter_lower);
}
true
})
.map(|(i, _)| i)
.collect()
}
fn browse_jump_to_first_match(&mut self) {
let filtered = self.browse_filtered_indices();
if filtered.is_empty() {
return;
}
if filtered.contains(&self.ui.browse_msg_index) {
return;
}
let target = filtered
.iter()
.rev()
.find(|&&i| i <= self.ui.browse_msg_index)
.copied()
.unwrap_or(filtered[0]);
self.ui.browse_msg_index = target;
self.ui.browse_scroll_offset = 0;
}
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: crate::command::chat::remote::protocol::WsOutbound) {
if let Some(ref ws) = self.ws_bridge {
ws.broadcast(msg);
}
}
pub fn build_sync_outbound(&self) -> crate::command::chat::remote::protocol::WsOutbound {
use crate::command::chat::remote::protocol::{SyncMessage, SyncToolCall, WsOutbound};
let messages: Vec<SyncMessage> = self
.state
.session
.messages
.iter()
.map(|m| SyncMessage {
role: m.role.clone(),
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: String) {
let text = content.trim().to_string();
if text.is_empty() {
return;
}
if self.state.is_loading {
use crate::command::chat::storage::ChatMessage;
self.state
.session
.messages
.push(ChatMessage::text("user", &text));
{
let mut pending = crate::util::safe_lock(
&self.state.pending_user_messages,
"inject_remote_message::pending",
);
pending.push(ChatMessage::text("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 build_current_system_prompt(&self) -> Option<String> {
use crate::command::chat::agent_md;
use crate::command::chat::storage::{
load_memory, load_soul, load_style, load_system_prompt,
};
let template = load_system_prompt()?;
let skills_summary = skill::build_skills_summary(
&self.state.loaded_skills,
&self.state.agent_config.disabled_skills,
);
let tools_summary = self
.tool_registry
.build_tools_summary(&self.state.agent_config.disabled_tools);
let style_text = load_style().unwrap_or_else(|| "(未设置)".to_string());
let memory_text = load_memory().unwrap_or_default();
let soul_text = load_soul().unwrap_or_default();
let agent_md_text = agent_md::load_agent_md();
let current_dir = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".to_string());
let skill_dir = skills_dir().to_string_lossy().to_string();
let project_skill_dir = skill::project_skills_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let tasks_summary =
crate::command::chat::tools::task::build_tasks_summary(&self.task_manager);
let background_summary = crate::command::chat::tools::background::build_running_summary(
&self.background_manager,
);
let session_state_summary = self.tool_registry.build_session_state_summary();
let teammates_summary = self
.teammate_manager
.lock()
.map(|m| m.team_summary())
.unwrap_or_default();
let resolved = apply_static_placeholders(
&template,
&skills_summary,
&tools_summary,
&style_text,
&memory_text,
&soul_text,
&agent_md_text,
¤t_dir,
&skill_dir,
&project_skill_dir,
)
.replace("{{.tasks}}", &tasks_summary)
.replace("{{.background_tasks}}", &background_summary)
.replace("{{.session_state}}", &session_state_summary)
.replace("{{.teammates}}", &teammates_summary);
Some(resolved)
}
pub fn build_api_messages(&self) -> Vec<ChatMessage> {
let max_history = self.state.agent_config.max_history_messages;
let msgs = &self.state.session.messages;
if msgs.len() <= max_history {
return msgs.clone();
}
let mut start = msgs.len() - max_history;
while start > 0
&& (msgs[start].role == ROLE_TOOL
|| (msgs[start].role == ROLE_ASSISTANT && msgs[start].tool_calls.is_some()))
{
start -= 1;
}
msgs[start..].to_vec()
}
pub fn send_message(&mut self) {
let text = self.ui.input_text().trim().to_string();
if text.is_empty() {
return;
}
self.ui.at_popup_active = false;
self.ui.file_popup_active = false;
self.ui.skill_popup_active = false;
self.ui.clear_input();
self.send_message_internal(text);
}
pub fn send_message_internal(&mut self, text: String) {
let hook_result = {
let has_hooks = self
.hook_manager
.lock()
.map(|m| m.has_hooks_for(HookEvent::PreSendMessage))
.unwrap_or(false);
if has_hooks {
let ctx = HookContext {
event: HookEvent::PreSendMessage,
user_input: Some(text.clone()),
messages: Some(self.state.session.messages.clone()),
cwd: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".to_string()),
..Default::default()
};
if let Ok(manager) = self.hook_manager.lock() {
manager.execute(HookEvent::PreSendMessage, ctx)
} else {
None
}
} else {
None
}
};
let text = if let Some(result) = hook_result {
if result.abort {
self.show_toast("消息发送被 hook 拦截", true);
return;
}
result.user_input.unwrap_or(text)
} else {
text
};
let text = command::expand_command_mentions(
&text,
&self.state.loaded_commands,
&self.state.agent_config.disabled_commands,
);
self.state
.session
.messages
.push(ChatMessage::text("user", &text));
self.ui.auto_scroll = true;
self.ui.scroll_offset = u16::MAX;
{
let has_hooks = self
.hook_manager
.lock()
.map(|m| m.has_hooks_for(HookEvent::PostSendMessage))
.unwrap_or(false);
if has_hooks {
let ctx = HookContext {
event: HookEvent::PostSendMessage,
user_input: Some(text.clone()),
messages: Some(self.state.session.messages.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(&self.hook_manager),
HookEvent::PostSendMessage,
ctx,
);
}
}
let provider = match self.active_provider() {
Some(p) => p.clone(),
None => {
self.show_toast("未配置模型提供方,请先编辑配置文件", true);
return;
}
};
{
let mut p = safe_lock(&self.agent_tool_provider, "send_message::agent_provider");
*p = provider.clone();
}
self.state.is_loading = true;
self.ui.last_rendered_streaming_len = 0;
self.ui.last_stream_render_time = std::time::Instant::now();
self.ui.msg_lines_cache = None;
self.tool_executor.reset();
let api_messages = self.build_api_messages();
{
let mut pending = safe_lock(
&self.state.pending_user_messages,
"send_message::pending_user_messages",
);
pending.clear();
}
{
let mut sc = safe_lock(
&self.state.streaming_content,
"send_message::streaming_content",
);
sc.clear();
}
let streaming_content = Arc::clone(&self.state.streaming_content);
let tools_enabled = self.state.agent_config.tools_enabled;
let max_tool_rounds = self.state.agent_config.max_tool_rounds;
let tools = if tools_enabled {
self.tool_registry
.to_openai_tools_filtered(&self.state.agent_config.disabled_tools)
} else {
vec![]
};
let pending_user_messages = Arc::clone(&self.state.pending_user_messages);
let background_manager = Arc::clone(&self.background_manager);
let compact_config = self.state.agent_config.compact.clone();
let loaded_skills = self.state.loaded_skills.clone();
let disabled_skills = self.state.agent_config.disabled_skills.clone();
let disabled_tools = self.state.agent_config.disabled_tools.clone();
let tool_registry = Arc::clone(&self.tool_registry);
let system_prompt_fn: Arc<dyn Fn() -> Option<String> + Send + Sync> = Arc::new(move || {
use crate::command::chat::agent_md;
use crate::command::chat::storage::{
load_memory, load_soul, load_style, load_system_prompt,
};
let template = load_system_prompt()?;
let skills_summary = skill::build_skills_summary(&loaded_skills, &disabled_skills);
let tools_summary = tool_registry.build_tools_summary(&disabled_tools);
let style_text = load_style().unwrap_or_else(|| "(未设置)".to_string());
let memory_text = load_memory().unwrap_or_default();
let soul_text = load_soul().unwrap_or_default();
let agent_md_text = agent_md::load_agent_md();
let current_dir = std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".to_string());
let skill_dir = skills_dir().to_string_lossy().to_string();
let project_skill_dir = skill::project_skills_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
Some(apply_static_placeholders(
&template,
&skills_summary,
&tools_summary,
&style_text,
&memory_text,
&soul_text,
&agent_md_text,
¤t_dir,
&skill_dir,
&project_skill_dir,
))
});
let hook_manager_clone = match self.hook_manager.lock() {
Ok(manager) => manager.clone(),
Err(_) => HookManager::default(),
};
let todo_manager = Arc::clone(&self.todo_manager);
{
let mut shared = safe_lock(&self.shared_agent_messages, "start_agent::clear_shared");
shared.clear();
}
self.shared_messages_read_cursor = 0;
let agent_config = AgentLoopConfig {
provider,
max_tool_rounds,
compact_config,
hook_manager: hook_manager_clone,
cancel_token: CancellationToken::new(),
};
let agent_shared = AgentSharedState {
streaming_content,
pending_user_messages,
background_manager,
todo_manager,
shared_messages: Arc::clone(&self.shared_agent_messages),
context_tokens: Arc::clone(&self.context_tokens),
invoked_skills: Arc::clone(&self.invoked_skills),
};
let (handle, tool_result_tx) = AgentHandle::spawn(
agent_config,
agent_shared,
api_messages,
tools,
system_prompt_fn,
);
self.agent = Some(handle);
self.tool_executor.tool_result_tx = Some(tool_result_tx);
}
pub fn poll_stream_actions(&mut self) -> Vec<Action> {
let mut actions = Vec::new();
{
let shared = safe_lock(&self.shared_agent_messages, "poll::shared_msgs");
let new_count = shared.len();
if new_count > self.shared_messages_read_cursor {
for msg in &shared[self.shared_messages_read_cursor..] {
self.state.session.messages.push(msg.clone());
}
self.shared_messages_read_cursor = new_count;
self.ui.msg_lines_cache = None;
self.ui.auto_scroll = true;
self.ui.scroll_offset = u16::MAX;
}
}
if self.agent.is_none() {
return actions;
}
if self.ui.mode == ChatMode::ToolConfirm {
let completed = self.tool_executor.poll_results();
for (name, output, is_error) in completed {
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::ToolResult {
name,
output,
is_error,
},
);
}
if let Some(ref rx) = self.ask_request_rx
&& let Ok(ask_req) = rx.try_recv()
{
self.init_ask_mode(ask_req);
self.ui.msg_lines_cache = None;
}
return actions;
}
if self.tool_executor.pending_tool_execution {
self.tool_executor.pending_tool_execution = false;
if self.ws_bridge.is_some() {
for tc in &self.tool_executor.active_tool_calls {
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::ToolCall {
name: tc.tool_name.clone(),
arguments: tc.arguments.clone(),
},
);
}
}
for tc in &self.tool_executor.active_tool_calls {
if let ToolExecStatus::Failed(ref msg) = tc.status
&& let Some(ref tx) = self.tool_executor.tool_result_tx
{
let _ = tx.send(ToolResultMsg {
tool_call_id: tc.tool_call_id.clone(),
result: msg.clone(),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
});
}
}
let first_confirm_idx = self
.tool_executor
.active_tool_calls
.iter()
.position(|tc| matches!(tc.status, ToolExecStatus::PendingConfirm));
if let Some(idx) = first_confirm_idx {
self.tool_executor.pending_tool_idx = idx;
self.tool_executor.tool_confirm_entered_at = std::time::Instant::now();
self.tool_executor.execute_batch(&self.tool_registry);
self.ui.tool_interact_selected = 0;
self.ui.tool_interact_typing = false;
self.ui.tool_interact_input.clear();
self.ui.tool_interact_cursor = 0;
self.ui.tool_ask_mode = false;
self.ui.tool_ask_questions.clear();
self.ui.tool_ask_current_idx = 0;
self.ui.tool_ask_answers.clear();
self.ui.tool_ask_selections.clear();
self.ui.tool_ask_cursor = 0;
actions.push(Action::EnterMode(ChatMode::ToolConfirm));
write_info_log(
"poll_stream",
&format!(
"进入 ToolConfirm 模式, pending_tool_idx={}, active_tool_calls={}, tools_executing_count={}",
self.tool_executor.pending_tool_idx,
self.tool_executor.active_tool_calls.len(),
self.tool_executor.tools_executing_count,
),
);
} else {
write_info_log(
"poll_stream",
&format!(
"无需确认的工具, 直接执行, active_tool_calls={}",
self.tool_executor.active_tool_calls.len(),
),
);
self.tool_executor.execute_batch(&self.tool_registry);
}
return actions;
}
let completed = self.tool_executor.poll_results();
for (name, output, is_error) in completed {
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::ToolResult {
name,
output,
is_error,
},
);
}
if let Some(ref rx) = self.ask_request_rx
&& let Ok(ask_req) = rx.try_recv()
{
self.init_ask_mode(ask_req);
actions.push(Action::EnterMode(ChatMode::ToolConfirm));
self.ui.msg_lines_cache = None;
return actions;
}
if let Some(ref agent) = self.agent {
let msgs = agent.poll();
for msg in msgs {
match msg {
StreamMsg::Chunk => {
actions.push(Action::StreamChunk);
}
StreamMsg::ToolCallRequest(tool_calls) => {
self.tool_executor.active_tool_calls.clear();
self.tool_executor.pending_tool_idx = 0;
for mut tc in tool_calls {
{
let has_hooks = self
.hook_manager
.lock()
.map(|m| m.has_hooks_for(HookEvent::PreToolExecution))
.unwrap_or(false);
if has_hooks {
let ctx = HookContext {
event: HookEvent::PreToolExecution,
tool_name: Some(tc.name.clone()),
tool_arguments: Some(tc.arguments.clone()),
cwd: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".to_string()),
..Default::default()
};
if let Ok(manager) = self.hook_manager.lock()
&& let Some(result) =
manager.execute(HookEvent::PreToolExecution, ctx)
{
if result.abort {
self.tool_executor.active_tool_calls.push(
ToolCallStatus {
tool_call_id: tc.id.clone(),
tool_name: tc.name.clone(),
arguments: tc.arguments.clone(),
confirm_message: format!(
"🚫 {} 被 hook 拦截",
tc.name
),
status: ToolExecStatus::Failed(
"该工具调用被 hook 拦截".to_string(),
),
},
);
continue;
}
if let Some(new_args) = result.tool_arguments {
tc.arguments = new_args;
}
}
}
}
if self.jcli_config.is_denied(&tc.name, &tc.arguments) {
self.tool_executor.active_tool_calls.push(ToolCallStatus {
tool_call_id: tc.id.clone(),
tool_name: tc.name.clone(),
arguments: tc.arguments.clone(),
confirm_message: format!(
"🚫 {} 被 .jcli/ 权限配置拒绝",
tc.name
),
status: ToolExecStatus::Failed(
"该命令被 .jcli/ 权限配置拒绝".to_string(),
),
});
continue;
}
let sandbox_outside = self.sandbox.is_outside(&tc.name, &tc.arguments);
let confirm_msg = if sandbox_outside {
self.sandbox.outside_message(&tc.name, &tc.arguments)
} else if let Some(tool) = self.tool_registry.get(&tc.name) {
tool.confirmation_message(&tc.arguments)
} else {
format!("调用工具 {} 参数: {}", tc.name, tc.arguments)
};
let tool_needs_confirm = self
.tool_registry
.get(&tc.name)
.map(|t| t.requires_confirmation())
.unwrap_or(false);
let needs_confirm = (tool_needs_confirm || sandbox_outside)
&& !self.jcli_config.is_allowed(&tc.name, &tc.arguments);
self.tool_executor.active_tool_calls.push(ToolCallStatus {
tool_call_id: tc.id.clone(),
tool_name: tc.name.clone(),
arguments: tc.arguments.clone(),
confirm_message: confirm_msg,
status: if needs_confirm {
ToolExecStatus::PendingConfirm
} else {
ToolExecStatus::Executing
},
});
}
self.tool_executor.pending_tool_execution = true;
break;
}
StreamMsg::Done => {
actions.push(Action::StreamDone);
break;
}
StreamMsg::Error(e) => {
actions.push(Action::StreamError(e));
break;
}
StreamMsg::Cancelled => {
actions.push(Action::StreamCancelled);
break;
}
StreamMsg::Retrying {
attempt,
max_attempts,
delay_ms,
error,
} => {
actions.push(Action::StreamRetrying {
attempt,
max_attempts,
delay_ms,
error,
});
}
}
}
}
actions
}
fn init_ask_mode(&mut self, ask_req: AskRequest) {
if self.ws_bridge.is_some() {
let questions: Vec<crate::command::chat::remote::protocol::AskQuestionInfo> = ask_req
.questions
.iter()
.map(
|q| crate::command::chat::remote::protocol::AskQuestionInfo {
question: q.question.clone(),
header: q.header.clone(),
options: q
.options
.iter()
.map(|o| crate::command::chat::remote::protocol::AskOptionInfo {
label: o.label.clone(),
description: o.description.clone(),
})
.collect(),
multi_select: q.multi_select,
},
)
.collect();
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::AskRequest { questions },
);
self.broadcast_ws(crate::command::chat::remote::protocol::WsOutbound::Status {
state: "ask".to_string(),
});
}
self.ui.tool_ask_mode = true;
self.ui.tool_ask_questions = ask_req.questions;
self.ui.tool_ask_current_idx = 0;
self.ui.tool_ask_answers = Vec::new();
self.ask_response_tx = Some(ask_req.response_tx);
self.init_ask_question_state();
self.ui.tool_interact_selected = 0;
self.ui.tool_interact_typing = false;
self.ui.tool_interact_input.clear();
self.ui.tool_interact_cursor = 0;
}
pub fn init_ask_question_state(&mut self) {
if let Some(q) = self.ui.tool_ask_questions.get(self.ui.tool_ask_current_idx) {
self.ui.tool_ask_selections = vec![false; q.options.len() + 1];
self.ui.tool_ask_cursor = 0;
if q.options.is_empty() {
self.ui.tool_interact_typing = true;
self.ui.tool_interact_input.clear();
self.ui.tool_interact_cursor = 0;
} else {
self.ui.tool_interact_typing = false;
self.ui.tool_interact_input.clear();
self.ui.tool_interact_cursor = 0;
}
}
}
pub fn ask_submit_answer(&mut self, answer: AskAnswer) {
let total = self.ui.tool_ask_questions.len();
if self.ui.tool_ask_current_idx < self.ui.tool_ask_answers.len() {
self.ui.tool_ask_answers[self.ui.tool_ask_current_idx] = answer;
} else {
self.ui.tool_ask_answers.push(answer);
}
if self.ui.tool_ask_current_idx + 1 < total {
self.ui.tool_ask_current_idx += 1;
self.init_ask_question_state();
} else {
let mut answers_map = serde_json::Map::new();
for (i, q) in self.ui.tool_ask_questions.iter().enumerate() {
if let Some(ans) = self.ui.tool_ask_answers.get(i) {
let val = match ans {
AskAnswer::Selected(indices) => {
let labels: Vec<&str> = indices
.iter()
.filter_map(|&idx| q.options.get(idx).map(|o| o.label.as_str()))
.collect();
labels.join(", ")
}
AskAnswer::FreeText(text) => text.clone(),
};
answers_map.insert(q.question.clone(), serde_json::Value::String(val));
}
}
let response = serde_json::json!({ "answers": answers_map }).to_string();
if let Some(tx) = self.ask_response_tx.take() {
let _ = tx.send(response);
}
self.ui.tool_ask_mode = false;
self.ui.tool_ask_questions.clear();
self.ui.tool_ask_current_idx = 0;
self.ui.tool_ask_answers.clear();
self.ui.tool_ask_selections.clear();
self.ui.tool_ask_cursor = 0;
if !self.tool_executor.has_pending_confirm() {
self.ui.mode = ChatMode::Chat;
}
}
}
fn finish_loading(&mut self, had_error: bool, was_cancelled: bool) {
if let Some(ref agent) = self.agent {
agent.cancel();
}
self.tool_executor.tool_result_tx = None;
self.agent = None;
self.tool_executor.tools_executing_count = 0;
self.state.is_loading = false;
self.ui.last_rendered_streaming_len = 0;
self.ui.msg_lines_cache = None;
self.tool_executor.active_tool_calls.clear();
if was_cancelled {
let content = {
let sc = safe_lock(
&self.state.streaming_content,
"finish_loading::streaming_content",
);
sc.clone()
};
if !content.is_empty() {
let cancelled_content = format!("{}\n\n*[已取消]*", content);
self.state
.session
.messages
.push(ChatMessage::text(ROLE_ASSISTANT, cancelled_content));
}
safe_lock(
&self.state.streaming_content,
"finish_loading::streaming_content_clear",
)
.clear();
if self.ui.auto_scroll {
self.ui.scroll_offset = u16::MAX;
}
self.show_toast("已取消", false);
} else if !had_error {
let mut content = {
let sc = safe_lock(
&self.state.streaming_content,
"finish_loading::streaming_content_done",
);
sc.clone()
};
if !content.is_empty() {
{
let has_hooks = self
.hook_manager
.lock()
.map(|m| m.has_hooks_for(HookEvent::PostLlmResponse))
.unwrap_or(false);
if has_hooks {
let ctx = HookContext {
event: HookEvent::PostLlmResponse,
assistant_output: Some(content.clone()),
messages: Some(self.state.session.messages.clone()),
cwd: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".to_string()),
..Default::default()
};
if let Ok(manager) = self.hook_manager.lock()
&& let Some(result) = manager.execute(HookEvent::PostLlmResponse, ctx)
&& let Some(new_msg) = result.assistant_output
{
content = new_msg;
}
}
}
self.state
.session
.messages
.push(ChatMessage::text(ROLE_ASSISTANT, content));
safe_lock(
&self.state.streaming_content,
"finish_loading::streaming_content_done_clear",
)
.clear();
self.show_toast("回复完成 ✓", false);
}
if self.ui.auto_scroll {
self.ui.scroll_offset = u16::MAX;
}
} else {
safe_lock(
&self.state.streaming_content,
"finish_loading::streaming_content_error",
)
.clear();
}
self.persist_new_messages();
let next_task = {
let mut tasks = safe_lock(&self.state.queued_tasks, "finish_loading::queued_tasks");
if !tasks.is_empty() {
Some(tasks.remove(0))
} else {
None
}
};
if let Some(task_text) = next_task {
self.send_message_internal(task_text);
}
}
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);
}
fn persist_new_messages(&mut self) {
let start = self.last_persisted_len;
let msgs: Vec<_> = self.state.session.messages[start..].to_vec();
for msg in msgs {
append_session_event(&self.session_id, &SessionEvent::Msg(msg));
}
self.last_persisted_len = self.state.session.messages.len();
}
pub fn clear_session(&mut self) {
self.persist_new_messages();
let new_id = generate_session_id();
self.session_id = new_id.clone();
self.state.session.messages.clear();
self.last_persisted_len = 0;
self.ui.scroll_offset = 0;
self.ui.msg_lines_cache = None;
if let Ok(mut ct) = self.context_tokens.lock() {
*ct = 0;
}
let sync = self.build_sync_outbound();
self.broadcast_ws(sync);
self.broadcast_ws(
crate::command::chat::remote::protocol::WsOutbound::SessionSwitched {
session_id: new_id,
},
);
self.show_toast("已创建新对话", false);
}
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 start_archive_confirm(&mut self) {
use crate::command::chat::archive::generate_default_archive_name;
self.ui.archive_default_name = generate_default_archive_name();
self.ui.archive_custom_name = String::new();
self.ui.archive_editing_name = false;
self.ui.archive_edit_cursor = 0;
self.ui.mode = ChatMode::ArchiveConfirm;
}
pub fn start_archive_list(&mut self) {
use crate::command::chat::archive::list_archives;
self.ui.archives = list_archives();
self.ui.archive_list_index = 0;
self.ui.restore_confirm_needed = false;
self.ui.mode = ChatMode::ArchiveList;
}
pub fn do_archive(&mut self, name: &str) {
use crate::command::chat::archive::create_archive;
match create_archive(name, self.state.session.messages.clone()) {
Ok(_) => {
self.clear_session();
self.show_toast(format!("对话已归档: {}", name), false);
}
Err(e) => {
self.show_toast(e, true);
}
}
self.ui.mode = ChatMode::Chat;
}
pub fn do_restore(&mut self) {
use crate::command::chat::archive::restore_archive;
let archive_name = self
.ui
.archives
.get(self.ui.archive_list_index)
.map(|a| a.name.clone());
if let Some(archive_name) = archive_name {
match restore_archive(&archive_name) {
Ok(messages) => {
self.state.session.messages = messages.clone();
self.ui.scroll_offset = u16::MAX;
self.ui.msg_lines_cache = None;
self.ui.clear_input();
append_session_event(&self.session_id, &SessionEvent::Restore { messages });
self.last_persisted_len = self.state.session.messages.len();
self.show_toast(format!("已还原归档: {}", archive_name), false);
}
Err(e) => {
self.show_toast(e, true);
}
}
}
self.ui.mode = ChatMode::Chat;
}
pub fn do_delete_archive(&mut self) {
use crate::command::chat::archive::delete_archive;
if let Some(archive) = self.ui.archives.get(self.ui.archive_list_index) {
match delete_archive(&archive.name) {
Ok(_) => {
self.show_toast(format!("归档已删除: {}", archive.name), false);
self.ui.archives = crate::command::chat::archive::list_archives();
if self.ui.archive_list_index >= self.ui.archives.len()
&& self.ui.archive_list_index > 0
{
self.ui.archive_list_index -= 1;
}
}
Err(e) => {
self.show_toast(e, true);
}
}
}
}
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;
}
}
}