diff --git a/BitFun-Installer/src/data/modelProviders.ts b/BitFun-Installer/src/data/modelProviders.ts index e5f7b42f..f61a25c3 100644 --- a/BitFun-Installer/src/data/modelProviders.ts +++ b/BitFun-Installer/src/data/modelProviders.ts @@ -45,8 +45,20 @@ export const PROVIDER_TEMPLATES: Record = { descriptionKey: 'model.providers.minimax.description', baseUrl: 'https://api.minimaxi.com/anthropic', format: 'anthropic', - models: ['MiniMax-M2.1', 'MiniMax-M2.1-lightning', 'MiniMax-M2'], + models: ['MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2.1-lightning', 'MiniMax-M2'], helpUrl: 'https://platform.minimax.io/', + baseUrlOptions: [ + { + url: 'https://api.minimaxi.com/anthropic', + format: 'anthropic', + noteKey: 'model.providers.minimax.urlOptions.default', + }, + { + url: 'https://api.minimaxi.com/v1', + format: 'openai', + noteKey: 'model.providers.minimax.urlOptions.openai', + }, + ], }, moonshot: { id: 'moonshot', @@ -98,8 +110,25 @@ export const PROVIDER_TEMPLATES: Record = { descriptionKey: 'model.providers.qwen.description', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', format: 'openai', - models: ['qwen3-max', 'qwen3-coder-plus', 'qwen3-coder-flash'], + models: ['qwen3.5-plus', 'glm-5', 'kimi-k2.5', 'MiniMax-M2.5', 'qwen3-max', 'qwen3-coder-plus', 'qwen3-coder-flash'], helpUrl: 'https://dashscope.console.aliyun.com/apiKey', + baseUrlOptions: [ + { + url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + format: 'openai', + noteKey: 'model.providers.qwen.urlOptions.default', + }, + { + url: 'https://coding.dashscope.aliyuncs.com/v1', + format: 'openai', + noteKey: 'model.providers.qwen.urlOptions.codingPlan', + }, + { + url: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', + format: 'anthropic', + noteKey: 'model.providers.qwen.urlOptions.codingPlanAnthropic', + }, + ], }, volcengine: { id: 'volcengine', diff --git a/BitFun-Installer/src/i18n/locales/en.json b/BitFun-Installer/src/i18n/locales/en.json index c3b95883..c90874fd 100644 --- a/BitFun-Installer/src/i18n/locales/en.json +++ b/BitFun-Installer/src/i18n/locales/en.json @@ -65,7 +65,11 @@ }, "minimax": { "name": "MiniMax", - "description": "MiniMax M2 series large language models" + "description": "MiniMax M2 series large language models", + "urlOptions": { + "default": "Anthropic Format - Default", + "openai": "OpenAI Compatible Format" + } }, "moonshot": { "name": "Moonshot AI", @@ -86,7 +90,12 @@ }, "qwen": { "name": "Qwen", - "description": "Alibaba Cloud Qwen3 series models" + "description": "Alibaba Cloud Qwen3 series models", + "urlOptions": { + "default": "OpenAI Format - Default", + "codingPlan": "OpenAI Format - Coding Plan", + "codingPlanAnthropic": "Anthropic Format - Coding Plan" + } }, "volcengine": { "name": "Volcano Engine", diff --git a/BitFun-Installer/src/i18n/locales/zh.json b/BitFun-Installer/src/i18n/locales/zh.json index 3b08d922..995fadfd 100644 --- a/BitFun-Installer/src/i18n/locales/zh.json +++ b/BitFun-Installer/src/i18n/locales/zh.json @@ -65,7 +65,11 @@ }, "minimax": { "name": "MiniMax", - "description": "MiniMax M2 系列大语言模型" + "description": "MiniMax M2 系列大语言模型", + "urlOptions": { + "default": "Anthropic格式-默认", + "openai": "OpenAI兼容格式" + } }, "moonshot": { "name": "月之暗面", @@ -86,7 +90,12 @@ }, "qwen": { "name": "通义千问", - "description": "阿里云通义千问 Qwen3 系列模型" + "description": "阿里云通义千问 Qwen3 系列模型", + "urlOptions": { + "default": "OpenAI格式-默认", + "codingPlan": "OpenAI格式-Coding Plan", + "codingPlanAnthropic": "Anthropic格式-Coding Plan" + } }, "volcengine": { "name": "火山引擎", diff --git a/src/apps/desktop/src/logging.rs b/src/apps/desktop/src/logging.rs index 34f34048..1ac11cda 100644 --- a/src/apps/desktop/src/logging.rs +++ b/src/apps/desktop/src/logging.rs @@ -13,9 +13,9 @@ use tauri_plugin_log::{fern, Target, TargetKind}; const SESSION_DIR_PATTERN: &str = r"^\d{8}T\d{6}$"; const MAX_LOG_SESSIONS: usize = 50; -const LOG_RETENTION_DAYS: i64 = 7; static SESSION_LOG_DIR: OnceLock = OnceLock::new(); -static CURRENT_LOG_LEVEL: AtomicU8 = AtomicU8::new(level_filter_to_u8(log::LevelFilter::Info)); +// Default to Debug in early development for easier diagnostics +static CURRENT_LOG_LEVEL: AtomicU8 = AtomicU8::new(level_filter_to_u8(log::LevelFilter::Debug)); fn get_thread_id() -> u64 { let thread_id = thread::current().id(); @@ -71,27 +71,9 @@ const fn u8_to_level_filter(value: u8) -> log::LevelFilter { } } -fn resolve_default_level(is_debug: bool) -> log::LevelFilter { - match std::env::var("BITFUN_LOG_LEVEL") { - Ok(val) => parse_log_level(&val).unwrap_or_else(|| { - eprintln!( - "Warning: Invalid BITFUN_LOG_LEVEL '{}', falling back to default", - val - ); - if is_debug { - log::LevelFilter::Debug - } else { - log::LevelFilter::Info - } - }), - Err(_) => { - if is_debug { - log::LevelFilter::Debug - } else { - log::LevelFilter::Info - } - } - } +// Default to Debug in early development for easier diagnostics +fn resolve_default_level(_is_debug: bool) -> log::LevelFilter { + log::LevelFilter::Debug } pub fn parse_log_level(value: &str) -> Option { @@ -287,10 +269,6 @@ fn format_log_plain( )) } -fn parse_session_timestamp(name: &str) -> Option { - chrono::NaiveDateTime::parse_from_str(name, "%Y%m%dT%H%M%S").ok() -} - pub async fn cleanup_old_log_sessions() { tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; @@ -333,29 +311,10 @@ async fn do_cleanup_log_sessions( return Ok(()); } - let now = Local::now().naive_local(); - let retention_threshold = now - chrono::Duration::days(LOG_RETENTION_DAYS); - let excess_count = session_dirs.len() - max_sessions; - let to_delete: Vec<_> = session_dirs - .into_iter() - .take(excess_count) - .filter(|name| { - parse_session_timestamp(name) - .map(|ts| ts < retention_threshold) - .unwrap_or(false) - }) - .collect(); - - if to_delete.is_empty() { - return Ok(()); - } + let to_delete: Vec<_> = session_dirs.into_iter().take(excess_count).collect(); - log::info!( - "Cleaning up {} old log session(s) older than {} days", - to_delete.len(), - LOG_RETENTION_DAYS - ); + log::info!("Cleaning up {} old log session(s)", to_delete.len()); for session_name in to_delete { let session_path = logs_root.join(&session_name); diff --git a/src/crates/core/src/agentic/agents/agentic_mode.rs b/src/crates/core/src/agentic/agents/agentic_mode.rs index 1d9ff534..103c415b 100644 --- a/src/crates/core/src/agentic/agents/agentic_mode.rs +++ b/src/crates/core/src/agentic/agents/agentic_mode.rs @@ -27,6 +27,7 @@ impl AgenticMode { "Skill".to_string(), "AskUserQuestion".to_string(), "Git".to_string(), + "TerminalControl".to_string(), ], } } diff --git a/src/crates/core/src/agentic/agents/cowork_mode.rs b/src/crates/core/src/agentic/agents/cowork_mode.rs index 513ee225..7edf4247 100644 --- a/src/crates/core/src/agentic/agents/cowork_mode.rs +++ b/src/crates/core/src/agentic/agents/cowork_mode.rs @@ -31,6 +31,7 @@ impl CoworkMode { "ReadLints".to_string(), "Git".to_string(), "Bash".to_string(), + "TerminalControl".to_string(), "WebFetch".to_string(), "WebSearch".to_string(), ], diff --git a/src/crates/core/src/agentic/agents/debug_mode.rs b/src/crates/core/src/agentic/agents/debug_mode.rs index 8df8ce4d..65970bee 100644 --- a/src/crates/core/src/agentic/agents/debug_mode.rs +++ b/src/crates/core/src/agentic/agents/debug_mode.rs @@ -364,6 +364,7 @@ Below is a snapshot of the current workspace's file structure. "MermaidInteractive".to_string(), "Log".to_string(), "ReadLints".to_string(), + "TerminalControl".to_string(), ] } diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index cfd402dd..372a526e 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -2,6 +2,7 @@ use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::infrastructure::events::event_system::get_global_event_system; +use crate::infrastructure::events::event_system::BackendEvent::ToolExecutionProgress; use crate::infrastructure::get_workspace_path; use crate::service::config::global::get_global_config_service; use crate::util::errors::{BitFunError, BitFunResult}; @@ -13,8 +14,10 @@ use serde_json::{json, Value}; use std::time::Instant; use terminal_core::shell::{ShellDetector, ShellType}; use terminal_core::{ - CommandStreamEvent, ExecuteCommandRequest, SignalRequest, TerminalApi, TerminalBindingOptions, + CommandStreamEvent, ExecuteCommandRequest, SendCommandRequest, SignalRequest, TerminalApi, + TerminalBindingOptions, TerminalSessionBinding, }; +use tokio::io::AsyncWriteExt; use tool_runtime::util::ansi_cleaner::strip_ansi; const MAX_OUTPUT_LENGTH: usize = 30000; @@ -102,9 +105,18 @@ impl BashTool { } } - fn render_result(&self, output_text: &str, interrupted: bool, exit_code: i32) -> String { + fn render_result( + &self, + session_id: &str, + output_text: &str, + interrupted: bool, + exit_code: i32, + ) -> String { let mut result_string = String::new(); + // Session ID + result_string.push_str(&format!("{}", session_id)); + // Exit code result_string.push_str(&format!("{}", exit_code)); @@ -169,10 +181,12 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required and MUST be a single-line command. - DO NOT use multiline commands or HEREDOC syntax (e.g., <` tag identifying the terminal session. The persistent shell session ID remains constant throughout the entire conversation; background sessions each have their own unique ID. + - Avoid using this tool with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - File search: Use Glob (NOT find or ls) - Content search: Use Grep (NOT grep or rg) @@ -203,9 +217,13 @@ Usage notes: "type": "string", "description": "The command to execute" }, - "timeout": { + "timeout_ms": { "type": "number", - "description": "Optional timeout in milliseconds (max 600000)" + "description": "Optional timeout in milliseconds (default 120000, max 600000). Ignored when run_in_background is true." + }, + "run_in_background": { + "type": "boolean", + "description": "If true, runs the command in a new dedicated background terminal session and returns the session ID immediately without waiting for completion. Useful for long-running processes like dev servers or file watchers. timeout_ms is ignored when this is true." }, "description": { "type": "string", @@ -235,6 +253,10 @@ Usage notes: _context: Option<&ToolUseContext>, ) -> ValidationResult { let command = input.get("command").and_then(|v| v.as_str()); + let run_in_background = input + .get("run_in_background") + .and_then(|v| v.as_bool()) + .unwrap_or(false); if let Some(cmd) = command { let parts: Vec<&str> = cmd.split_whitespace().collect(); @@ -261,6 +283,18 @@ Usage notes: }; } + // Warn if timeout_ms is set alongside run_in_background + if run_in_background && input.get("timeout_ms").is_some() { + return ValidationResult { + result: true, + message: Some( + "Note: timeout_ms is ignored when run_in_background is true".to_string(), + ), + error_code: None, + meta: None, + }; + } + ValidationResult { result: true, message: None, @@ -308,7 +342,10 @@ Usage notes: .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("command is required".to_string()))?; - let timeout_ms = input.get("timeout").and_then(|v| v.as_u64()); + let run_in_background = input + .get("run_in_background") + .and_then(|v| v.as_bool()) + .unwrap_or(false); // Get session_id (for binding terminal session) let chat_session_id = context @@ -321,25 +358,46 @@ Usage notes: .tool_call_id .clone() .unwrap_or_else(|| format!("bash_{}", uuid::Uuid::new_v4())); - let tool_name = self.name().to_string(); - - debug!( - "Bash tool executing command: {}, session_id: {}, tool_id: {}", - command_str, chat_session_id, tool_use_id - ); // 1. Get Terminal API let terminal_api = TerminalApi::from_singleton() .map_err(|e| BitFunError::tool(format!("Terminal not initialized: {}", e)))?; - // 2. Resolve shell type (falls back to system default if configured shell doesn't support integration) + // 2. Resolve shell type let shell_type = Self::resolve_shell().await.shell_type; - // 3. Get or create terminal session let binding = terminal_api.session_manager().binding(); let workspace_path = get_workspace_path().map(|p| p.to_string_lossy().to_string()); - let terminal_session_id = binding + if run_in_background { + // For background commands, inherit CWD from an already-running primary session + // if one exists; otherwise fall back to workspace path. This avoids forcing a + // primary session to be created just to read its working directory. + let initial_cwd = if let Some(existing_id) = binding.get(chat_session_id) { + terminal_api + .get_session(&existing_id) + .await + .map(|s| s.cwd) + .unwrap_or_else(|_| workspace_path.clone().unwrap_or_default()) + } else { + workspace_path.clone().unwrap_or_default() + }; + + return self + .call_background( + command_str, + chat_session_id, + &initial_cwd, + shell_type, + &terminal_api, + &binding, + start_time, + ) + .await; + } + + // 3. Foreground: get or create the primary terminal session + let primary_session_id = binding .get_or_create( chat_session_id, TerminalBindingOptions { @@ -349,28 +407,47 @@ Usage notes: "Chat-{}", &chat_session_id[..8.min(chat_session_id.len())] )), - shell_type, + shell_type: shell_type.clone(), + env: Some({ + let mut env = std::collections::HashMap::new(); + env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()); + env + }), ..Default::default() }, ) .await .map_err(|e| BitFunError::tool(format!("Failed to create Terminal session: {}", e)))?; - // Get actual working directory - let working_directory = terminal_api - .get_session(&terminal_session_id) + // Get actual working directory from primary session + let primary_cwd = terminal_api + .get_session(&primary_session_id) .await .map(|s| s.cwd) - .unwrap_or_default(); + .unwrap_or_else(|_| workspace_path.clone().unwrap_or_default()); + + // --- Foreground execution --- + + let tool_name = self.name().to_string(); + + const DEFAULT_TIMEOUT_MS: u64 = 120_000; + const MAX_TIMEOUT_MS: u64 = 600_000; + let timeout_ms = Some( + input + .get("timeout_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_TIMEOUT_MS) + .min(MAX_TIMEOUT_MS), + ); debug!( - "Bash tool using terminal session: {} (bound to chat: {})", - terminal_session_id, chat_session_id + "Bash tool executing command: {}, session_id: {}, tool_id: {}", + command_str, chat_session_id, tool_use_id ); // 4. Create streaming execution request let request = ExecuteCommandRequest { - session_id: terminal_session_id.clone(), + session_id: primary_session_id.clone(), command: command_str.to_string(), timeout_ms, prevent_history: Some(true), @@ -389,21 +466,16 @@ Usage notes: // Check cancellation request if let Some(token) = &context.cancellation_token { if token.is_cancelled() && !was_interrupted { - // Only send signal on first cancellation detection debug!("Bash tool received cancellation request, sending interrupt signal, tool_id: {}", tool_use_id); was_interrupted = true; - // Send interrupt signal to PTY let _ = terminal_api .signal(SignalRequest { - session_id: terminal_session_id.clone(), + session_id: primary_session_id.clone(), signal: "SIGINT".to_string(), }) .await; - // Set exit code and exit directly - // Unix/Linux: 130 (128 + SIGINT=2) - // Windows: -1073741510 (STATUS_CONTROL_C_EXIT) #[cfg(windows)] { final_exit_code = Some(-1073741510); @@ -423,19 +495,16 @@ Usage notes: CommandStreamEvent::Output { data } => { accumulated_output.push_str(&data); - // Send progress event to frontend - let progress_event = crate::infrastructure::events::event_system::BackendEvent::ToolExecutionProgress( - ToolExecutionProgressInfo { - tool_use_id: tool_use_id.clone(), - tool_name: tool_name.clone(), - progress_message: data, - percentage: None, - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - } - ); + let progress_event = ToolExecutionProgress(ToolExecutionProgressInfo { + tool_use_id: tool_use_id.clone(), + tool_name: tool_name.clone(), + progress_message: data, + percentage: None, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }); let event_system_clone = event_system.clone(); tokio::spawn(async move { @@ -452,12 +521,10 @@ Usage notes: ); final_exit_code = exit_code; - // Even if was_interrupted is false (e.g., user pressed Ctrl+C directly in terminal), should mark as interrupted if matches!(exit_code, Some(130) | Some(-1073741510)) { was_interrupted = true; } - // Use complete output (may be more complete than accumulated) if !total_output.is_empty() { accumulated_output = total_output; } @@ -476,7 +543,7 @@ Usage notes: } } - // 5. Build result + // 6. Build result let execution_time_ms = start_time.elapsed().as_millis() as u64; let result_data = json!({ @@ -485,13 +552,13 @@ Usage notes: "output": accumulated_output, "exit_code": final_exit_code, "interrupted": was_interrupted, - "working_directory": working_directory, + "working_directory": primary_cwd, "execution_time_ms": execution_time_ms, - "terminal_session_id": terminal_session_id, + "terminal_session_id": primary_session_id, }); - // Generate result for AI let result_for_assistant = self.render_result( + &primary_session_id, &accumulated_output, was_interrupted, final_exit_code.unwrap_or(-1), @@ -503,3 +570,160 @@ Usage notes: }]) } } + +impl BashTool { + /// Execute a command in a new background terminal session. + /// Returns immediately with the new session ID. + async fn call_background( + &self, + command_str: &str, + chat_session_id: &str, + initial_cwd: &str, + shell_type: Option, + terminal_api: &TerminalApi, + binding: &TerminalSessionBinding, + start_time: Instant, + ) -> BitFunResult> { + debug!( + "Bash tool starting background command: {}, owner: {}", + command_str, chat_session_id + ); + + // Create a dedicated background terminal session sharing the primary session's cwd + let bg_session_id = binding + .create_background_session( + chat_session_id, + TerminalBindingOptions { + working_directory: Some(initial_cwd.to_string()), + session_id: None, + session_name: None, + shell_type, + env: Some({ + let mut env = std::collections::HashMap::new(); + env.insert("BITFUN_NONINTERACTIVE".to_string(), "1".to_string()); + env + }), + ..Default::default() + }, + ) + .await + .map_err(|e| { + BitFunError::tool(format!( + "Failed to create background terminal session: {}", + e + )) + })?; + + // Subscribe to session output before sending the command so no data is missed + let mut output_rx = terminal_api.subscribe_session_output(&bg_session_id); + + // Fire-and-forget: write the command to the PTY without waiting for completion + terminal_api + .send_command(SendCommandRequest { + session_id: bg_session_id.clone(), + command: command_str.to_string(), + }) + .await + .map_err(|e| BitFunError::tool(format!("Failed to send background command: {}", e)))?; + + debug!( + "Background command started, session_id: {}, owner: {}", + bg_session_id, chat_session_id + ); + + // Determine output file path: /.bitfun/terminals/.txt + let output_file_path = get_workspace_path().map(|ws| { + ws.join(".bitfun") + .join("terminals") + .join(format!("{}.txt", bg_session_id)) + }); + + // Spawn task: write PTY output to file, delete when session ends + if let Some(file_path) = output_file_path.clone() { + let bg_id_for_log = bg_session_id.clone(); + tokio::spawn(async move { + if let Some(parent) = file_path.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + error!( + "Failed to create terminals output dir for bg session {}: {}", + bg_id_for_log, e + ); + return; + } + } + + let file = match tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&file_path) + .await + { + Ok(f) => f, + Err(e) => { + error!( + "Failed to open output file for bg session {}: {}", + bg_id_for_log, e + ); + return; + } + }; + + let mut writer = tokio::io::BufWriter::new(file); + + while let Some(data) = output_rx.recv().await { + if let Err(e) = writer.write_all(data.as_bytes()).await { + error!( + "Failed to write output for bg session {}: {}", + bg_id_for_log, e + ); + break; + } + let _ = writer.flush().await; + } + + // Channel closed means session was destroyed - delete the log file + drop(writer); + if let Err(e) = tokio::fs::remove_file(&file_path).await { + debug!( + "Could not remove output file for bg session {} (may already be gone): {}", + bg_id_for_log, e + ); + } else { + debug!("Removed output file for bg session {}", bg_id_for_log); + } + }); + } + + let execution_time_ms = start_time.elapsed().as_millis() as u64; + + let output_file_str = output_file_path.as_deref().map(|p| p.display().to_string()); + + let output_file_note = output_file_str + .as_deref() + .map(|s| format!("\nOutput is being written to: {}", s)) + .unwrap_or_default(); + + let result_data = json!({ + "success": true, + "command": command_str, + "output": format!("Command started in background terminal session.{}", output_file_note), + "exit_code": null, + "interrupted": false, + "working_directory": initial_cwd, + "execution_time_ms": execution_time_ms, + "terminal_session_id": bg_session_id, + "output_file": output_file_str, + }); + + let result_for_assistant = format!( + "Command started in background terminal session (id: {}).{}", + bg_session_id, output_file_note + ); + + Ok(vec![ToolResult::Result { + data: result_data, + result_for_assistant: Some(result_for_assistant), + }]) + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index edab188d..4912528b 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -23,6 +23,7 @@ pub mod git_tool; pub mod create_plan_tool; pub mod get_file_diff_tool; pub mod code_review_tool; +pub mod terminal_control_tool; pub mod util; pub use file_read_tool::FileReadTool; @@ -46,4 +47,5 @@ pub use task_tool::TaskTool; pub use git_tool::GitTool; pub use create_plan_tool::CreatePlanTool; pub use get_file_diff_tool::GetFileDiffTool; -pub use code_review_tool::CodeReviewTool; \ No newline at end of file +pub use code_review_tool::CodeReviewTool; +pub use terminal_control_tool::TerminalControlTool; \ No newline at end of file diff --git a/src/crates/core/src/agentic/tools/implementations/terminal_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/terminal_control_tool.rs new file mode 100644 index 00000000..594d702c --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/terminal_control_tool.rs @@ -0,0 +1,221 @@ +use crate::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use log::debug; +use serde_json::{json, Value}; +use terminal_core::{CloseSessionRequest, SignalRequest, TerminalApi}; + +/// TerminalControl tool - kill or interrupt a terminal session +pub struct TerminalControlTool; + +impl TerminalControlTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for TerminalControlTool { + fn name(&self) -> &str { + "TerminalControl" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Control a terminal session by performing a kill or interrupt action. + +Actions: +- "kill": Permanently close a terminal session. When to use: + 1. Clean up terminals that are no longer needed (e.g., after stopping a server or when a long-running task completes). + 2. Close the persistent shell used by BashTool - if BashTool output appears clearly abnormal (e.g., garbled output, stuck prompts, corrupted shell state), use this to forcefully close the persistent shell. The next BashTool invocation will automatically create a fresh shell session. +- "interrupt": Cancel the currently running process without closing the session. + +The session_id is returned inside ... tags in BashTool results."# + .to_string()) + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "The ID of the terminal session to control." + }, + "action": { + "type": "string", + "enum": ["kill", "interrupt"], + "description": "The action to perform: 'kill' closes the session permanently; 'interrupt' cancels the running process." + } + }, + "required": ["session_id", "action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + true + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + if input.get("session_id").and_then(|v| v.as_str()).is_none() { + return ValidationResult { + result: false, + message: Some("session_id is required".to_string()), + error_code: Some(400), + meta: None, + }; + } + match input.get("action").and_then(|v| v.as_str()) { + Some("kill") | Some("interrupt") => {} + _ => { + return ValidationResult { + result: false, + message: Some("action must be one of: \"kill\", \"interrupt\"".to_string()), + error_code: Some(400), + meta: None, + }; + } + } + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let session_id = input + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let action = input + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + match action { + "kill" => format!("Kill terminal session: {}", session_id), + "interrupt" => format!("Interrupt terminal session: {}", session_id), + _ => format!("Control terminal session: {}", session_id), + } + } + + async fn call_impl( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let session_id = input + .get("session_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("session_id is required".to_string()))?; + + let action = input + .get("action") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("action is required".to_string()))?; + + let terminal_api = TerminalApi::from_singleton() + .map_err(|e| BitFunError::tool(format!("Terminal not initialized: {}", e)))?; + + match action { + "interrupt" => { + debug!("TerminalControl: sending SIGINT to session {}", session_id); + + terminal_api + .signal(SignalRequest { + session_id: session_id.to_string(), + signal: "SIGINT".to_string(), + }) + .await + .map_err(|e| { + BitFunError::tool(format!("Failed to interrupt terminal session: {}", e)) + })?; + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "session_id": session_id, + "action": "interrupt", + }), + result_for_assistant: Some(format!( + "Sent interrupt (SIGINT) to terminal session '{}'.", + session_id + )), + }]) + } + + "kill" => { + // Determine if this is a primary (persistent) session by checking the binding. + // For primary sessions, owner_id == terminal_session_id, so binding.get(session_id) + // returns Some(session_id) when the session is primary. + let binding = terminal_api.session_manager().binding(); + let is_primary = binding + .get(session_id) + .map(|bound_id| bound_id == session_id) + .unwrap_or(false); + + debug!( + "TerminalControl: killing session {}, is_primary={}", + session_id, is_primary + ); + + if is_primary { + binding.remove(session_id).await.map_err(|e| { + BitFunError::tool(format!("Failed to close terminal session: {}", e)) + })?; + } else { + terminal_api + .close_session(CloseSessionRequest { + session_id: session_id.to_string(), + immediate: Some(true), + }) + .await + .map_err(|e| { + BitFunError::tool(format!("Failed to close terminal session: {}", e)) + })?; + } + + let result_for_assistant = if is_primary { + format!( + "Terminal session '{}' has been killed. The next Bash tool call will automatically create a new persistent shell session.", + session_id + ) + } else { + format!( + "Background terminal session '{}' has been killed.", + session_id + ) + }; + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "session_id": session_id, + "action": "kill", + }), + result_for_assistant: Some(result_for_assistant), + }]) + } + + _ => Err(BitFunError::tool(format!( + "Unknown action: '{}'. Must be 'kill' or 'interrupt'.", + action + ))), + } + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/ansi_cleaner.rs b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/ansi_cleaner.rs index 60cb71d1..635e5bb3 100644 --- a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/ansi_cleaner.rs +++ b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/ansi_cleaner.rs @@ -21,8 +21,16 @@ fn floor_char_boundary(s: &str, index: usize) -> usize { /// - CSI sequences like `\033[K` (clear line), `\033[2J` (clear screen) /// /// Color codes, cursor movements, and other non-content sequences are ignored. +/// +/// Empty lines are classified as either "real" (created by explicit `\n`) or +/// "phantom" (intermediate rows filled when `ESC[row;colH` jumps over them). +/// Phantom empty lines are omitted from output to avoid blank space artifacts +/// from screen-mode rendering sequences. pub struct AnsiCleaner { lines: Vec, + /// Parallel to `lines`: true = row was explicitly written via `\n`; + /// false = phantom row filled by a cursor-position jump (H sequence). + line_is_real: Vec, current_line: String, cursor_col: usize, // Track cursor column position for handling cursor movement sequences line_cleared: bool, // Track if line was just cleared with \x1b[K @@ -34,6 +42,7 @@ impl AnsiCleaner { pub fn new() -> Self { Self { lines: Vec::new(), + line_is_real: Vec::new(), current_line: String::new(), cursor_col: 0, line_cleared: false, @@ -45,45 +54,64 @@ impl AnsiCleaner { pub fn process(&mut self, input: &str) -> String { let mut parser = vte::Parser::new(); parser.advance(self, input.as_bytes()); - // Add last line if not empty + // Save last line if it has content if !self.current_line.is_empty() { - // Ensure lines has space for current row while self.lines.len() <= self.cursor_row { self.lines.push(String::new()); + self.line_is_real.push(false); } self.lines[self.cursor_row] = std::mem::take(&mut self.current_line); + // Non-empty lines are always included regardless of is_real, + // but mark true for consistency. + self.line_is_real[self.cursor_row] = true; } - // Trim trailing empty lines - let last_non_empty = self.lines.iter().rposition(|l| !l.is_empty()); - match last_non_empty { - Some(idx) => self.lines[..=idx].join("\n"), - None => String::new(), - } + self.build_output() } /// Process input bytes and return cleaned plain text. pub fn process_bytes(&mut self, input: &[u8]) -> String { let mut parser = vte::Parser::new(); parser.advance(self, input); - // Add last line if not empty + // Save last line if it has content if !self.current_line.is_empty() { - // Ensure lines has space for current row while self.lines.len() <= self.cursor_row { self.lines.push(String::new()); + self.line_is_real.push(false); } self.lines[self.cursor_row] = std::mem::take(&mut self.current_line); + self.line_is_real[self.cursor_row] = true; } - // Trim trailing empty lines + self.build_output() + } + + /// Build the final output string, skipping phantom empty lines. + /// + /// A "phantom" empty line is one that was never explicitly written by `\n` + /// but was created as a placeholder when a cursor-position (`H`) sequence + /// jumped forward over multiple rows. Real blank lines (from `\n\n`) are + /// preserved; only phantom ones are dropped. + fn build_output(&self) -> String { let last_non_empty = self.lines.iter().rposition(|l| !l.is_empty()); - match last_non_empty { - Some(idx) => self.lines[..=idx].join("\n"), - None => String::new(), + let Some(idx) = last_non_empty else { + return String::new(); + }; + + let mut result: Vec<&str> = Vec::with_capacity(idx + 1); + for (i, line) in self.lines[..=idx].iter().enumerate() { + let is_real = self.line_is_real.get(i).copied().unwrap_or(false); + // Keep the line if it has content OR if it was explicitly created by \n. + // Drop phantom empty lines (H-jump fillers that were never written to). + if !line.is_empty() || is_real { + result.push(line.as_str()); + } } + result.join("\n") } /// Reset the cleaner state for reuse. pub fn reset(&mut self) { self.lines.clear(); + self.line_is_real.clear(); self.current_line.clear(); self.cursor_col = 0; self.line_cleared = false; @@ -128,13 +156,17 @@ impl Perform for AnsiCleaner { fn execute(&mut self, byte: u8) { match byte { b'\n' => { - // Line feed: move to next line - // Ensure lines has space for current row + // Line feed: move to next line. + // Intermediate rows pushed here are phantom (cursor jumped over them + // via a prior B/H sequence without writing). while self.lines.len() <= self.cursor_row { self.lines.push(String::new()); + self.line_is_real.push(false); } - // Save current line content at cursor_row position (overwrites if exists) + // Save current line content at cursor_row position (overwrites if exists). + // Mark as real: \n explicitly visited this row. self.lines[self.cursor_row] = std::mem::take(&mut self.current_line); + self.line_is_real[self.cursor_row] = true; self.cursor_col = 0; self.cursor_row += 1; self.line_cleared = false; @@ -220,16 +252,22 @@ impl Perform for AnsiCleaner { // If we need to move to a different row, handle current line first if target_row != self.cursor_row { if !self.current_line.is_empty() { - // Ensure lines has enough space + // Ensure lines has enough space; new slots are phantom. while self.lines.len() <= self.cursor_row { self.lines.push(String::new()); + self.line_is_real.push(false); } self.lines[self.cursor_row] = std::mem::take(&mut self.current_line); + // Line has content so it will always be included; + // no need to update line_is_real here. } - // Fill in missing rows if needed + // Fill rows between current position and target with phantom entries. + // These rows are never written to by \n and are pure screen-layout + // artifacts of the absolute-positioning sequence. while self.lines.len() <= target_row { self.lines.push(String::new()); + self.line_is_real.push(false); } // Load the target row @@ -271,6 +309,7 @@ impl Perform for AnsiCleaner { if *param == 2 { // \033[2J - Erase entire display self.lines.clear(); + self.line_is_real.clear(); self.current_line.clear(); self.cursor_col = 0; self.cursor_row = 0; @@ -436,10 +475,11 @@ mod tests { #[test] fn test_cursor_position() { - // \x1b[5;1H moves cursor to row 5, column 1 - // This creates empty rows 1-4, then starts writing at row 5 + // \x1b[5;1H moves cursor to row 5, column 1. + // Rows 1-4 are phantom (H-jump fillers, never written by \n), so they + // are omitted from output. Only real content rows are included. let input = "Header\x1b[5;1HNew content"; - assert_eq!(strip_ansi(input), "Header\n\n\n\nNew content"); + assert_eq!(strip_ansi(input), "Header\nNew content"); } #[test] @@ -465,7 +505,10 @@ mod tests { #[test] fn human_written_test() { let input = "\u{001b}[93mls\u{001b}[K\r\n\u{001b}[?25h\u{001b}[m\r\n\u{001b}[?25l Directory: E:\\Projects\\ForTest\\basic-rust\u{001b}[32m\u{001b}[1m\u{001b}[5;1HMode LastWriteTime\u{001b}[m \u{001b}[32m\u{001b}[1m\u{001b}[3m Length\u{001b}[23m Name\r\n---- \u{001b}[m \u{001b}[32m\u{001b}[1m -------------\u{001b}[m \u{001b}[32m\u{001b}[1m ------\u{001b}[m \u{001b}[32m\u{001b}[1m----\u{001b}[m\r\nd---- 2026/1/10 19:23\u{001b}[16X\u{001b}[44m\u{001b}[1m\u{001b}[16C.bitfun\u{001b}[m\r\nd---- 2026/1/10 21:18\u{001b}[16X\u{001b}[44m\u{001b}[1m\u{001b}[16C.worktrees\u{001b}[m\r\nd---- 2026/1/10 19:21\u{001b}[16X\u{001b}[44m\u{001b}[1m\u{001b}[16Csrc\u{001b}[m\r\nd---- 2026/1/10 19:21\u{001b}[16X\u{001b}[44m\u{001b}[1m\u{001b}[16Ctarget\r\n\u{001b}[?25h\u{001b}[?25l\u{001b}[m-a--- 2026/1/10 19:23 57 .gitignore\r\n-a--- 2026/1/10 19:21 154 Cargo.lock\r\n-a--- 2026/1/10 19:21 81 Cargo.toml\u{001b}[15;1H\u{001b}[?25h"; - let expected_output = "ls\n\n Directory: E:\\Projects\\ForTest\\basic-rust\n\nMode LastWriteTime Length Name\n---- ------------- ------ ----\nd---- 2026/1/10 19:23 .bitfun\nd---- 2026/1/10 21:18 .worktrees\nd---- 2026/1/10 19:21 src\nd---- 2026/1/10 19:21 target\n-a--- 2026/1/10 19:23 57 .gitignore\n-a--- 2026/1/10 19:21 154 Cargo.lock\n-a--- 2026/1/10 19:21 81 Cargo.toml"; + // The blank line between "Directory:" and "Mode..." was produced by + // ESC[5;1H jumping from row 2 to row 4, leaving row 3 as a phantom + // empty line. With phantom-line filtering it is now omitted. + let expected_output = "ls\n\n Directory: E:\\Projects\\ForTest\\basic-rust\nMode LastWriteTime Length Name\n---- ------------- ------ ----\nd---- 2026/1/10 19:23 .bitfun\nd---- 2026/1/10 21:18 .worktrees\nd---- 2026/1/10 19:21 src\nd---- 2026/1/10 19:21 target\n-a--- 2026/1/10 19:23 57 .gitignore\n-a--- 2026/1/10 19:21 154 Cargo.lock\n-a--- 2026/1/10 19:21 81 Cargo.toml"; assert_eq!(strip_ansi(input), expected_output); } diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 728db42b..1eefdb51 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -89,6 +89,7 @@ impl ToolRegistry { self.register_tool(Arc::new(FileEditTool::new())); self.register_tool(Arc::new(DeleteFileTool::new())); self.register_tool(Arc::new(BashTool::new())); + self.register_tool(Arc::new(TerminalControlTool::new())); // TodoWrite tool self.register_tool(Arc::new(TodoWriteTool::new())); diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/openai.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/openai.rs index b4e50683..df5216d4 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/openai.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/openai.rs @@ -43,12 +43,20 @@ pub async fn handle_openai_stream( ) { let mut stream = response.bytes_stream().eventsource(); let idle_timeout = Duration::from_secs(600); + // Track whether a chunk with `finish_reason` was received. + // Some providers (e.g. MiniMax) close the stream after the final chunk + // without sending `[DONE]`, so we treat `Ok(None)` as a normal termination + // when a finish_reason has already been seen. + let mut received_finish_reason = false; loop { let sse_event = timeout(idle_timeout, stream.next()).await; let sse = match sse_event { Ok(Some(Ok(sse))) => sse, Ok(None) => { + if received_finish_reason { + return; + } let error_msg = "SSE stream closed before response completed"; error!("{}", error_msg); let _ = tx_event.send(Err(anyhow!(error_msg))); @@ -144,6 +152,9 @@ pub async fn handle_openai_stream( } for unified_response in unified_responses { + if unified_response.finish_reason.is_some() { + received_finish_reason = true; + } let _ = tx_event.send(Ok(unified_response)); } } diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/openai.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/openai.rs index 57fde206..e584b074 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/openai.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/openai.rs @@ -8,8 +8,11 @@ struct PromptTokensDetails { #[derive(Debug, Deserialize)] struct OpenAIUsage { + #[serde(default)] prompt_tokens: u32, + #[serde(default)] completion_tokens: u32, + #[serde(default)] total_tokens: u32, prompt_tokens_details: Option, } @@ -35,11 +38,23 @@ struct Choice { finish_reason: Option, } +/// MiniMax `reasoning_details` array element. +/// Only elements with `type == "reasoning.text"` carry thinking text. +#[derive(Debug, Deserialize)] +struct ReasoningDetail { + #[serde(rename = "type")] + detail_type: Option, + text: Option, +} + #[derive(Debug, Deserialize)] struct Delta { #[allow(dead_code)] role: Option, + /// Standard OpenAI-compatible reasoning field (DeepSeek, Qwen, etc.) reasoning_content: Option, + /// MiniMax-specific reasoning field; used as fallback when `reasoning_content` is absent. + reasoning_details: Option>, content: Option, tool_calls: Option>, } @@ -123,11 +138,34 @@ impl OpenAISSEData { let mut finish_reason = finish_reason; let Delta { reasoning_content, + reasoning_details, content, tool_calls, .. } = delta; + // Treat empty strings the same as absent fields (MiniMax sends `content: ""` in + // reasoning-only chunks). + let content = content.filter(|s| !s.is_empty()); + let reasoning_content = reasoning_content.filter(|s| !s.is_empty()); + + // MiniMax uses `reasoning_details` instead of `reasoning_content`. + // Collect all "reasoning.text" entries and join them as a fallback. + let reasoning_content = reasoning_content.or_else(|| { + reasoning_details.and_then(|details| { + let text: String = details + .into_iter() + .filter(|d| d.detail_type.as_deref() == Some("reasoning.text")) + .filter_map(|d| d.text) + .collect(); + if text.is_empty() { + None + } else { + Some(text) + } + }) + }); + let mut responses = Vec::new(); if content.is_some() || reasoning_content.is_some() { diff --git a/src/crates/core/src/infrastructure/ai/client.rs b/src/crates/core/src/infrastructure/ai/client.rs index 0a586a73..f883f48d 100644 --- a/src/crates/core/src/infrastructure/ai/client.rs +++ b/src/crates/core/src/infrastructure/ai/client.rs @@ -118,6 +118,13 @@ impl AIClient { url.contains("dashscope.aliyuncs.com") } + /// Whether the URL is MiniMax API. + /// MiniMax (api.minimaxi.com) uses `reasoning_split=true` to enable streamed thinking content + /// delivered via `delta.reasoning_details` rather than the standard `reasoning_content` field. + fn is_minimax_url(url: &str) -> bool { + url.contains("api.minimaxi.com") + } + /// Apply thinking-related fields onto the request body (mutates `request_body`). /// /// * `enable` - whether thinking process is enabled @@ -137,6 +144,12 @@ impl AIClient { request_body["enable_thinking"] = serde_json::json!(enable); return; } + if Self::is_minimax_url(url) && api_format.eq_ignore_ascii_case("openai") { + if enable { + request_body["reasoning_split"] = serde_json::json!(true); + } + return; + } let thinking_value = if enable { if api_format.eq_ignore_ascii_case("anthropic") && model_name.starts_with("claude") { let mut obj = serde_json::map::Map::new(); diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index f77178e8..49783e2b 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -879,7 +879,8 @@ impl Default for AppConfig { impl Default for AppLoggingConfig { fn default() -> Self { Self { - level: "info".to_string(), + // Set to Debug in early development for easier diagnostics + level: "debug".to_string(), } } } diff --git a/src/crates/core/src/service/terminal/src/api.rs b/src/crates/core/src/service/terminal/src/api.rs index d0dde2f0..f04dcf53 100644 --- a/src/crates/core/src/service/terminal/src/api.rs +++ b/src/crates/core/src/service/terminal/src/api.rs @@ -445,6 +445,17 @@ impl TerminalApi { .await } + /// Subscribe to raw PTY output of a specific session. + /// + /// Returns a receiver that yields raw output strings as they arrive. + /// The channel closes when the session is destroyed. + pub fn subscribe_session_output( + &self, + session_id: &str, + ) -> tokio::sync::mpsc::Receiver { + self.session_manager.subscribe_session_output(session_id) + } + /// Subscribe to terminal events pub fn subscribe_events(&self) -> tokio::sync::mpsc::Receiver { let (tx, rx) = tokio::sync::mpsc::channel(1024); diff --git a/src/crates/core/src/service/terminal/src/session/binding.rs b/src/crates/core/src/service/terminal/src/session/binding.rs index c2e24db0..0368885d 100644 --- a/src/crates/core/src/service/terminal/src/session/binding.rs +++ b/src/crates/core/src/service/terminal/src/session/binding.rs @@ -46,12 +46,15 @@ pub struct TerminalBindingOptions { /// - Get or create terminal sessions on demand /// - Bind existing terminal sessions to owners /// - Remove bindings and optionally close terminal sessions +/// - Create and track background terminal sessions (one-to-many) /// /// # Thread Safety /// This struct is thread-safe and can be shared across async tasks. pub struct TerminalSessionBinding { - /// Mapping from owner_id to terminal_session_id + /// Mapping from owner_id to terminal_session_id (primary, one-to-one) bindings: Arc>, + /// Mapping from owner_id to background terminal session IDs (one-to-many) + background_bindings: Arc>>, } impl TerminalSessionBinding { @@ -59,6 +62,7 @@ impl TerminalSessionBinding { pub fn new() -> Self { Self { bindings: Arc::new(DashMap::new()), + background_bindings: Arc::new(DashMap::new()), } } @@ -146,16 +150,76 @@ impl TerminalSessionBinding { self.bindings.remove(owner_id).map(|(_, v)| v) } + /// Create a new background terminal session for the given owner. + /// + /// Unlike `get_or_create`, this always creates a fresh session and allows + /// multiple background sessions per owner. The session ID is returned immediately + /// after the session is started; the caller is responsible for sending commands. + /// + /// # Arguments + /// * `owner_id` - The external entity ID (e.g., chat_session_id) + /// * `options` - Options for creating the terminal session + /// + /// # Returns + /// The newly created background terminal session ID + pub async fn create_background_session( + &self, + owner_id: &str, + options: TerminalBindingOptions, + ) -> TerminalResult { + let session_manager = get_session_manager() + .ok_or_else(|| TerminalError::Session("SessionManager not initialized".to_string()))?; + + let session_id = options.session_id.unwrap_or_else(|| { + format!( + "bg-{}-{}", + &owner_id[..8.min(owner_id.len())], + &uuid::Uuid::new_v4().to_string()[..8] + ) + }); + + let session_name = options + .session_name + .unwrap_or_else(|| format!("Background-{}", &session_id[..8.min(session_id.len())])); + + let _session = session_manager + .create_session( + Some(session_id.clone()), + Some(session_name), + options.shell_type, + options.working_directory, + options.env, + options.cols, + options.rows, + ) + .await?; + + self.background_bindings + .entry(owner_id.to_string()) + .or_default() + .push(session_id.clone()); + + Ok(session_id) + } + + /// List all background terminal session IDs for the given owner. + pub fn list_background_sessions(&self, owner_id: &str) -> Vec { + self.background_bindings + .get(owner_id) + .map(|v| v.clone()) + .unwrap_or_default() + } + /// Remove binding and close the associated terminal session /// /// This is the recommended way to clean up when an owner is being destroyed. + /// Also closes all background sessions associated with this owner. pub async fn remove(&self, owner_id: &str) -> TerminalResult<()> { - if let Some(terminal_session_id) = self.unbind(owner_id) { - let session_manager = get_session_manager().ok_or_else(|| { - TerminalError::Session("SessionManager not initialized".to_string()) - })?; + let session_manager = get_session_manager() + .ok_or_else(|| TerminalError::Session("SessionManager not initialized".to_string()))?; - // Close the terminal session + // Close primary session + if let Some(terminal_session_id) = self.unbind(owner_id) { if let Err(e) = session_manager .close_session(&terminal_session_id, false) .await @@ -164,7 +228,18 @@ impl TerminalSessionBinding { "Failed to close terminal session {}: {}", terminal_session_id, e ); - // Don't return error - the binding is already removed + } + } + + // Close all background sessions + if let Some((_, bg_sessions)) = self.background_bindings.remove(owner_id) { + for bg_session_id in bg_sessions { + if let Err(e) = session_manager.close_session(&bg_session_id, false).await { + warn!( + "Failed to close background terminal session {}: {}", + bg_session_id, e + ); + } } } @@ -196,13 +271,21 @@ impl TerminalSessionBinding { /// Use this with caution - terminal sessions will become orphaned. pub fn clear(&self) { self.bindings.clear(); + self.background_bindings.clear(); } - /// Remove all bindings and close all associated terminal sessions + /// Remove all bindings and close all associated terminal sessions (primary + background) pub async fn remove_all(&self) -> TerminalResult<()> { - let bindings: Vec<(String, String)> = self.list_bindings(); + let owner_ids: Vec = self + .bindings + .iter() + .map(|e| e.key().clone()) + .chain(self.background_bindings.iter().map(|e| e.key().clone())) + .collect::>() + .into_iter() + .collect(); - for (owner_id, _) in bindings { + for owner_id in owner_ids { if let Err(e) = self.remove(&owner_id).await { warn!("Failed to remove binding for {}: {}", owner_id, e); } diff --git a/src/crates/core/src/service/terminal/src/session/manager.rs b/src/crates/core/src/service/terminal/src/session/manager.rs index 0600e1f7..b2382904 100644 --- a/src/crates/core/src/service/terminal/src/session/manager.rs +++ b/src/crates/core/src/service/terminal/src/session/manager.rs @@ -5,6 +5,7 @@ use std::pin::Pin; use std::sync::Arc; use std::time::Duration; +use dashmap::DashMap; use futures::Stream; use log::warn; use tokio::sync::{mpsc, RwLock}; @@ -99,6 +100,9 @@ pub struct SessionManager { /// Shell integration scripts manager scripts_manager: ScriptsManager, + + /// Per-session output taps for real-time output streaming + output_taps: Arc>>>, } impl SessionManager { @@ -114,6 +118,7 @@ impl SessionManager { let event_emitter = Arc::new(TerminalEventEmitter::new(1024)); let integration_manager = Arc::new(ShellIntegrationManager::new()); let binding = Arc::new(super::TerminalSessionBinding::new()); + let output_taps = Arc::new(DashMap::new()); let manager = Self { config, @@ -125,6 +130,7 @@ impl SessionManager { session_integrations: Arc::new(RwLock::new(HashMap::new())), binding, scripts_manager, + output_taps, }; // Start event forwarding @@ -148,6 +154,7 @@ impl SessionManager { let sessions = self.sessions.clone(); let pty_to_session = self.pty_to_session.clone(); let session_integrations = self.session_integrations.clone(); + let output_taps = self.output_taps.clone(); tokio::spawn(async move { loop { @@ -231,6 +238,11 @@ impl SessionManager { } } + // Fan out raw data to output taps (e.g. background session file loggers) + if let Some(mut senders) = output_taps.get_mut(&session_id) { + senders.retain(|tx| tx.try_send(data_str.clone()).is_ok()); + } + TerminalEvent::Data { session_id, data: data_str, @@ -1317,12 +1329,20 @@ impl SessionManager { .unregister_session(session_id) .await; + // Drop output taps so file-writing tasks can detect session end + self.output_taps.remove(session_id); + // Remove session { let mut sessions = self.sessions.write().await; sessions.remove(session_id); } + // Remove any binding pointing to this session so the next get_or_create + // creates a fresh session rather than returning a stale ID. + // For primary sessions owner_id == session_id, so unbind(session_id) is sufficient. + self.binding.unbind(session_id); + // Emit session destroyed event for frontend let _ = self .event_emitter @@ -1388,6 +1408,20 @@ impl SessionManager { self.pty_service.shutdown_all().await; } + + /// Subscribe to the raw PTY output of a specific session. + /// + /// Returns a receiver that yields raw output strings as they arrive from the PTY. + /// The receiver will return `None` (channel closed) when the session is destroyed. + /// Multiple subscriptions to the same session are supported. + pub fn subscribe_session_output(&self, session_id: &str) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(256); + self.output_taps + .entry(session_id.to_string()) + .or_default() + .push(tx); + rx + } } impl Drop for SessionManager { diff --git a/src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration.ps1 b/src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration.ps1 index 60793290..d190eb65 100644 --- a/src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration.ps1 +++ b/src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration.ps1 @@ -174,5 +174,13 @@ if (Get-Module -Name PSReadLine) { if ($Global:__TerminalState.ContinuationPrompt) { [Console]::Write("$([char]0x1b)]633;P;ContinuationPrompt=$(__Terminal-Escape-Value $Global:__TerminalState.ContinuationPrompt)`a") } + + # For programmatic terminals (bash_tool), disable PSReadLine inline + # prediction to prevent ConPTY rendering interference. ConPTY's async + # renderer can flush prediction rendering (cursor repositioning, partial + # text fragments) AFTER the 633;C marker, polluting captured output. + if ($env:BITFUN_NONINTERACTIVE -eq "1") { + try { Set-PSReadLineOption -PredictionSource None } catch {} + } } diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx new file mode 100644 index 00000000..d8a9998b --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx @@ -0,0 +1,106 @@ +/** + * Compact display for the TerminalControl tool. + */ + +import React, { useMemo } from 'react'; +import { Loader2, Clock, Check, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { ToolCardProps } from '../types/flow-chat'; +import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import type { CompactToolCardProps } from './CompactToolCard'; + +export const TerminalControlDisplay: React.FC = React.memo(({ + toolItem, +}) => { + const { t } = useTranslation('flow-chat'); + const { toolCall, status } = toolItem; + + const getStatusIcon = () => { + switch (status) { + case 'running': + case 'streaming': + return ; + case 'completed': + return ; + case 'error': + return ; + case 'pending': + default: + return ; + } + }; + + const sessionId = useMemo(() => { + return toolCall?.input?.session_id as string | undefined; + }, [toolCall?.input?.session_id]); + + const action = useMemo(() => { + return (toolCall?.input?.action as string | undefined) ?? 'kill'; + }, [toolCall?.input?.action]); + + const renderContent = () => { + const idLabel = sessionId + ? {sessionId} + : null; + + const isInterrupt = action === 'interrupt'; + + if (status === 'completed') { + return ( + <> + {isInterrupt + ? t('toolCards.terminalControl.sessionInterrupted') + : t('toolCards.terminalControl.sessionKilled')} + {idLabel} + + ); + } + if (status === 'running' || status === 'streaming') { + return ( + <> + {isInterrupt + ? t('toolCards.terminalControl.interruptingSession') + : t('toolCards.terminalControl.terminatingSession')} + {idLabel} + ... + + ); + } + if (status === 'error') { + return ( + <> + {isInterrupt + ? t('toolCards.terminalControl.interruptFailed') + : t('toolCards.terminalControl.killFailed')} + {idLabel} + + ); + } + if (status === 'pending') { + return ( + <> + {isInterrupt + ? t('toolCards.terminalControl.preparingInterrupt') + : t('toolCards.terminalControl.preparingKill')} + {idLabel} + + ); + } + return null; + }; + + return ( + + } + /> + ); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts index 93977d65..f32facba 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.ts @@ -30,6 +30,7 @@ import { GitToolDisplay } from './GitToolDisplay'; import { GetFileDiffDisplay } from './GetFileDiffDisplay'; import { CreatePlanDisplay } from './CreatePlanDisplay'; import { TerminalToolCard } from './TerminalToolCard'; +import { TerminalControlDisplay } from './TerminalControlDisplay'; // Tool card config map - uses backend tool names export const TOOL_CARD_CONFIGS: Record = { @@ -271,6 +272,18 @@ export const TOOL_CARD_CONFIGS: Record = { primaryColor: '#f59e0b' // Orange }, + // TerminalControl tool + 'TerminalControl': { + toolName: 'TerminalControl', + displayName: 'Terminal Control', + icon: 'TC', + requiresConfirmation: false, + resultDisplayType: 'summary', + description: 'Kill or interrupt a terminal session', + displayMode: 'compact', + primaryColor: '#ef4444' + }, + // Bash terminal tool 'Bash': { toolName: 'Bash', @@ -337,6 +350,9 @@ export const TOOL_CARD_COMPONENTS = { // CreatePlan tool 'CreatePlan': CreatePlanDisplay, + // TerminalControl tool + 'TerminalControl': TerminalControlDisplay, + // Bash tool 'Bash': TerminalToolCard }; diff --git a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts index 4d9c2689..a60b6a36 100644 --- a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts +++ b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts @@ -23,10 +23,14 @@ export const PROVIDER_TEMPLATES: Record = { name: t('settings/ai-model:providers.minimax.name'), baseUrl: 'https://api.minimaxi.com/anthropic', format: 'anthropic', - models: ['MiniMax-M2.1', 'MiniMax-M2.1-lightning', 'MiniMax-M2'], + models: ['MiniMax-M2.5', 'MiniMax-M2.1', 'MiniMax-M2.1-lightning', 'MiniMax-M2'], requiresApiKey: true, description: t('settings/ai-model:providers.minimax.description'), - helpUrl: 'https://platform.minimax.io/' + helpUrl: 'https://platform.minimax.io/', + baseUrlOptions: [ + { url: 'https://api.minimaxi.com/anthropic', format: 'anthropic', note: 'default' }, + { url: 'https://api.minimaxi.com/v1', format: 'openai', note: 'OpenAI Compatible' }, + ] }, moonshot: { diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index a081e457..f272e4b9 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -568,6 +568,16 @@ "readingFile": "Reading", "preparingRead": "Preparing to read" }, + "terminalControl": { + "terminatingSession": "Terminating terminal", + "sessionKilled": "Terminal killed", + "killFailed": "Failed to kill terminal", + "preparingKill": "Preparing to kill terminal", + "interruptingSession": "Interrupting terminal", + "sessionInterrupted": "Terminal interrupted", + "interruptFailed": "Failed to interrupt terminal", + "preparingInterrupt": "Preparing to interrupt terminal" + }, "imageAnalysis": { "parsingAnalysisInfo": "Parsing analysis info...", "unknownImage": "Unknown image", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index a0f20bc5..e8053306 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -568,6 +568,16 @@ "readingFile": "正在读取", "preparingRead": "准备读取" }, + "terminalControl": { + "terminatingSession": "正在终止终端", + "sessionKilled": "终端已终止", + "killFailed": "终止终端失败", + "preparingKill": "准备终止终端", + "interruptingSession": "正在中断终端", + "sessionInterrupted": "终端已中断", + "interruptFailed": "中断终端失败", + "preparingInterrupt": "准备中断终端" + }, "imageAnalysis": { "parsingAnalysisInfo": "解析分析信息中...", "unknownImage": "未知图片", diff --git a/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx b/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx index 527cdbd7..9ec9dfcc 100644 --- a/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx +++ b/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx @@ -1,8 +1,35 @@ /** * Terminal output renderer based on xterm.js (read-only). * Uses TerminalActionManager to avoid per-instance EventBus listeners. + * + * Raw PTY output may contain absolute cursor-position sequences (ESC[row;colH) + * that assume existing content on screen. When replayed in a fresh xterm.js + * these sequences leave blank rows at the top. We strip them before writing + * so content flows sequentially; colors and relative movements are preserved. */ +/** + * Normalize absolute cursor-position sequences for fresh-context rendering. + * + * ESC[row;colH (CUP) and ESC[row;colf (HVP) reposition the cursor to an + * absolute screen coordinate. In a live terminal the rows above that + * coordinate already contain shell prompts and prior output, so no blank space + * appears. In a fresh xterm.js context those rows are empty, producing a + * large blank area before the first line of real content. + * + * We replace each such sequence with CR+LF so the two sections it separates + * stay on different lines (plain deletion would cause them to run together), + * while avoiding the blank-row artifact from coordinate-based positioning. + * + * Colors, bold, relative cursor movements and all other sequences are left + * untouched. + */ +function normalizeAbsoluteCursorPositions(content: string): string { + // Matches ESC [ ; H|f + // e.g. ESC[14;35H ESC[18;1H ESC[5;1H ESC[H ESC[;1H + return content.replace(/\x1b\[\d*;?\d*[Hf]/g, '\r\n'); +} + import React, { useEffect, useRef, useCallback, memo, useId } from 'react'; import { Terminal as XTerm } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; @@ -195,16 +222,20 @@ export const TerminalOutputRenderer: React.FC = mem const lastContent = lastContentRef.current; + // Compare raw content for incremental detection, but replace absolute + // cursor-position sequences with CR+LF before writing so a fresh xterm.js + // context does not show blank rows caused by ESC[row;colH jumps, while + // keeping the line boundary between the sections they separated. if (content.startsWith(lastContent) && lastContent.length > 0) { const newPart = content.slice(lastContent.length); if (newPart) { - terminal.write(newPart); + terminal.write(normalizeAbsoluteCursorPositions(newPart)); } } else { terminal.clear(); terminal.reset(); if (content) { - terminal.write(content); + terminal.write(normalizeAbsoluteCursorPositions(content)); } }