use super::super::agent_md;
use super::super::input_thread::InputThread;
use super::super::remote;
use super::super::remote::bridge::WsBridge;
use super::super::remote::protocol::{WsInbound, WsOutbound};
use super::super::storage::{load_style, load_system_prompt, save_style, save_system_prompt};
use super::super::ui::draw_chat_ui;
use super::{
handle_agent_perm_confirm_mode, handle_archive_confirm_mode, handle_archive_list_mode,
handle_browse_mode, handle_chat_mode, handle_config_mode, handle_plan_approval_confirm_mode,
handle_select_model, handle_select_theme, handle_tool_confirm_mode,
};
use crate::command::chat::app::{Action, ChatApp, ChatMode, CursorDirection};
use crate::error;
use crate::util::safe_lock;
use crossterm::{
event::{
self, Event, KeyCode, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseEventKind,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io;
fn restore_terminal() {
let _ = terminal::disable_raw_mode();
let _ = execute!(
io::stdout(),
PopKeyboardEnhancementFlags,
event::DisableMouseCapture,
event::DisableBracketedPaste,
LeaveAlternateScreen
);
}
fn dispatch_event(
app: &mut ChatApp,
evt: Event,
needs_redraw: &mut bool,
mouse_capture_enabled: &mut bool,
) -> bool {
match evt {
Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('m') {
*mouse_capture_enabled = !*mouse_capture_enabled;
if *mouse_capture_enabled {
let _ = execute!(io::stdout(), event::EnableMouseCapture);
app.show_toast("鼠标: 滚轮滚动 (Shift+拖拽可选中)", false);
} else {
let _ = execute!(io::stdout(), event::DisableMouseCapture);
app.show_toast("鼠标: 自由选中 (Ctrl+M 切回滚轮)", false);
}
*needs_redraw = true;
return false;
}
*needs_redraw = true;
match app.ui.mode {
ChatMode::Chat => {
if handle_chat_mode(app, key) {
return true; }
}
ChatMode::SelectModel => handle_select_model(app, key),
ChatMode::SelectTheme => handle_select_theme(app, key),
ChatMode::Browse => handle_browse_mode(app, key),
ChatMode::Help => {
app.update(Action::ExitToChat);
}
ChatMode::Config => handle_config_mode(app, key),
ChatMode::ArchiveConfirm => handle_archive_confirm_mode(app, key),
ChatMode::ArchiveList => handle_archive_list_mode(app, key),
ChatMode::ToolConfirm => handle_tool_confirm_mode(app, key),
ChatMode::AgentPermConfirm => handle_agent_perm_confirm_mode(app, key),
ChatMode::PlanApprovalConfirm => handle_plan_approval_confirm_mode(app, key),
}
false
}
Event::Paste(text) => {
if matches!(app.ui.mode, ChatMode::Chat) {
for c in text.chars() {
if c == '\r' {
continue; }
if c == '\n' {
app.ui.input_buffer.insert_newline();
} else {
app.ui.input_buffer.insert_char(c);
}
}
*needs_redraw = true;
} else if matches!(app.ui.mode, ChatMode::Config) && app.ui.config_editing {
for c in text.chars() {
if c == '\n' || c == '\r' {
continue; }
app.update(Action::ConfigEditChar(c));
}
*needs_redraw = true;
}
false
}
Event::Resize(_, _) => {
*needs_redraw = true;
false
}
Event::Mouse(mouse) if *mouse_capture_enabled => match mouse.kind {
MouseEventKind::ScrollUp => {
app.update(Action::Scroll(CursorDirection::Up));
*needs_redraw = true;
false
}
MouseEventKind::ScrollDown => {
app.update(Action::Scroll(CursorDirection::Down));
*needs_redraw = true;
false
}
_ => false,
},
_ => false,
}
}
pub fn run_chat_tui(remote_mode: bool, port: u16) {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
original_hook(info);
}));
let ws_bridge = if remote_mode {
match remote::start_remote_and_wait(port) {
Ok((bridge, _url)) => Some(bridge),
Err(e) => {
if e.kind() == std::io::ErrorKind::Interrupted {
return;
}
crate::error!("远程服务启动失败: {}", e);
None
}
}
} else {
None
};
let result = run_chat_tui_internal(ws_bridge);
let _ = std::panic::take_hook();
if let Err(e) = result {
restore_terminal();
error!("✖️ Chat TUI 启动失败: {}", e);
}
}
fn generate_session_id() -> String {
super::super::storage::generate_session_id()
}
pub fn run_chat_tui_internal(ws_bridge: Option<WsBridge>) -> io::Result<()> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(
stdout,
EnterAlternateScreen,
event::EnableMouseCapture,
event::EnableBracketedPaste
)?;
let _ = execute!(
stdout,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
let mut mouse_capture_enabled = true;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let session_id = generate_session_id();
let mut app = ChatApp::new(session_id);
app.ws_bridge = ws_bridge;
app.remote_connected = app
.ws_bridge
.as_ref()
.map(|ws| ws.has_client())
.unwrap_or(false);
if app.state.agent_config.auto_restore_session
&& let Some(latest_id) = super::super::storage::find_latest_session_id()
{
let session = super::super::storage::load_session(&latest_id);
if !session.messages.is_empty() {
app.session_id = latest_id;
app.last_persisted_len = session.messages.len();
app.state.session = session;
app.restore_session_state();
app.ui.scroll_offset = u16::MAX; app.ui.msg_lines_cache = None;
}
}
if app.state.agent_config.providers.is_empty() {
use super::super::storage::{
AgentConfig, ModelProvider, agent_config_path, save_agent_config,
};
use super::super::theme::ThemeName;
if !agent_config_path().exists() {
let example = AgentConfig {
providers: vec![ModelProvider {
name: "OpenAI".to_string(),
api_base: "https://api.openai.com/v1".to_string(),
api_key: "sk-your-api-key".to_string(),
model: "gpt-4o".to_string(),
supports_vision: false,
}],
active_index: 0,
system_prompt: None,
max_history_messages: 20,
max_context_tokens: 0,
theme: ThemeName::default(),
tools_enabled: false,
max_tool_rounds: 10,
style: None,
tool_confirm_timeout: 0,
disabled_tools: Vec::new(),
disabled_skills: Vec::new(),
disabled_commands: Vec::new(),
compact: Default::default(),
auto_restore_session: false,
};
let _ = save_agent_config(&example);
app.state.agent_config = example;
}
app.ui.mode = ChatMode::Config;
app.show_toast("尚未配置模型,请先完成配置 (Esc 保存退出)", true);
}
let mut needs_redraw = true; let mut last_render_time = std::time::Instant::now();
const RENDER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
let input_thread = InputThread::spawn();
loop {
let had_toast = app.ui.toast.is_some();
app.update(Action::TickToast);
if had_toast && app.ui.toast.is_none() {
needs_redraw = true;
}
let was_loading = app.state.is_loading;
let stream_actions = app.poll_stream_actions();
if !stream_actions.is_empty() {
needs_redraw = true;
}
for action in stream_actions {
app.update(action);
}
if app.ui.pending_agent_perm.is_none()
&& matches!(app.ui.mode, ChatMode::Chat)
&& let Some(req) = app.permission_queue.pop_pending()
{
app.ui.pending_agent_perm = Some(req);
app.ui.mode = ChatMode::AgentPermConfirm;
app.ui.msg_lines_cache = None;
needs_redraw = true;
}
if app.ui.pending_plan_approval.is_none()
&& matches!(app.ui.mode, ChatMode::Chat)
&& let Some(req) = app.plan_approval_queue.pop_pending()
{
app.ui.pending_plan_approval = Some(req);
app.ui.mode = ChatMode::PlanApprovalConfirm;
app.ui.msg_lines_cache = None;
needs_redraw = true;
}
if app.ws_bridge.is_some() {
let mut ws = app
.ws_bridge
.take()
.expect("ws_bridge checked is_some() above");
let mut ws_actions: Vec<(WsInbound,)> = Vec::new();
while let Some(msg) = ws.try_recv() {
ws_actions.push((msg,));
}
app.remote_connected = ws.has_client();
app.ws_bridge = Some(ws);
for (msg,) in ws_actions {
needs_redraw = true;
match msg {
WsInbound::SendMessage { content } => {
app.inject_remote_message(&content);
}
WsInbound::ToolConfirm { action, reason } => match action.as_str() {
"allow" => app.update(Action::ExecutePendingTool),
"allow_always" => app.update(Action::AllowAndExecutePendingTool),
"reject_with_reason" => {
let r = reason.unwrap_or_default();
app.update(Action::RejectPendingToolWithReason(r));
}
_ => app.update(Action::RejectPendingTool),
},
WsInbound::AskResponse { answers } => {
if app.ui.tool_ask_mode {
let response = serde_json::json!({ "answers": answers }).to_string();
if let Some(tx) = app.ask_response_tx.take() {
let _ = tx.send(response);
}
app.ui.tool_ask_mode = false;
app.ui.tool_ask_questions.clear();
app.ui.tool_ask_current_idx = 0;
app.ui.tool_ask_answers.clear();
app.ui.tool_ask_selections.clear();
app.ui.tool_ask_cursor = 0;
if !app.tool_executor.has_pending_confirm() {
app.ui.mode = ChatMode::Chat;
}
app.broadcast_ws(WsOutbound::Status {
state: "loading".to_string(),
});
}
}
WsInbound::Cancel => {
app.update(Action::CancelStream);
}
WsInbound::Sync => {
let sync = app.build_sync_outbound();
app.broadcast_ws(sync);
}
WsInbound::Ping => {
app.broadcast_ws(WsOutbound::Pong);
}
WsInbound::ListSessions => {
app.update(Action::ListSessions);
}
WsInbound::SwitchSession { session_id } => {
app.update(Action::SwitchSession { session_id });
}
WsInbound::NewSession => {
app.update(Action::NewSession);
}
WsInbound::KeyExchange { .. } => {}
}
}
}
if app.tool_executor.pending_tool_execution {
needs_redraw = true;
}
if app.ui.mode == ChatMode::ToolConfirm && app.state.agent_config.tool_confirm_timeout > 0 {
let elapsed = app.tool_executor.tool_confirm_entered_at.elapsed();
let timeout =
std::time::Duration::from_secs(app.state.agent_config.tool_confirm_timeout);
if elapsed >= timeout {
app.update(Action::ExecutePendingTool);
needs_redraw = true;
} else {
needs_redraw = true; }
}
let streaming_snapshot_len: usize = if app.state.is_loading {
let len = safe_lock(&app.state.streaming_content, "tui_loop::streaming_throttle").len();
let bytes_delta = len.saturating_sub(app.ui.last_rendered_streaming_len);
let time_elapsed = app.ui.last_stream_render_time.elapsed();
if bytes_delta >= 200
|| time_elapsed >= std::time::Duration::from_millis(150)
|| len == 0
{
needs_redraw = true;
}
len
} else {
if was_loading {
needs_redraw = true;
}
0
};
if app.ui.mode == ChatMode::ToolConfirm && app.state.agent_config.tool_confirm_timeout > 0 {
needs_redraw = true;
}
if needs_redraw {
if last_render_time.elapsed() >= RENDER_INTERVAL {
terminal.draw(|f| draw_chat_ui(f, &mut app))?;
needs_redraw = false;
last_render_time = std::time::Instant::now();
if app.state.is_loading {
app.ui.last_rendered_streaming_len = streaming_snapshot_len;
app.ui.last_stream_render_time = std::time::Instant::now();
}
}
}
#[allow(clippy::if_same_then_else)]
let poll_timeout = if app.state.is_loading {
std::time::Duration::from_millis(100)
} else if app.ui.mode == ChatMode::ToolConfirm {
std::time::Duration::from_millis(500)
} else {
std::time::Duration::from_millis(500)
};
let first = input_thread.rx.recv_timeout(poll_timeout);
if let Ok(evt) = first {
let mut should_quit =
dispatch_event(&mut app, evt, &mut needs_redraw, &mut mouse_capture_enabled);
if !should_quit {
while let Ok(evt) = input_thread.rx.try_recv() {
if dispatch_event(&mut app, evt, &mut needs_redraw, &mut mouse_capture_enabled)
{
should_quit = true;
break;
}
}
}
if should_quit {
break;
}
if app.ui.pending_system_prompt_edit {
app.ui.pending_system_prompt_edit = false;
input_thread.pause();
input_thread.drain();
let current_prompt = load_system_prompt().unwrap_or_default();
match crate::tui::editor_markdown::open_markdown_editor_on_terminal(
&mut terminal,
"编辑系统提示词 (System Prompt)",
¤t_prompt,
&app.ui.theme,
) {
Ok((Some(new_text), _)) => {
if save_system_prompt(&new_text) {
app.update(Action::ShowToast("系统提示词已更新".to_string(), false));
} else {
app.update(Action::ShowToast("系统提示词保存失败".to_string(), true));
}
}
Ok((None, _)) => {}
Err(e) => {
app.update(Action::ShowToast(format!("编辑器错误: {}", e), true));
}
}
input_thread.drain();
input_thread.resume();
needs_redraw = true;
}
if app.ui.pending_agent_md_edit {
app.ui.pending_agent_md_edit = false;
input_thread.pause();
input_thread.drain();
let current_agent_md =
std::fs::read_to_string(agent_md::agent_md_path()).unwrap_or_default();
match crate::tui::editor_markdown::open_markdown_editor_on_terminal(
&mut terminal,
"编辑项目指令 (AGENT.md)",
¤t_agent_md,
&app.ui.theme,
) {
Ok((Some(new_text), _)) => {
let path = agent_md::agent_md_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&path, &new_text) {
Ok(_) => {
app.update(Action::ShowToast("项目指令已更新".to_string(), false));
}
Err(_) => {
app.update(Action::ShowToast("项目指令保存失败".to_string(), true));
}
}
}
Ok((None, _)) => {}
Err(e) => {
app.update(Action::ShowToast(format!("编辑器错误: {}", e), true));
}
}
input_thread.drain();
input_thread.resume();
needs_redraw = true;
}
if app.ui.pending_style_edit {
app.ui.pending_style_edit = false;
input_thread.pause();
input_thread.drain();
let current_style = load_style().unwrap_or_default();
match crate::tui::editor_markdown::open_markdown_editor_on_terminal(
&mut terminal,
"编辑回复风格 (Style)",
¤t_style,
&app.ui.theme,
) {
Ok((Some(new_text), _)) => {
if save_style(&new_text) {
app.update(Action::ShowToast("回复风格已更新".to_string(), false));
} else {
app.update(Action::ShowToast("回复风格保存失败".to_string(), true));
}
}
Ok((None, _)) => {}
Err(e) => {
app.update(Action::ShowToast(format!("编辑器错误: {}", e), true));
}
}
input_thread.drain();
input_thread.resume();
needs_redraw = true;
}
}
}
input_thread.shutdown();
if !app.state.session.messages.is_empty() {
app.save_session_state();
}
if app.state.session.messages.is_empty() {
super::super::storage::delete_session(&app.session_id);
}
terminal::disable_raw_mode()?;
execute!(
terminal.backend_mut(),
PopKeyboardEnhancementFlags,
event::DisableMouseCapture,
event::DisableBracketedPaste,
LeaveAlternateScreen
)?;
{
use crate::command::chat::hook::{HookContext, HookEvent, HookManager};
let has_hooks = app
.hook_manager
.lock()
.map(|m| m.has_hooks_for(HookEvent::SessionEnd))
.unwrap_or(false);
if has_hooks {
let ctx = HookContext {
event: HookEvent::SessionEnd,
messages: Some(app.state.session.messages.clone()),
session_id: Some(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(
std::sync::Arc::clone(&app.hook_manager),
HookEvent::SessionEnd,
ctx,
);
}
}
Ok(())
}