diff --git a/config.example.toml b/config.example.toml index 87af1a8e4..ae19daacc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -576,8 +576,61 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # [runtime_api] # cors_origins = ["http://localhost:5173", "http://127.0.0.1:5173"] +# ───────────────────────────────────────────────────────────────────────────────── +# Tool Overrides & Plugins ([tools]) +# ───────────────────────────────────────────────────────────────────────────────── +# The `[tools]` table lets you replace any built-in tool with a custom +# implementation (script or command) or disable it entirely — without +# forking or recompiling the binary. +# +# Plugin scripts dropped in the plugin directory are auto-discovered and +# registered as model-visible tools alongside the built-in ones. +# +# Scripts receive the tool's JSON input on **stdin** and must return a +# JSON `ToolResult` (`{"content": "...", "success": true}`) on **stdout**. +# +# [tools] +# # Custom plugin directory (defaults to `~/.deepseek/tools/`) +# plugin_dir = "~/.deepseek/tools" +# +# [tools.overrides] +# # Disable a tool entirely — removes it from the model-visible catalog. +# "code_execution" = { type = "disabled" } +# +# # Replace a tool with a script. Relative paths resolve against plugin_dir. +# "exec_shell" = { type = "script", path = "audit-exec-shell.sh" } +# +# # Replace a tool with a command (binary on PATH or absolute path). +# "read_file" = { type = "command", command = "bat", args = ["--paging=never"] } +# +# # Scripts can also accept static arguments before the JSON input: +# "fetch_url" = { type = "script", path = "cached-fetch.sh", args = ["--ttl", "300"] } + +# ──────────── Enterprise example: audit-logging exec_shell wrapper ────────────── +# Drop `audit-exec-shell.sh` in `~/.deepseek/tools/` and enable with: +# +# [tools.overrides] +# "exec_shell" = { type = "script", path = "audit-exec-shell.sh" } +# +# The wrapper logs every command to `~/.deepseek/audit/exec_shell.log` before +# executing it, then runs the real `exec_shell` tool logic via stdin/stdout +# passthrough. No code changes, no fork, no recompile. +# +# ```sh +# #!/usr/bin/env sh +# # name: exec_shell +# # description: Audit-logging wrapper for exec_shell +# # approval: required +# LOGDIR="${HOME}/.deepseek/audit" +# mkdir -p "$LOGDIR" +# LOGFILE="$LOGDIR/exec_shell.log" +# input=$(cat) +# echo "[$(date -Iseconds)] $input" >> "$LOGFILE" +# echo "$input" | exec /bin/sh -s +# ``` + # ───────────────────────────────────────────────────────────────────────────────── # Requirements (admin constraints) example file # ───────────────────────────────────────────────────────────────────────────────── # allowed_approval_policies = ["on-request", "untrusted", "never"] -# allowed_sandbox_modes = ["read-only", "workspace-write"] +# allowed_sandbox_modes = ["read-only", "workspace-write"] \ No newline at end of file diff --git a/crates/tui/src/commands/share.rs b/crates/tui/src/commands/share.rs index 9923af0b5..31e835126 100644 --- a/crates/tui/src/commands/share.rs +++ b/crates/tui/src/commands/share.rs @@ -12,6 +12,7 @@ use std::io::Write; use std::path::Path; use super::CommandResult; +use crate::dependencies::ExternalTool; use crate::tui::app::{App, AppAction}; /// Share the current session as a web URL. @@ -101,7 +102,7 @@ fn render_session_html(history_json: &str, model: &str, mode: &str) -> String { -codewhale Session Export +DeepSeek TUI Session Export -

codewhale Session

+

DeepSeek TUI Session

Model: {escaped_model} · Mode: {escaped_mode}
Exported: {timestamp}
{escaped_body}
"#, @@ -145,7 +146,7 @@ fn html_escape(s: &str) -> String { /// Write HTML to a secure temp file and keep it alive for upload. fn write_temp_html(html: &str) -> Result { let mut tmp = tempfile::Builder::new() - .prefix("codewhale-share-") + .prefix("deepseek-share-") .suffix(".html") .tempfile() .map_err(|e| format!("{e}"))?; @@ -155,16 +156,17 @@ fn write_temp_html(html: &str) -> Result { /// Upload a file as a GitHub Gist using the `gh` CLI. async fn upload_gist(path: &Path) -> Result { - let output = tokio::process::Command::new("gh") + let output = crate::dependencies::Gh::tokio_command() + .ok_or_else(|| "gh not found on PATH".to_string())? .args([ "gist", "create", "--public", - &path.to_string_lossy(), + &path.to_string_lossy().to_string(), "--filename", "session-export.html", "--desc", - "codewhale Session Export", + "DeepSeek TUI Session Export", ]) .output() .await @@ -194,7 +196,7 @@ mod tests { assert!(html.contains("deepseek-v4-pro")); assert!(html.contains("agent")); assert!(html.contains("[{}]")); - assert!(html.contains("codewhale")); + assert!(html.contains("DeepSeek TUI")); } #[test] @@ -219,6 +221,6 @@ mod tests { assert!(html.contains("plan")); assert!(html.contains("test data")); assert!(html.contains("Exported:")); - assert!(html.contains("https://github.com/Hmbown/CodeWhale")); + assert!(html.contains("https://github.com/Hmbown/DeepSeek-TUI")); } } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index b41712557..d315d6767 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1039,6 +1039,59 @@ pub struct Config { /// Vision model configuration for the `image_analyze` tool. #[serde(default)] pub vision_model: Option, + + /// Tool override and plugin configuration (`[tools]` table in config.toml). + /// When absent, all built-in tools remain as-is and no plugin directory + /// is scanned. + #[serde(default)] + pub tools: Option, +} + +/// Runtime tool override configuration loaded from `[tools]` in config.toml. +/// +/// Users can replace any built-in tool with a script or external command, +/// or disable a tool entirely — without forking or recompiling. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct ToolsConfig { + /// Optional directory to scan for plugin tool scripts. Scripts with a + /// frontmatter header (`# name:`, `# description:`, `# schema:`) are + /// auto-discovered and registered as tools. + /// + /// Defaults to `~/.deepseek/tools/` when `None`. + pub plugin_dir: Option, + + /// Per-tool overrides keyed by built-in tool name. + /// Each override replaces or disables the named tool. + #[serde(default)] + pub overrides: Option>, +} + +/// How a user wants to replace or disable a built-in tool. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolOverride { + /// Run a local script file. The script receives the tool's JSON input + /// on stdin and must return a JSON `ToolResult` on stdout. + Script { + /// Path to the script (absolute, or relative to `~/.deepseek/tools/`). + path: String, + /// Optional static arguments prepended before the tool's JSON input. + #[serde(default)] + args: Option>, + }, + /// Run an external command. The command receives the tool's JSON input + /// on stdin and must return a JSON `ToolResult` on stdout. + Command { + /// The command to run (binary name or absolute path). + command: String, + /// Optional static arguments prepended before the tool's JSON input. + #[serde(default)] + args: Option>, + }, + /// Completely disable a built-in tool. The tool will not appear in the + /// model-visible catalog and cannot be called. + Disabled, } /// Vision model configuration for the `image_analyze` tool. @@ -2971,6 +3024,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode), runtime_api: override_cfg.runtime_api.or(base.runtime_api), workshop: override_cfg.workshop.or(base.workshop), + tools: override_cfg.tools.or(base.tools), } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index fc286d583..80f0022b1 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -171,6 +171,11 @@ pub struct EngineConfig { /// once at engine construction, then threaded onto every /// `SubAgentRuntime` the engine builds (#1806, #1808). pub subagent_api_timeout: Duration, + + /// Tool override and plugin configuration (`[tools]` table in config.toml). + /// Applied to the per-turn tool registry after built-in tools are registered. + /// When `None`, no overrides or plugin loading occurs. + pub tools: Option, } impl Default for EngineConfig { @@ -214,6 +219,7 @@ impl Default for EngineConfig { subagent_api_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS, ), + tools: None, } } } @@ -298,7 +304,7 @@ pub struct Engine { /// can fan completion events back into the engine. tx_subagent_completion: mpsc::UnboundedSender, /// Receiver paired with `tx_subagent_completion`. Drained at the - /// turn-loop's empty-tool_uses branch to surface `` + /// turn-loop's empty-tool_uses branch to surface `` /// sentinels into the parent's transcript before deciding to end the turn. pub(super) rx_subagent_completion: mpsc::UnboundedReceiver, cancel_token: CancellationToken, @@ -1060,7 +1066,7 @@ impl Engine { None }; - let tool_registry = match mode { + let mut tool_registry = match mode { AppMode::Agent | AppMode::Yolo => { if self.config.features.enabled(Feature::Subagents) { let runtime = if let Some(client) = self.deepseek_client.clone() { @@ -1108,13 +1114,83 @@ impl Engine { _ => Some(builder.build(tool_context)), }; + // Track names added by plugin loading so we never defer them. + let mut plugin_tool_names: std::collections::HashSet = + std::collections::HashSet::new(); + + // Load plugin tools from the user's tools directory and apply any + // config.toml overrides. Plugin scripts are auto-discovered and + // registered without requiring a `[tools]` config section — + // the default `~/.deepseek/tools/` directory is always checked. + if let Some(ref mut tool_registry) = tool_registry { + // Snapshot built-in tool names before any modifications. + let names_before: std::collections::HashSet = tool_registry + .names() + .into_iter() + .map(|s| s.to_string()) + .collect(); + + // Resolve the plugin directory. Defaults to `~/.deepseek/tools/`. + let default_dir = { + let home = dirs::home_dir() + .map(|h| h.join(".deepseek").join("tools")) + .unwrap_or_else(|| PathBuf::from(".deepseek/tools")); + home + }; + let plugin_dir = if let Some(ref tools_config) = self.config.tools + && let Some(ref custom_dir) = tools_config.plugin_dir + { + let p = PathBuf::from(shellexpand::tilde(custom_dir).as_ref()); + if !p.exists() { + tracing::warn!( + "Configured plugin directory {} does not exist, falling back to default", + p.display() + ); + default_dir + } else { + p + } + } else { + default_dir + }; + + // Apply per-tool overrides from config.toml (disable / replace). + if let Some(ref tools_config) = self.config.tools + && let Some(ref overrides) = tools_config.overrides + { + tool_registry.apply_overrides(overrides, &plugin_dir); + } + + // Load auto-discovered plugin scripts from the tools directory. + tool_registry.load_plugins(&plugin_dir); + + // Diff: any tool name that didn't exist before overrides/plugins + // is a user-registered tool. These should never be deferred. + let names_after: std::collections::HashSet = tool_registry + .names() + .into_iter() + .map(|s| s.to_string()) + .collect(); + plugin_tool_names = &names_after - &names_before; + } + let mcp_tools = if self.config.features.enabled(Feature::Mcp) { self.mcp_tools().await } else { Vec::new() }; + let tools = tool_registry.as_ref().map(|registry| { - build_model_tool_catalog(registry.to_api_tools_with_cache(true), mcp_tools, mode) + let mut catalog = + build_model_tool_catalog(registry.to_api_tools_with_cache(true), mcp_tools, mode); + // Ensure plugin/override tools are NOT deferred — they should be + // immediately visible since the user explicitly opted into them. + for tool in &mut catalog { + if plugin_tool_names.contains(&tool.name) { + tool.defer_loading = Some(false); + } + } + catalog }); // Main turn loop @@ -1364,7 +1440,7 @@ impl Engine { "Emergency compaction complete: {before_count} → {after_count} messages ({removed} removed), ~{before_tokens} → ~{after_tokens} tokens" ); if retries_used > 0 { - details.push_str(&format!(" ({retries_used} retries)")); + details.push_str(&format!(" ({} retries)", retries_used)); } if trimmed > 0 { details.push_str(&format!(", trimmed {trimmed} oldest")); @@ -1383,7 +1459,8 @@ impl Engine { let message = format!( "Emergency context compaction failed to reduce request below model limit \ - (estimate ~{after_tokens} tokens, budget ~{target_budget})." + (estimate ~{} tokens, budget ~{}).", + after_tokens, target_budget ); self.emit_compaction_failed(id, true, message.clone()).await; let _ = self.tx_event.send(Event::status(message)).await; diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 9f2da5ffd..0e3977227 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -180,8 +180,9 @@ impl Engine { if estimated_input > input_budget { if context_recovery_attempts >= MAX_CONTEXT_RECOVERY_ATTEMPTS { let message = format!( - "Context remains above model limit after {MAX_CONTEXT_RECOVERY_ATTEMPTS} recovery attempts \ - (~{estimated_input} token estimate, ~{input_budget} budget). Please run /compact or /clear." + "Context remains above model limit after {} recovery attempts \ + (~{} token estimate, ~{} budget). Please run /compact or /clear.", + MAX_CONTEXT_RECOVERY_ATTEMPTS, estimated_input, input_budget ); turn_error = Some(message.clone()); let _ = self @@ -308,14 +309,7 @@ impl Engine { // first call) so we can resend it on a transparent retry below // when the wire dies before any content was streamed (#103). let stream_request = request; - let stream_result = tokio::select! { - biased; - () = self.cancel_token.cancelled() => { - let _ = self.tx_event.send(Event::status("Request cancelled")).await; - return (TurnOutcomeStatus::Interrupted, None); - } - result = client.create_message_stream(stream_request.clone()) => result, - }; + let stream_result = client.create_message_stream(stream_request.clone()).await; let stream = match stream_result { Ok(s) => { context_recovery_attempts = 0; @@ -397,7 +391,6 @@ impl Engine { // Process stream events loop { let poll_outcome = tokio::select! { - biased; _ = self.cancel_token.cancelled() => None, result = tokio::time::timeout(chunk_timeout, stream.next()) => { match result { @@ -488,17 +481,13 @@ impl Engine { transparent_stream_retries = transparent_stream_retries.saturating_add(1); crate::logging::info(format!( - "Transparent stream retry {transparent_stream_retries}/{MAX_TRANSPARENT_STREAM_RETRIES} (no content received yet): {message}", + "Transparent stream retry {}/{} (no content received yet): {}", + transparent_stream_retries, MAX_TRANSPARENT_STREAM_RETRIES, message, )); // Drop the failed stream before issuing the new // request to release the underlying connection. drop(stream); - let retry_stream_result = tokio::select! { - biased; - () = self.cancel_token.cancelled() => break, - result = client.create_message_stream(stream_request.clone()) => result, - }; - match retry_stream_result { + match client.create_message_stream(stream_request.clone()).await { Ok(fresh) => { stream = fresh; stream_start = Instant::now(); @@ -583,7 +572,8 @@ impl Engine { caller, } => { crate::logging::info(format!( - "Tool '{name}' block start. Initial input: {input:?}" + "Tool '{}' block start. Initial input: {:?}", + name, input )); current_block_kind = Some(ContentBlockKind::ToolUse); current_tool_indices.insert(index, tool_uses.len()); @@ -601,7 +591,8 @@ impl Engine { } ContentBlockStart::ServerToolUse { id, name, input } => { crate::logging::info(format!( - "Server tool '{name}' block start. Initial input: {input:?}" + "Server tool '{}' block start. Initial input: {:?}", + name, input )); current_block_kind = Some(ContentBlockKind::ToolUse); current_tool_indices.insert(index, tool_uses.len()); @@ -755,11 +746,6 @@ impl Engine { } } - if self.cancel_token.is_cancelled() { - let _ = self.tx_event.send(Event::status("Request cancelled")).await; - return (TurnOutcomeStatus::Interrupted, None); - } - // #103 Phase 3 — transparent retry. The inner loop above bails // when reqwest yields chunk decode errors three times in a row; // most of the time those are recoverable proxy / HTTP/2 issues @@ -777,12 +763,14 @@ impl Engine { if stream_retry_attempts < MAX_STREAM_RETRIES { stream_retry_attempts = stream_retry_attempts.saturating_add(1); crate::logging::warn(format!( - "Stream died with no content (attempt {stream_retry_attempts}/{MAX_STREAM_RETRIES}); retrying request" + "Stream died with no content (attempt {}/{}); retrying request", + stream_retry_attempts, MAX_STREAM_RETRIES )); let _ = self .tx_event .send(Event::status(format!( - "Connection interrupted; retrying ({stream_retry_attempts}/{MAX_STREAM_RETRIES})" + "Connection interrupted; retrying ({}/{})", + stream_retry_attempts, MAX_STREAM_RETRIES ))) .await; // Don't preserve the per-stream `turn_error` — we're @@ -792,7 +780,8 @@ impl Engine { continue; } crate::logging::warn(format!( - "Stream retry budget exhausted ({stream_retry_attempts} attempts); failing turn" + "Stream retry budget exhausted ({} attempts); failing turn", + stream_retry_attempts )); } else if stream_errors == 0 { // Healthy round → reset retry budget so we don't carry over @@ -878,17 +867,6 @@ impl Engine { ) }); - // Issue #1727: did this turn produce ONLY a reasoning/thinking - // block — empty content, no tool calls (e.g. gpt-oss via ollama's - // harmony→OpenAI shim mapping to `reasoning_content`)? We do NOT - // surface anything here: after this point the same turn can still - // CONTINUE for pending steers (~below) or sub-agent completions, - // and emitting now would show a spurious "turn ended" notice right - // before the turn resumes. Capture the fact and decide later, at - // the point the turn is certain to be finishing with no sendable - // content (see the `tool_uses.is_empty()` tail). - let thinking_only_no_sendable = !has_sendable_assistant_content; - // Add assistant message to session if has_sendable_assistant_content { self.add_session_message(Message { @@ -917,7 +895,7 @@ impl Engine { // streaming with no tool calls — but if it has direct children // still running (or completions queued from children that // finished while we were inferring), surface their - // `` sentinels into the transcript and + // `` sentinels into the transcript and // resume instead of ending the turn. This fulfils the contract // already documented in `prompts/base.md`: the parent is // promised it'll see the sentinel when a child finishes. @@ -1086,64 +1064,6 @@ impl Engine { continue; } - // Issue #1727: the turn is now genuinely finishing with no - // sendable content. Control only reaches here when there were - // no pending steers (`continue`d above), no sub-agent - // completions to resume with, and we were not holding for - // running children (the `should_hold_turn_for_subagents` - // branch above would have awaited / `continue`d / returned). - // If the assistant produced ONLY a reasoning block, the prior - // code fell straight through to this `break`, emitting nothing - // and leaving the UI spinner hung. Surface a status now — - // safe because the turn can no longer resume. - // #1961: Before breaking, drain any sub-agent completions that - // arrived between the last hold check and now. If a child finished - // while we were running the thinking-only check, surface its - // sentinel rather than delaying it to the next turn. - let mut late_completions: Vec = - Vec::new(); - while let Ok(c) = self.rx_subagent_completion.try_recv() { - late_completions.push(c); - } - if !late_completions.is_empty() { - let count = late_completions.len(); - for c in late_completions { - self.add_session_message(subagent_completion_runtime_message(&c.payload)) - .await; - } - let _ = self - .tx_event - .send(Event::status(format!( - "Resuming turn with {count} late sub-agent completion(s)" - ))) - .await; - turn.next_step(); - continue; - } - - if thinking_only_no_sendable { - let holding_for_subagents = { - let running = { - let mgr = self.subagent_manager.read().await; - mgr.running_count() - }; - should_hold_turn_for_subagents(0, running) - }; - if should_emit_thinking_only_status( - tool_uses.is_empty(), - turn_error.is_none(), - self.cancel_token.is_cancelled(), - !pending_steers.is_empty(), - holding_for_subagents, - ) { - let message = "Model returned reasoning but no answer or tool call; \ - turn ended without output. Send a follow-up to retry." - .to_string(); - crate::logging::warn(&message); - let _ = self.tx_event.send(Event::status(message)).await; - } - } - break; } @@ -1174,7 +1094,8 @@ impl Engine { let tool_input = tool.input.clone(); let tool_caller = tool.caller.clone(); crate::logging::info(format!( - "Planning tool '{tool_name}' with input: {tool_input:?}" + "Planning tool '{}' with input: {:?}", + tool_name, tool_input )); let interactive = (tool_name == "exec_shell" @@ -1218,7 +1139,8 @@ impl Engine { && let Some(canonical) = registry.resolve(&tool_name) { crate::logging::info(format!( - "Resolved hallucinated tool name '{tool_name}' -> '{canonical}'" + "Resolved hallucinated tool name '{}' -> '{}'", + tool_name, canonical )); tool_def = tool_catalog.iter().find(|d| d.name == canonical); if tool_def.is_some() { @@ -1297,7 +1219,8 @@ impl Engine { format!("Auto-loaded deferred tool '{tool_name}' after model request.") } else { format!( - "Auto-loaded deferred tool '{tool_name}' after resolving '{requested_tool_name}'." + "Auto-loaded deferred tool '{}' after resolving '{}'.", + tool_name, requested_tool_name ) }; let _ = self.tx_event.send(Event::status(status)).await; @@ -2018,6 +1941,39 @@ impl Engine { // and destroys DeepSeek's KV prefix cache reuse. self.session.messages.clone() } + + /// Record a completed tool execution outcome and emit the completion + /// event. Extracted to eliminate the identical `started_at → await → + /// ToolCallComplete → ToolExecOutcome` boilerplate shared by + /// dotnet_execution and RuntimeTool-based tools (go, ts, rust). + #[allow(dead_code)] + async fn emit_tool_outcome( + &self, + started_at: Instant, + tool_id: String, + tool_name: String, + tool_input: serde_json::Value, + result: Result, + outcomes: &mut [Option], + index: usize, + ) { + let _ = self + .tx_event + .send(Event::ToolCallComplete { + id: tool_id.clone(), + name: tool_name.clone(), + result: result.clone(), + }) + .await; + outcomes[index] = Some(ToolExecOutcome { + index, + id: tool_id, + name: tool_name, + input: tool_input, + started_at, + result, + }); + } } fn subagent_completion_runtime_message(payload: &str) -> Message { @@ -2025,13 +1981,13 @@ fn subagent_completion_runtime_message(payload: &str) -> Message { role: "system".to_string(), content: vec![ContentBlock::Text { text: format!( - "\n\ + "\n\ This is an internal runtime event, not user input. Use the sub-agent completion \ data below to continue coordinating the current task. Do not tell the user they \ pasted sentinels, do not explain the sentinel protocol, and do not quote the raw \ XML unless the user explicitly asks to debug sub-agent internals.\n\n\ {payload}\n\ -" +" ), cache_control: None, }], @@ -2042,27 +1998,6 @@ fn should_hold_turn_for_subagents(queued_completions: usize, running_children: u queued_completions > 0 || running_children > 0 } -/// Issue #1727: decide whether to surface a "thinking-only, no output" status. -/// -/// Reached when the assistant turn had no sendable content (no Text, no -/// ToolUse — only a reasoning/thinking block). We notify the user *only* when -/// the turn is genuinely finishing: no tool uses to dispatch, no `turn_error` -/// already surfaced for this turn, the request wasn't cancelled, AND the turn -/// is not about to CONTINUE — there are no pending steers and we are not -/// holding the turn open for running sub-agents. The status must fire at the -/// point the turn truly ends; emitting it earlier (at the persist site) would -/// show a spurious "turn ended" notice immediately before the turn resumed -/// for a steer or a sub-agent completion. -fn should_emit_thinking_only_status( - tool_uses_empty: bool, - turn_error_is_none: bool, - cancelled: bool, - steers_pending: bool, - holding_for_subagents: bool, -) -> bool { - tool_uses_empty && turn_error_is_none && !cancelled && !steers_pending && !holding_for_subagents -} - /// Resolve an `"auto"` reasoning-effort tier to a concrete value. /// /// When the configured effort is `"auto"`, inspects the last user message @@ -2124,7 +2059,7 @@ mod tests { #[test] fn subagent_completion_handoff_is_internal_system_message() { let message = subagent_completion_runtime_message( - "Build passed\n{\"agent_id\":\"agent_a\"}", + "Build passed\n{\"agent_id\":\"agent_a\"}", ); assert_eq!(message.role, "system"); @@ -2134,7 +2069,7 @@ mod tests { }; assert!(text.contains("internal runtime event, not user input")); assert!(text.contains("Do not tell the user they pasted sentinels")); - assert!(text.contains("")); + assert!(text.contains("")); assert!(text.contains("Build passed")); } @@ -2145,71 +2080,6 @@ mod tests { assert!(!should_hold_turn_for_subagents(0, 0)); } - /// Regression test for issue #1727 (P0, release-blocking). - /// - /// When a model (e.g. gpt-oss via ollama's harmony→OpenAI shim) returns - /// ONLY a reasoning/thinking block — empty `content`, no `tool_calls` — - /// `has_sendable_assistant_content` is false, so no assistant message is - /// persisted. Previously the code also emitted NO event and fell straight - /// through to finishing the turn: the UI spinner stayed up forever with no - /// error, looking hung. - /// - /// This pins the decision: a clean turn end (no tool uses to dispatch, no - /// `turn_error`, not cancelled, no pending steers, not holding for - /// sub-agents) must surface a status. We must NOT spam the status when the - /// turn is ending for another reason (error already shown, cancelled), - /// when there are tool uses still to dispatch, or — critically (the - /// MEDIUM review finding) — when the turn is about to CONTINUE because a - /// steer is pending or sub-agents are still running. Emitting at the old - /// persist site fired before those continuations were known. - /// - /// Limitation: this tests the extracted pure decision, not the full async - /// `handle_deepseek_turn` loop (driving it would need a mock DeepSeek - /// client + session + channels — far beyond a surgical fix and unlike any - /// existing turn-loop test, which all pin pure helpers the same way). The - /// wiring at the `tool_uses.is_empty()` tail (capture-then-decide, with the - /// live steer/sub-agent signals) is reviewed by inspection — consistent - /// with how the other turn-loop helpers in this module are tested. - #[test] - fn thinking_only_turn_emits_status_only_on_clean_end() { - // Thinking-only response, turn genuinely ending (no tool uses, no - // error, not cancelled, no steers pending, not holding for - // sub-agents) → surface a status so the user isn't left staring at a - // hung spinner. - assert!(should_emit_thinking_only_status( - true, true, false, false, false - )); - - // Tool uses still pending → the normal dispatch path handles it; no - // thinking-only status. - assert!(!should_emit_thinking_only_status( - false, true, false, false, false - )); - - // A turn_error was already surfaced → don't double-report. - assert!(!should_emit_thinking_only_status( - true, false, false, false, false - )); - - // Request was cancelled → cancellation status already covers it. - assert!(!should_emit_thinking_only_status( - true, true, true, false, false - )); - - // A steer is pending → the turn will resume with the steer; emitting - // "turn ended" now would be a spurious notice right before the turn - // continues (the MEDIUM correctness finding). - assert!(!should_emit_thinking_only_status( - true, true, false, true, false - )); - - // Sub-agents are still running / completions queued → the turn is - // held open and will resume; do not claim it ended. - assert!(!should_emit_thinking_only_status( - true, true, false, false, true - )); - } - /// Regression test for the OpenAI streaming batch tool_calls bug. /// /// Background: when an OpenAI-compatible backend (vLLM, Ollama, LM Studio, diff --git a/crates/tui/src/dependencies.rs b/crates/tui/src/dependencies.rs index 1918c291e..64854fba0 100644 --- a/crates/tui/src/dependencies.rs +++ b/crates/tui/src/dependencies.rs @@ -119,11 +119,11 @@ pub fn resolve_pdftotext() -> Option { .clone() } -/// Resolve `tesseract` (OCR engine) once per process. Used by the -/// `image_ocr` tool on platforms that do not have a native OCR backend. -/// Tesseract is the de-facto open-source OCR engine and ships as a single -/// binary on every platform we support, so the candidate list is just -/// `tesseract`. +/// Resolve `tesseract` (OCR engine) once per process. Used by +/// the `image_ocr` tool to decide whether to register itself with +/// the model. Tesseract is the de-facto open-source OCR engine and +/// ships as a single binary on every platform we support, so the +/// candidate list is just `tesseract`. pub fn resolve_tesseract() -> Option { static CACHE: OnceLock> = OnceLock::new(); CACHE @@ -137,7 +137,7 @@ pub fn resolve_tesseract() -> Option { } else { tracing::warn!( target: "tool_dependencies", - "tesseract binary not found; image_ocr will rely on native OCR if available", + "tesseract binary not found; image_ocr tool will not be registered", ); None } @@ -198,6 +198,348 @@ pub fn resolve_node() -> Option { .clone() } +/// Extract the simple type name from `std::any::type_name` output. +/// e.g. turns `deepseek_tui::dependencies::Git` into `Git`. +fn simple_type_name() -> &'static str { + let full = std::any::type_name::(); + full.rsplit("::").next().unwrap_or(full) +} + +// --------------------------------------------------------------------------- +// ExternalTool trait — unified subprocess interface +// --------------------------------------------------------------------------- + +/// A tool that DeepSeek-TUI shells out to. Instead of scattering +/// `Command::new("git")` / `Command::new("gh")` across the codebase, +/// each external dependency implements this trait once in this module. +/// Callers ask the tool for a pre-populated [`Command`] and chain their +/// own args, working directory, and spawn method. +/// +/// # Example +/// +/// ```ignore +/// let output = Git::command() +/// .expect("git not found") +/// .args(["diff", "--stat"]) +/// .current_dir(&workspace) +/// .output()?; +/// ``` +#[allow(dead_code)] +pub trait ExternalTool { + /// Candidate binary names, tried in order until one responds to + /// `--version`. For single-binary tools (git, gh, node) this is a + /// one-element slice. + fn candidates() -> &'static [&'static str]; + + /// Resolve the best candidate once per process (cached). Returns + /// the spec string (e.g. `"python3"` or `"py -3"`). + fn resolve() -> Option; + + /// Quick availability check — true when the tool was found on PATH. + fn available() -> bool { + Self::resolve().is_some() + } + + /// Build a `std::process::Command` pre-populated with the resolved + /// binary (and any fixed arguments from a multi-word candidate like + /// `"py -3"`). Returns `None` when the tool isn't installed. + /// + /// Callers should chain `.args(...)`, `.current_dir(...)`, and then + /// call `.output()`, `.status()`, or `.spawn()`. + fn command() -> Option { + let spec = Self::resolve()?; + let (program, fixed_args) = split_interpreter_spec(&spec); + let mut cmd = Command::new(&program); + for arg in &fixed_args { + cmd.arg(arg); + } + Some(cmd) + } + + /// Convenience: run the tool with arguments in a working directory + /// and return the captured output. + fn output(args: &[&str], cwd: &std::path::Path) -> std::io::Result { + let mut cmd = Self::command().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("{} not found on PATH", simple_type_name::()), + ) + })?; + cmd.args(args).current_dir(cwd).output() + } + + /// Convenience: run the tool with arguments and return only the + /// exit status (discards stdout/stderr). + fn status(args: &[&str], cwd: &std::path::Path) -> std::io::Result { + let mut cmd = Self::command().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("{} not found on PATH", simple_type_name::()), + ) + })?; + cmd.args(args).current_dir(cwd).status() + } + + /// Build a `tokio::process::Command` pre-populated with the resolved + /// binary (and any fixed arguments from a multi-word candidate like + /// `"py -3"`). Returns `None` when the tool isn't installed. + /// + /// Async callers (`code_execution`, `js_execution`) use this instead + /// of [`ExternalTool::command`] so they can `.await` the child. + fn tokio_command() -> Option { + let spec = Self::resolve()?; + let (program, fixed_args) = split_interpreter_spec(&spec); + let mut cmd = tokio::process::Command::new(&program); + for arg in &fixed_args { + cmd.arg(arg); + } + Some(cmd) + } +} + +// --------------------------------------------------------------------------- +// Concrete tool implementations +// --------------------------------------------------------------------------- + +/// Git version control. +pub struct Git; + +impl ExternalTool for Git { + fn candidates() -> &'static [&'static str] { + &["git"] + } + + fn resolve() -> Option { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + for candidate in Self::candidates() { + if probe_executable(candidate) { + tracing::info!(target: "tool_dependencies", "Resolved git binary"); + return Some((*candidate).to_string()); + } + } + None + }) + .clone() + } +} + +/// GitHub CLI. +pub struct Gh; + +impl ExternalTool for Gh { + fn candidates() -> &'static [&'static str] { + &["gh"] + } + + fn resolve() -> Option { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + for candidate in Self::candidates() { + if probe_executable(candidate) { + tracing::info!( + target: "tool_dependencies", + "Resolved gh binary: {candidate}" + ); + return Some((*candidate).to_string()); + } + } + None + }) + .clone() + } +} + +/// Rust compiler — used for version reporting in diagnostics. +pub struct RustC; + +impl ExternalTool for RustC { + fn candidates() -> &'static [&'static str] { + &["rustc"] + } + + fn resolve() -> Option { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + for candidate in Self::candidates() { + if probe_executable(candidate) { + tracing::info!( + target: "tool_dependencies", + "Resolved rustc binary: {candidate}" + ); + return Some((*candidate).to_string()); + } + } + None + }) + .clone() + } +} + +/// Rust build tool — used by the `run_tests` tool. +#[allow(dead_code)] +pub struct Cargo; + +impl ExternalTool for Cargo { + fn candidates() -> &'static [&'static str] { + &["cargo"] + } + + fn resolve() -> Option { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + for candidate in Self::candidates() { + if probe_executable(candidate) { + tracing::info!( + target: "tool_dependencies", + "Resolved cargo binary: {candidate}" + ); + return Some((*candidate).to_string()); + } + } + None + }) + .clone() + } +} + +/// Python interpreter — used by `code_execution` tool and RLM REPL. +/// Delegates to the existing [`resolve_python_interpreter`] so the +/// multi-candidate ladder (`python3` → `python` → `py -3`) is +/// shared with legacy callers until they migrate to the trait. +#[allow(dead_code)] +pub struct Python; + +impl ExternalTool for Python { + fn candidates() -> &'static [&'static str] { + PYTHON_CANDIDATES + } + + fn resolve() -> Option { + resolve_python_interpreter() + } +} + +/// Node.js runtime — used by the `js_execution` tool. +/// The binary name `node` is the same on every platform we support, +/// so this is a single probe rather than a candidate ladder. +#[allow(dead_code)] +pub struct Node; + +impl ExternalTool for Node { + fn candidates() -> &'static [&'static str] { + &["node"] + } + + fn resolve() -> Option { + resolve_node() + } +} + +/// .NET SDK — used by the `dotnet_execution` tool. +/// Starting with .NET 6, `dotnet run file.cs` can run a single C# file +/// without a project. The binary is `dotnet` on all platforms. +#[allow(dead_code)] +pub struct DotNet; + +impl ExternalTool for DotNet { + fn candidates() -> &'static [&'static str] { + &["dotnet"] + } + + fn resolve() -> Option { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + for candidate in Self::candidates() { + if probe_executable(candidate) { + tracing::info!( + target: "tool_dependencies", + "Resolved dotnet binary: {candidate}" + ); + return Some((*candidate).to_string()); + } + } + None + }) + .clone() + } +} + +// --------------------------------------------------------------------------- +/// Go toolchain — used by the `go_execution` tool. +/// +/// `go run file.go` compiles and executes in one step. +#[allow(dead_code)] +pub struct Go; + +impl ExternalTool for Go { + fn candidates() -> &'static [&'static str] { + &["go", "go.cmd"] + } + + fn resolve() -> Option { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + for candidate in Self::candidates() { + if probe_executable(candidate) { + tracing::info!( + target: "tool_dependencies", + "Resolved go binary: {candidate}" + ); + return Some((*candidate).to_string()); + } + } + None + }) + .clone() + } +} + +/// TypeScript runtime — used by the `ts_execution` tool. +/// +/// Tries `tsx` first (lightning-fast ESM, Node 24 compatible), +/// then `ts-node` (most common historically), then `deno` (built-in +/// TS). The multi-candidate ladder is similar to Python's +/// `python3`/`python`/`py -3`. +#[allow(dead_code)] +pub struct TypeScript; + +#[allow(dead_code)] +const TS_CANDIDATES: &[&str] = &["tsx", "tsx.cmd", "ts-node", "deno", "npx tsx"]; + +impl ExternalTool for TypeScript { + fn candidates() -> &'static [&'static str] { + TS_CANDIDATES + } + + fn resolve() -> Option { + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| { + for candidate in Self::candidates() { + if probe_executable(candidate) { + tracing::info!( + target: "tool_dependencies", + "Resolved TypeScript runtime: {candidate}" + ); + return Some(candidate.to_string()); + } + } + None + }) + .clone() + } +} + +// Legacy interpreter helpers (kept for existing callers until migrated) +// --------------------------------------------------------------------------- + /// Split an interpreter spec like `"py -3"` into the program name /// and any initial arguments. Returns `("py", vec!["-3"])` for the /// example; returns `("python3", vec![])` for a bare name. @@ -219,7 +561,7 @@ mod tests { fn probe_executable_returns_false_for_unknown_binary() { // Pick a name we're confident isn't on any developer's PATH. // If this ever starts failing locally, rename it. - assert!(!probe_executable("codewhale-tui-imaginary-binary-xyz123")); + assert!(!probe_executable("deepseek-tui-imaginary-binary-xyz123")); } #[test] @@ -288,4 +630,211 @@ mod tests { ); } } + + // =================================================================== + // ExternalTool trait tests + // =================================================================== + + #[test] + fn python_candidates_matches_const() { + assert_eq!(Python::candidates(), PYTHON_CANDIDATES); + } + + #[test] + fn node_candidates_is_node_only() { + assert_eq!(Node::candidates(), &["node"]); + } + + #[test] + fn git_candidates_is_git_only() { + assert_eq!(Git::candidates(), &["git"]); + } + + #[test] + fn gh_candidates_is_gh_only() { + assert_eq!(Gh::candidates(), &["gh"]); + } + + #[test] + fn rustc_candidates_is_rustc_only() { + assert_eq!(RustC::candidates(), &["rustc"]); + } + + #[test] + fn cargo_candidates_is_cargo_only() { + assert_eq!(Cargo::candidates(), &["cargo"]); + } + + #[test] + fn git_resolve_is_cached() { + let first = Git::resolve(); + let second = Git::resolve(); + assert_eq!(first, second); + } + + #[test] + fn gh_resolve_is_cached() { + let first = Gh::resolve(); + let second = Gh::resolve(); + assert_eq!(first, second); + } + + #[test] + fn python_trait_resolve_is_cached() { + let first = Python::resolve(); + let second = Python::resolve(); + assert_eq!(first, second); + } + + #[test] + fn node_resolve_is_cached() { + let first = Node::resolve(); + let second = Node::resolve(); + assert_eq!(first, second); + } + + #[test] + fn rustc_resolve_is_cached() { + let first = RustC::resolve(); + let second = RustC::resolve(); + assert_eq!(first, second); + } + + #[test] + fn cargo_resolve_is_cached() { + let first = Cargo::resolve(); + let second = Cargo::resolve(); + assert_eq!(first, second); + } + + #[test] + fn git_available_matches_resolve() { + assert_eq!(Git::available(), Git::resolve().is_some()); + } + + #[test] + fn python_available_matches_resolve() { + assert_eq!(Python::available(), Python::resolve().is_some()); + } + + #[test] + fn node_available_matches_resolve() { + assert_eq!(Node::available(), Node::resolve().is_some()); + } + + #[test] + fn rustc_available_matches_resolve() { + assert_eq!(RustC::available(), RustC::resolve().is_some()); + } + + #[test] + fn cargo_available_matches_resolve() { + assert_eq!(Cargo::available(), Cargo::resolve().is_some()); + } + + #[test] + fn git_command_returns_some_when_available() { + if Git::available() { + assert!(Git::command().is_some()); + } + } + + #[test] + fn python_command_returns_some_when_available() { + if Python::available() { + assert!(Python::command().is_some()); + } + } + + #[test] + fn python_tokio_command_returns_some_when_available() { + if Python::available() { + assert!(Python::tokio_command().is_some()); + } + } + + #[test] + fn node_tokio_command_returns_some_when_available() { + if Node::available() { + assert!(Node::tokio_command().is_some()); + } + } + + #[test] + fn git_output_version_succeeds() { + // Only run when git is actually installed. + if !Git::available() { + return; + } + let tmp = std::env::temp_dir(); + let out = Git::output(&["--version"], &tmp); + assert!( + out.is_ok(), + "git --version must succeed when git is available" + ); + let out = out.unwrap(); + assert!(out.status.success(), "git --version must exit 0"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("git version"), + "git --version stdout must contain 'git version', got: {}", + stdout.trim() + ); + } + + #[test] + fn python_output_version_succeeds() { + if !Python::available() { + return; + } + let tmp = std::env::temp_dir(); + let out = Python::output(&["--version"], &tmp); + assert!(out.is_ok(), "python --version must spawn"); + let out = out.unwrap(); + // Python --version writes to stdout on 3.x, so just check + // that it succeeded (exit 0). + assert!(out.status.success(), "python --version must exit 0"); + } + + #[test] + fn node_output_version_succeeds() { + if !Node::available() { + return; + } + let tmp = std::env::temp_dir(); + let out = Node::output(&["--version"], &tmp); + assert!(out.is_ok(), "node --version must spawn"); + let out = out.unwrap(); + assert!(out.status.success(), "node --version must exit 0"); + } + + #[test] + fn cargo_output_version_succeeds() { + if !Cargo::available() { + return; + } + let tmp = std::env::temp_dir(); + let out = Cargo::output(&["--version"], &tmp); + assert!(out.is_ok(), "cargo --version must spawn"); + let out = out.unwrap(); + assert!(out.status.success(), "cargo --version must exit 0"); + } + + #[test] + fn external_tool_output_respects_cwd() { + // Verify that `output()` runs in the requested directory. + if !Git::available() { + return; + } + let tmp = std::env::temp_dir(); + let out = Git::output(&["rev-parse", "--show-toplevel"], &tmp); + assert!(out.is_ok(), "git rev-parse must spawn"); + let out = out.unwrap(); + // rev-parse --show-toplevel in a non-git dir should fail + // because temp_dir is not a git repo. That's expected. + // The key assertion: the command executed without IO errors. + // We don't assert success because temp_dir might or might not + // be inside a git worktree. + let _ = out; // just checking it didn't panic/IO-error + } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 34cb7fce7..1f1e84e1b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -12,6 +12,8 @@ use dotenvy::dotenv; use tempfile::NamedTempFile; use wait_timeout::ChildExt; +use crate::dependencies::ExternalTool; + mod acp_server; mod artifacts; mod audit; @@ -63,6 +65,7 @@ mod schema_migration; mod seam_manager; mod session_manager; mod settings; +mod shell_dispatcher; mod skill_state; mod skills; mod snapshot; @@ -101,12 +104,12 @@ fn configure_windows_console_utf8() {} #[derive(Parser, Debug)] #[command( - name = "codewhale-tui", - bin_name = "codewhale-tui", + name = "deepseek-tui", + bin_name = "deepseek-tui", author, version = env!("DEEPSEEK_BUILD_VERSION"), - about = "codewhale/CLI for DeepSeek models", - long_about = "Terminal-native TUI and CLI for DeepSeek models.\n\nRun 'codewhale' to start.\n\nNot affiliated with DeepSeek Inc." + about = "DeepSeek TUI/CLI for DeepSeek models", + long_about = "Terminal-native TUI and CLI for DeepSeek models.\n\nRun 'deepseek' to start.\n\nNot affiliated with DeepSeek Inc." )] struct Cli { /// Subcommand to run @@ -432,7 +435,7 @@ fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result ...`.", + "No saved sessions found for workspace {}. Use `deepseek sessions` to list sessions, or pass `deepseek exec --resume ...`.", workspace.display() ) }, @@ -650,15 +653,15 @@ enum McpCommand { Validate, /// Register this DeepSeek binary as a local MCP stdio server. /// - /// This adds a config entry that runs `codewhale serve --mcp` (stdio protocol). - /// For the HTTP/SSE runtime API, use `codewhale serve --http` directly instead. + /// This adds a config entry that runs `deepseek serve --mcp` (stdio protocol). + /// For the HTTP/SSE runtime API, use `deepseek serve --http` directly instead. #[command( name = "add-self", - long_about = "Register this DeepSeek binary as a local MCP stdio server.\n\nAdds a config entry to ~/.deepseek/mcp.json that launches `codewhale serve --mcp`\nvia the stdio transport. Other DeepSeek sessions (or any MCP client) can then\ndiscover and call tools exposed by this server.\n\nUse `codewhale serve --http` instead if you need the HTTP/SSE runtime API." + long_about = "Register this DeepSeek binary as a local MCP stdio server.\n\nAdds a config entry to ~/.deepseek/mcp.json that launches `deepseek serve --mcp`\nvia the stdio transport. Other DeepSeek sessions (or any MCP client) can then\ndiscover and call tools exposed by this server.\n\nUse `deepseek serve --http` instead if you need the HTTP/SSE runtime API." )] AddSelf { - /// Server name in mcp.json (default: "codewhale") - #[arg(long, default_value = "codewhale")] + /// Server name in mcp.json (default: "deepseek") + #[arg(long, default_value = "deepseek")] name: String, /// Workspace directory for the MCP server #[arg(long)] @@ -969,7 +972,7 @@ async fn main() -> Result<()> { return run_one_shot(&config, &model, &prompt).await; } - // Handle session resume. Plain `codewhale` starts fresh: interrupted + // Handle session resume. Plain `deepseek` starts fresh: interrupted // snapshots are preserved for explicit resume, but never auto-attached. let resume_session_id = if cli.continue_session { let workspace = resolve_workspace(&cli); @@ -1454,18 +1457,26 @@ fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStat fn tools_readme_template() -> &'static str { "# Local tools\n\n\ - Drop self-describing scripts here so they can be discovered by\n\ - `codewhale-tui setup --status` and surfaced in `codewhale-tui doctor`.\n\n\ - Each script should start with a frontmatter-style header so the\n\ - description is visible without executing the file:\n\n\ + Drop self-describing scripts here — they are auto-discovered and\n\ + registered as model-visible tools when `[tools.plugin_dir]` is set in\n\ + config.toml (or when the default `~/.deepseek/tools/` directory exists).\n\n\ + Each script must start with a frontmatter-style header so the agent\n\ + knows the tool name, description, and input schema:\n\n\ ```\n\ # name: my-tool\n\ # description: One-line summary of what this tool does\n\ - # usage: my-tool [args...]\n\ + # schema: {\"type\":\"object\",\"properties\":{\"input\":{\"type\":\"string\"}}}\n\ + # approval: auto\n\ + ```\n\n\ + The script receives the tool's JSON input on **stdin** and must return\n\ + a JSON `ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n\ + To override a built-in tool (e.g. `exec_shell` with an audit wrapper),\n\ + add an entry to the `[tools.overrides]` table in config.toml:\n\n\ + ```toml\n\ + [tools.overrides]\n\ + \"exec_shell\" = { type = \"script\", path = \"audit-exec-shell.sh\" }\n\ ```\n\n\ - The directory is intentionally not auto-loaded into the agent's tool\n\ - catalog. Wire individual tools through MCP, hooks, or skills when you\n\ - want them available inside a session.\n" + See `config.example.toml` for the full [tools] reference.\n" } fn tools_example_script() -> &'static str { @@ -1473,7 +1484,7 @@ fn tools_example_script() -> &'static str { # name: example\n\ # description: Print a confirmation that local tool discovery works\n\ # usage: example [name]\n\ - printf 'codewhale-tui local tool ok: %s\\n' \"${1:-world}\"\n" + printf 'deepseek-tui local tool ok: %s\\n' \"${1:-world}\"\n" } fn init_tools_dir(tools_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus, WriteStatus)> { @@ -1534,7 +1545,7 @@ fn init_plugins_dir( Ok((readme_path, example_path, readme_status, example_status)) } -/// Resolve the user-supplied CORS origins for `codewhale serve --http`. +/// Resolve the user-supplied CORS origins for `deepseek serve --http`. /// /// Sources, in priority order (later sources extend earlier ones): /// 1. `--cors-origin URL` flags (repeatable) @@ -1661,9 +1672,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> { println!(" · MCP config already exists at {}", mcp_path.display()); } } - println!( - " Next: edit the file, then run `codewhale mcp list` or `codewhale mcp tools`." - ); + println!(" Next: edit the file, then run `deepseek mcp list` or `deepseek mcp tools`."); } if run_skills { @@ -1968,7 +1977,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { println!(" {} {}", "·".dimmed(), dotenv_status_line(workspace)); println!(); - println!("Run `codewhale doctor --json` for a machine-readable check."); + println!("Run `deepseek doctor --json` for a machine-readable check."); Ok(()) } @@ -2036,14 +2045,16 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( "{}", - "codewhale Doctor".truecolor(blue_r, blue_g, blue_b).bold() + "DeepSeek TUI Doctor" + .truecolor(blue_r, blue_g, blue_b) + .bold() ); println!("{}", "==================".truecolor(sky_r, sky_g, sky_b)); println!(); // Version info println!("{}", "Version Information:".bold()); - println!(" codewhale-tui: {}", env!("DEEPSEEK_BUILD_VERSION")); + println!(" deepseek-tui: {}", env!("DEEPSEEK_BUILD_VERSION")); println!(" rust: {}", rustc_version()); println!(); @@ -2120,25 +2131,6 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "nvidia-nim", &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"][..], ), - ( - crate::config::ApiProvider::Openai, - "openai", - &["OPENAI_API_KEY"][..], - ), - ( - crate::config::ApiProvider::Atlascloud, - "atlascloud", - &["ATLASCLOUD_API_KEY"][..], - ), - ( - crate::config::ApiProvider::WanjieArk, - "wanjie-ark", - &[ - "WANJIE_ARK_API_KEY", - "WANJIE_API_KEY", - "WANJIE_MAAS_API_KEY", - ][..], - ), ( crate::config::ApiProvider::Openrouter, "openrouter", @@ -2315,7 +2307,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt } else if error_msg.contains("connect") { println!(" Connection failed. Check firewall settings or try again"); } else { - println!(" Error: {error_msg}"); + println!(" Error: {}", error_msg); } } } @@ -2401,7 +2393,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "·".dimmed(), crate::utils::display_path(&mcp_config_path) ); - println!(" Run `codewhale mcp init` or `codewhale setup --mcp`."); + println!(" Run `deepseek mcp init` or `deepseek setup --mcp`."); } // Skills configuration @@ -2531,7 +2523,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt .is_some_and(|dir| dir.exists()) && !global_skills_dir.exists() { - println!(" Run `codewhale setup --skills` (or add --local for ./skills)."); + println!(" Run `deepseek setup --skills` (or add --local for ./skills)."); } // Tools directory @@ -2552,7 +2544,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "·".dimmed(), crate::utils::display_path(&tools_dir) ); - println!(" Run `codewhale setup --tools` to scaffold a starter dir."); + println!(" Run `deepseek setup --tools` to scaffold a starter dir."); } // Plugins directory @@ -2573,7 +2565,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "·".dimmed(), crate::utils::display_path(&plugins_dir) ); - println!(" Run `codewhale setup --plugins` to scaffold a starter dir."); + println!(" Run `deepseek setup --plugins` to scaffold a starter dir."); } // Storage surfaces (#422 / #440 / #500) @@ -2707,42 +2699,23 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt } match crate::dependencies::resolve_tesseract() { - Some(_) => { - if cfg!(target_os = "macos") { - println!( - " {} OCR: macOS Vision + tesseract available → image_ocr/read_file screenshot OCR enabled", - "✓".truecolor(aqua_r, aqua_g, aqua_b), - ); - } else { - println!( - " {} tesseract: present → image_ocr/read_file screenshot OCR enabled", - "✓".truecolor(aqua_r, aqua_g, aqua_b), - ); - } - } + Some(_) => println!( + " {} tesseract: present → image_ocr tool registered", + "✓".truecolor(aqua_r, aqua_g, aqua_b), + ), None => { - if cfg!(target_os = "macos") { - println!( - " {} OCR: macOS Vision available → image_ocr/read_file screenshot OCR enabled", - "✓".truecolor(aqua_r, aqua_g, aqua_b), - ); - println!( - " tesseract not found (optional; install only for alternate OCR packs)." - ); - } else { - println!(" {} tesseract: not found (optional)", "·".dimmed(),); - println!( - " image_ocr tool is NOT advertised to the model. Install tesseract to enable:" - ); - match std::env::consts::OS { - "macos" => println!(" brew install tesseract"), - "linux" => println!( - " sudo apt install tesseract-ocr (Debian/Ubuntu) — or your distro's equivalent" - ), - "windows" => println!(" winget install UB-Mannheim.TesseractOCR"), - other => { - println!(" install tesseract for {other} from tesseract-ocr.github.io") - } + println!(" {} tesseract: not found (optional)", "·".dimmed(),); + println!( + " image_ocr tool is NOT advertised to the model. Install tesseract to enable:" + ); + match std::env::consts::OS { + "macos" => println!(" brew install tesseract"), + "linux" => println!( + " sudo apt install tesseract-ocr (Debian/Ubuntu) — or your distro's equivalent" + ), + "windows" => println!(" winget install UB-Mannheim.TesseractOCR"), + other => { + println!(" install tesseract for {other} from tesseract-ocr.github.io") } } } @@ -3106,7 +3079,7 @@ fn run_doctor_json( }, "api_connectivity": { "checked": false, - "note": "Skipped in --json mode; run `codewhale doctor` for a live check.", + "note": "Skipped in --json mode; run `deepseek doctor` for a live check.", }, "capability": provider_capability_report(config), }); @@ -3243,7 +3216,7 @@ fn doctor_timeout_recovery_lines(config: &Config) -> Vec { && !target.base_url.contains("api.deepseeki.com") => { lines.push( - "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.deepseek/config.toml and rerun `codewhale doctor`." + "If this is a custom DeepSeek-compatible endpoint, set its HTTPS base URL in ~/.deepseek/config.toml and rerun `deepseek doctor`." .to_string(), ); } @@ -3262,7 +3235,7 @@ fn doctor_timeout_recovery_lines(config: &Config) -> Vec { } lines.push( - "Run `codewhale doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue." + "Run `deepseek doctor --json` and include `base_url`, `default_text_model`, and `api_connectivity` when filing an issue." .to_string(), ); lines @@ -3355,13 +3328,15 @@ async fn test_api_connectivity(config: &Config) -> Result<()> { } fn rustc_version() -> String { - // Try to get rustc version, fall back to "unknown" - std::process::Command::new("rustc") - .arg("--version") - .output() - .ok() - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string()) + let Some(mut cmd) = crate::dependencies::RustC::command() else { + return "unknown".to_string(); + }; + let Ok(output) = cmd.arg("--version").output() else { + return "unknown".to_string(); + }; + String::from_utf8(output.stdout) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) } /// List saved sessions @@ -3386,7 +3361,7 @@ fn list_sessions(limit: usize, search: Option) -> Result<()> { println!("{}", "No sessions found.".truecolor(sky_r, sky_g, sky_b)); println!( "Start a new session with: {}", - "codewhale".truecolor(blue_r, blue_g, blue_b) + "deepseek".truecolor(blue_r, blue_g, blue_b) ); return Ok(()); } @@ -3419,12 +3394,12 @@ fn list_sessions(limit: usize, search: Option) -> Result<()> { println!(); println!( "Resume with: {} {}", - "codewhale --resume".truecolor(blue_r, blue_g, blue_b), + "deepseek --resume".truecolor(blue_r, blue_g, blue_b), "".dimmed() ); println!( "Continue latest in this workspace: {}", - "codewhale --continue".truecolor(blue_r, blue_g, blue_b) + "deepseek --continue".truecolor(blue_r, blue_g, blue_b) ); Ok(()) @@ -3461,7 +3436,7 @@ fn init_project() -> Result<()> { ); println!(); println!("Edit this file to customize how the AI agent works with your project."); - println!("The instructions will be loaded automatically when you run codewhale."); + println!("The instructions will be loaded automatically when you run deepseek."); } Err(e) => { println!( @@ -3525,7 +3500,7 @@ fn resolve_session_id(session_id: Option, last: bool, workspace: &Path) if last { return latest_session_id_for_workspace(workspace)?.ok_or_else(|| { anyhow!( - "No saved sessions found for workspace {}. Use `codewhale sessions` to list all sessions, or `codewhale resume ` to resume one explicitly.", + "No saved sessions found for workspace {}. Use `deepseek sessions` to list all sessions, or `deepseek resume ` to resume one explicitly.", workspace.display() ) }); @@ -3570,7 +3545,6 @@ fn fork_session(session_id: Option, last: bool, workspace: &Path) -> Res system_prompt.as_ref(), ); forked.metadata.copy_cost_from(&saved.metadata); - forked.metadata.mark_forked_from(&saved.metadata); manager.save_session(&forked)?; let source_title = saved.metadata.title.trim(); @@ -3688,7 +3662,7 @@ Provide findings ordered by severity with file references, then open questions, Ok(()) } -/// `codewhale pr ` (#451) — fetch a GitHub PR via `gh`, format +/// `deepseek pr ` (#451) — fetch a GitHub PR via `gh`, format /// title + body + diff as the composer's first message, and launch /// the interactive TUI. Falls back gracefully if `gh` is missing. async fn run_pr( @@ -3702,7 +3676,7 @@ async fn run_pr( bail!( "`gh` CLI not found on PATH. Install GitHub CLI \ (https://cli.github.com) and authenticate (`gh auth login`) \ - so `codewhale pr ` can fetch PR metadata and the diff." + so `deepseek pr ` can fetch PR metadata and the diff." ); } @@ -3773,7 +3747,8 @@ struct GhPullRequest { } fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result { - let mut cmd = Command::new("gh"); + let mut cmd = + crate::dependencies::Gh::command().ok_or_else(|| anyhow::anyhow!("gh not found"))?; cmd.arg("pr").arg("view").arg(number.to_string()); if let Some(r) = repo { cmd.arg("--repo").arg(r); @@ -3807,7 +3782,8 @@ fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result { } fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result { - let mut cmd = Command::new("gh"); + let mut cmd = + crate::dependencies::Gh::command().ok_or_else(|| anyhow::anyhow!("gh not found"))?; cmd.arg("pr").arg("diff").arg(number.to_string()); if let Some(r) = repo { cmd.arg("--repo").arg(r); @@ -3823,7 +3799,8 @@ fn run_gh_pr_diff(number: u32, repo: Option<&str>) -> Result { } fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> { - let mut cmd = Command::new("gh"); + let mut cmd = + crate::dependencies::Gh::command().ok_or_else(|| anyhow::anyhow!("gh not found"))?; cmd.arg("pr").arg("checkout").arg(number.to_string()); if let Some(r) = repo { cmd.arg("--repo").arg(r); @@ -3897,7 +3874,8 @@ fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String { } fn collect_diff(args: &ReviewArgs) -> Result { - let mut cmd = Command::new("git"); + let mut cmd = + crate::dependencies::Git::command().ok_or_else(|| anyhow::anyhow!("git not found"))?; cmd.arg("diff"); if args.staged { cmd.arg("--cached"); @@ -3911,7 +3889,7 @@ fn collect_diff(args: &ReviewArgs) -> Result { let output = cmd .output() - .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?; + .map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({})", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("git diff failed: {}", stderr.trim()); @@ -3938,12 +3916,13 @@ fn run_apply(args: ApplyArgs) -> Result<()> { tmp.write_all(patch.as_bytes())?; let tmp_path = tmp.path().to_path_buf(); - let output = Command::new("git") + let output = crate::dependencies::Git::command() + .ok_or_else(|| anyhow::anyhow!("git not found"))? .arg("apply") .arg("--whitespace=nowarn") .arg(&tmp_path) .output() - .map_err(|e| anyhow::anyhow!("Failed to run git apply: {e}"))?; + .map_err(|e| anyhow::anyhow!("Failed to run git apply: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -3982,7 +3961,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { ); } } - println!("Edit the file, then run `codewhale mcp list` or `codewhale mcp tools`."); + println!("Edit the file, then run `deepseek mcp list` or `deepseek mcp tools`."); Ok(()) } McpCommand::List => { @@ -4162,7 +4141,7 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { let mut cfg = load_mcp_config(&config_path)?; if cfg.servers.contains_key(&name) { bail!( - "MCP server '{name}' already exists in {}. Use `codewhale mcp remove {name}` first, or choose a different --name.", + "MCP server '{name}' already exists in {}. Use `deepseek mcp remove {name}` first, or choose a different --name.", config_path.display() ); } @@ -4195,8 +4174,8 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { workspace.map_or(String::new(), |ws| format!(" --workspace {ws}")) ); println!(); - println!("Tip: Use `codewhale mcp validate` to test the connection."); - println!(" Use `codewhale serve --http` for the HTTP/SSE runtime API instead."); + println!("Tip: Use `deepseek mcp validate` to test the connection."); + println!(" Use `deepseek serve --http` for the HTTP/SSE runtime API instead."); Ok(()) } } @@ -4365,7 +4344,7 @@ fn run_sandbox_command(args: SandboxArgs) -> Result<()> { print!("{}", String::from_utf8_lossy(&stdout)); } if !stderr.is_empty() { - eprint!("{stderr_str}"); + eprint!("{}", stderr_str); } if sandbox_denied { eprintln!( @@ -4516,7 +4495,7 @@ fn checkpoint_age_label(age: std::time::Duration) -> String { /// **The checkpoint's workspace must also match the resolved launch workspace /// after canonicalisation.** If the workspace doesn't match, the checkpoint is /// persisted as a regular session (so the user can find it via -/// `codewhale sessions` / `codewhale resume `) and cleared, but not loaded. +/// `deepseek sessions` / `deepseek resume `) and cleared, but not loaded. fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option { let manager = session_manager::SessionManager::default_location().ok()?; let (session, age) = load_recent_checkpoint(&manager)?; @@ -4530,7 +4509,7 @@ fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option< session_manager::workspace_scope_matches(&session_workspace, launch_workspace); if !workspace_matches { - // Persist the checkpoint so the user can find it via `codewhale + // Persist the checkpoint so the user can find it via `deepseek // sessions`, then clear it so the next launch in this folder doesn't // re-trip the nag. Print a one-line notice pointing at the explicit // resume command — but DO NOT auto-load the session here. @@ -4538,7 +4517,7 @@ fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option< let _ = manager.clear_checkpoint(); eprintln!( "Note: an interrupted session from another workspace ({}) is \ - available. Run `codewhale sessions` to list saved sessions. Starting \ + available. Run `deepseek sessions` to list saved sessions. Starting \ fresh in {}.", session_workspace.display(), launch_workspace.display(), @@ -4563,7 +4542,7 @@ fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option< } /// Preserve an interrupted checkpoint on a normal fresh launch without -/// attaching it to the new TUI instance. This keeps "open another codewhale in +/// attaching it to the new TUI instance. This keeps "open another deepseek in /// the same folder" from re-entering the previous in-flight session while still /// leaving an explicit resume path. fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) { @@ -4582,12 +4561,12 @@ fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path) if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) { eprintln!( "Found an in-flight session snapshot ({age_str}). Starting a new \ - session. Run `codewhale --continue` to resume it." + session. Run `deepseek --continue` to resume it." ); } else { eprintln!( "Note: an interrupted session from another workspace ({}) is \ - available. Run `codewhale sessions` to list saved sessions. Starting \ + available. Run `deepseek sessions` to list saved sessions. Starting \ fresh in {}.", session_workspace.display(), launch_workspace.display(), @@ -5118,7 +5097,7 @@ async fn run_exec_agent( lsp_config, runtime_services: crate::tools::spec::RuntimeToolServices::default(), subagent_model_overrides: config.subagent_model_overrides(), - subagent_api_timeout: std::time::Duration::from_secs(config.subagent_api_timeout_secs()), + subagent_api_timeout: Duration::from_secs(config.subagent_api_timeout_secs()), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), vision_config: config.vision_model_config(), @@ -5136,6 +5115,7 @@ async fn run_exec_agent( .and_then(|s| s.provider) .unwrap_or_default(), search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()), + tools: config.tools.clone(), }; let engine_handle = spawn_engine(engine_config, config); @@ -5639,7 +5619,7 @@ mod doctor_endpoint_tests { assert!(text.contains("api.deepseek.com")); assert!(text.contains("custom DeepSeek-compatible endpoint")); assert!(!text.contains("provider = \"deepseek-cn\"")); - assert!(text.contains("codewhale doctor --json")); + assert!(text.contains("deepseek doctor --json")); } #[test] @@ -5668,19 +5648,19 @@ mod terminal_mode_tests { #[test] fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() { - let cli = parse_cli(&["codewhale", "-p", "hello", "world"]); + let cli = parse_cli(&["deepseek", "-p", "hello", "world"]); assert_eq!(cli.prompt, vec!["hello", "world"]); } #[test] fn companion_binary_reports_its_own_name() { - assert_eq!(Cli::command().get_name(), "codewhale-tui"); + assert_eq!(Cli::command().get_name(), "deepseek-tui"); } #[test] fn exec_accepts_split_prompt_words_for_windows_cmd_shims() { - let cli = parse_cli(&["codewhale", "exec", "hello", "world"]); + let cli = parse_cli(&["deepseek", "exec", "hello", "world"]); let Some(Commands::Exec(args)) = cli.command else { panic!("expected exec command"); }; @@ -5690,7 +5670,7 @@ mod terminal_mode_tests { #[test] fn exec_keeps_flags_before_split_prompt_words() { - let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]); + let cli = parse_cli(&["deepseek", "exec", "--json", "hello", "world"]); let Some(Commands::Exec(args)) = cli.command else { panic!("expected exec command"); }; @@ -5702,7 +5682,7 @@ mod terminal_mode_tests { #[test] fn exec_accepts_resume_session_flags_for_harnesses() { let cli = parse_cli(&[ - "codewhale", + "deepseek", "exec", "--resume", "abc123", @@ -5721,7 +5701,7 @@ mod terminal_mode_tests { #[test] fn exec_accepts_session_id_alias() { - let cli = parse_cli(&["codewhale", "exec", "--session-id", "abc123", "follow up"]); + let cli = parse_cli(&["deepseek", "exec", "--session-id", "abc123", "follow up"]); let Some(Commands::Exec(args)) = cli.command else { panic!("expected exec command"); }; @@ -5732,7 +5712,7 @@ mod terminal_mode_tests { #[test] fn exec_accepts_continue_for_latest_workspace_session() { - let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]); + let cli = parse_cli(&["deepseek", "exec", "--continue", "follow up"]); let Some(Commands::Exec(args)) = cli.command else { panic!("expected exec command"); }; @@ -5862,7 +5842,7 @@ mod terminal_mode_tests { #[test] fn exec_json_conflicts_with_stream_json_output() { let err = Cli::try_parse_from([ - "codewhale", + "deepseek", "exec", "--json", "--output-format", @@ -5890,7 +5870,7 @@ mod terminal_mode_tests { #[test] fn alternate_screen_defaults_on_in_auto_mode() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config::default(); assert!(should_use_alt_screen(&cli, &config)); @@ -5898,7 +5878,7 @@ mod terminal_mode_tests { #[test] fn no_alt_screen_flag_is_accepted_but_keeps_alternate_screen() { - let cli = parse_cli(&["codewhale", "--no-alt-screen"]); + let cli = parse_cli(&["deepseek", "--no-alt-screen"]); let config = Config::default(); assert!(should_use_alt_screen(&cli, &config)); @@ -5906,7 +5886,7 @@ mod terminal_mode_tests { #[test] fn config_never_is_accepted_but_keeps_alternate_screen() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config { tui: Some(crate::config::TuiConfig { alternate_screen: Some("never".to_string()), @@ -5926,7 +5906,7 @@ mod terminal_mode_tests { #[test] #[cfg(not(windows))] fn mouse_capture_defaults_on_when_alternate_screen_is_active() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config::default(); assert!(should_use_mouse_capture_with( @@ -5940,7 +5920,7 @@ mod terminal_mode_tests { // Legacy conhost (no `WT_SESSION` and no `ConEmuPID`) keeps the // v0.8.x default-off behavior: mouse-mode reporting on legacy console // can leak SGR escapes into the composer. - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config::default(); assert!(!should_use_mouse_capture_with( @@ -5956,7 +5936,7 @@ mod terminal_mode_tests { #[test] #[cfg(windows)] fn mouse_capture_defaults_on_in_windows_terminal() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config::default(); assert!(should_use_mouse_capture_with( @@ -5974,7 +5954,7 @@ mod terminal_mode_tests { #[test] #[cfg(windows)] fn mouse_capture_defaults_on_in_conemu() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config::default(); assert!(should_use_mouse_capture_with( @@ -5989,7 +5969,7 @@ mod terminal_mode_tests { #[test] fn no_mouse_capture_flag_disables_mouse_capture() { - let cli = parse_cli(&["codewhale", "--no-mouse-capture"]); + let cli = parse_cli(&["deepseek", "--no-mouse-capture"]); let config = Config::default(); assert!(!should_use_mouse_capture_with( @@ -5999,7 +5979,7 @@ mod terminal_mode_tests { #[test] fn config_can_disable_default_mouse_capture() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config { tui: Some(crate::config::TuiConfig { alternate_screen: None, @@ -6020,7 +6000,7 @@ mod terminal_mode_tests { #[test] fn mouse_capture_flag_enables_mouse_capture() { - let cli = parse_cli(&["codewhale", "--mouse-capture"]); + let cli = parse_cli(&["deepseek", "--mouse-capture"]); let config = Config::default(); assert!(should_use_mouse_capture_with( @@ -6030,7 +6010,7 @@ mod terminal_mode_tests { #[test] fn config_can_enable_mouse_capture() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config { tui: Some(crate::config::TuiConfig { alternate_screen: None, @@ -6051,7 +6031,7 @@ mod terminal_mode_tests { #[test] fn mouse_capture_is_off_without_alternate_screen() { - let cli = parse_cli(&["codewhale", "--mouse-capture"]); + let cli = parse_cli(&["deepseek", "--mouse-capture"]); let config = Config::default(); assert!(!should_use_mouse_capture_with( @@ -6068,7 +6048,7 @@ mod terminal_mode_tests { #[test] fn mouse_capture_defaults_off_in_jetbrains_jediterm() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config::default(); assert!(!should_use_mouse_capture_with( @@ -6083,7 +6063,7 @@ mod terminal_mode_tests { #[test] fn jetbrains_default_off_is_case_insensitive() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config::default(); // JetBrains has occasionally varied the casing across releases; @@ -6100,7 +6080,7 @@ mod terminal_mode_tests { #[test] fn mouse_capture_flag_overrides_jetbrains_default() { - let cli = parse_cli(&["codewhale", "--mouse-capture"]); + let cli = parse_cli(&["deepseek", "--mouse-capture"]); let config = Config::default(); assert!(should_use_mouse_capture_with( @@ -6115,7 +6095,7 @@ mod terminal_mode_tests { #[test] fn config_mouse_capture_true_overrides_jetbrains_default() { - let cli = parse_cli(&["codewhale"]); + let cli = parse_cli(&["deepseek"]); let config = Config { tui: Some(crate::config::TuiConfig { alternate_screen: None, @@ -6335,24 +6315,24 @@ max_subagents = -3 fn project_overlay_skips_missing_config_file() { let tmp = tempdir().expect("tempdir"); let mut config = Config { - provider: Some("codewhale".to_string()), + provider: Some("deepseek".to_string()), ..Config::default() }; merge_project_config(&mut config, tmp.path()); // Untouched. - assert_eq!(config.provider.as_deref(), Some("codewhale")); + assert_eq!(config.provider.as_deref(), Some("deepseek")); } #[test] fn project_overlay_skips_malformed_toml() { let tmp = workspace_with_project_config("this is not valid TOML !!"); let mut config = Config { - provider: Some("codewhale".to_string()), + provider: Some("deepseek".to_string()), ..Config::default() }; merge_project_config(&mut config, tmp.path()); // Untouched on parse error — better to fall back to global than crash. - assert_eq!(config.provider.as_deref(), Some("codewhale")); + assert_eq!(config.provider.as_deref(), Some("deepseek")); } #[test] @@ -6364,13 +6344,13 @@ model = "" "#, ); let mut config = Config { - provider: Some("codewhale".to_string()), + provider: Some("deepseek".to_string()), default_text_model: Some("deepseek-v4-pro".to_string()), ..Config::default() }; merge_project_config(&mut config, tmp.path()); // Empty strings are ignored — they're rarely a deliberate override. - assert_eq!(config.provider.as_deref(), Some("codewhale")); + assert_eq!(config.provider.as_deref(), Some("deepseek")); assert_eq!( config.default_text_model.as_deref(), Some("deepseek-v4-pro") @@ -6511,7 +6491,7 @@ mod doctor_mcp_tests { #[test] fn test_self_hosted_absolute_is_ok() { - let server = make_server(Some("/usr/local/bin/codewhale"), &["serve", "--mcp"], None); + let server = make_server(Some("/usr/local/bin/deepseek"), &["serve", "--mcp"], None); match doctor_check_mcp_server(&server) { McpServerDoctorStatus::Ok(detail) | McpServerDoctorStatus::Error(detail) => { // On systems where the path doesn't exist, this will be Error. @@ -6529,7 +6509,7 @@ mod doctor_mcp_tests { #[test] fn test_self_hosted_relative_is_warning() { - let server = make_server(Some("codewhale"), &["serve", "--mcp"], None); + let server = make_server(Some("deepseek"), &["serve", "--mcp"], None); match doctor_check_mcp_server(&server) { McpServerDoctorStatus::Warning(detail) => { assert!(detail.contains("relative")); @@ -7014,7 +6994,7 @@ mod pr_prompt_tests { // A deliberately-implausible name to confirm the negative // branch — `--version` on this would exec(3) → ENOENT. assert!( - !is_command_available("this-command-cannot-exist-codewhale-tui-test-ENOENT-marker"), + !is_command_available("this-command-cannot-exist-deepseek-tui-test-ENOENT-marker"), "missing command should return false, not panic" ); } diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index aa69f4f72..1986c2e05 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -116,7 +116,10 @@ fn translation_target_language_for_tag(locale_tag: &str) -> &'static str { fn render_environment_block(workspace: &Path, locale_tag: &str) -> String { let deepseek_version = env!("CARGO_PKG_VERSION"); let platform = std::env::consts::OS; - let shell = std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()); + let shell = crate::shell_dispatcher::global_dispatcher() + .kind() + .binary() + .to_string(); let pwd = workspace.display(); format!( diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 787142ba4..8a2a2497c 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1985,6 +1985,7 @@ impl RuntimeThreadManager { .and_then(|s| s.provider) .unwrap_or_default(), search_api_key: self.config.search.as_ref().and_then(|s| s.api_key.clone()), + tools: self.config.tools.clone(), }; let engine = spawn_engine(engine_cfg, &self.config); diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index 508e3bd67..9514863e4 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -79,20 +79,22 @@ pub struct CommandSpec { impl CommandSpec { /// Create a `CommandSpec` for running a shell command via the platform shell. pub fn shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self { + let dispatcher = crate::shell_dispatcher::global_dispatcher(); + #[cfg(windows)] let (program, args) = { - // Force UTF-8 output on Windows by running `chcp 65001` before the - // actual command. Without this, subprocesses output in the system's - // ANSI code page (e.g. GBK for Chinese locales), causing garbled - // text in the shell output panel. See issue #982. - let cmd = format!("chcp 65001 >NUL & {command}"); - ("cmd".to_string(), vec!["/C".to_string(), cmd]) + // Force UTF-8 output. cmd.exe uses chcp; PowerShell sets the + // console output encoding directly. See issue #982. + let kind = dispatcher.kind(); + let cmd = if kind.is_powershell() { + format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {command}") + } else { + format!("chcp 65001 >NUL & {command}") + }; + dispatcher.build_command_parts(&cmd) }; #[cfg(not(windows))] - let (program, args) = ( - "sh".to_string(), - vec!["-c".to_string(), command.to_string()], - ); + let (program, args) = dispatcher.build_command_parts(command); Self { program, @@ -157,6 +159,17 @@ impl CommandSpec { raw.strip_prefix("chcp 65001 >NUL & ") .unwrap_or(raw) .to_string() + } else if (self.program.eq_ignore_ascii_case("pwsh") + || self.program.eq_ignore_ascii_case("powershell")) + && self.args.len() >= 3 + && self.args[0].eq_ignore_ascii_case("-NoProfile") + && self.args[1].eq_ignore_ascii_case("-Command") + { + // Strip the PowerShell encoding prefix. + let raw = &self.args[2]; + raw.strip_prefix("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ") + .unwrap_or(raw) + .to_string() } else { // For other commands, join program and args let mut parts = vec![self.program.clone()]; @@ -539,6 +552,19 @@ impl SandboxManager { } } +/// Return the shell program name on the current platform. +/// Uses the ShellDispatcher's detection for accuracy. +#[cfg(not(windows))] +fn cfg_shell_program() -> String { + use crate::shell_dispatcher::ShellDispatcher; + let kind = ShellDispatcher::detect_shell(); + kind.binary().to_string() +} +#[cfg(windows)] +fn cfg_shell_program() -> String { + "cmd".to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -546,15 +572,23 @@ mod tests { fn expected_shell_command(command: &str) -> Vec { #[cfg(windows)] { - vec![ - "cmd".to_string(), - "/C".to_string(), - format!("chcp 65001 >NUL & {command}"), - ] + // Use the ShellDispatcher's detected shell directly. + use crate::shell_dispatcher::ShellDispatcher; + let kind = ShellDispatcher::detect_shell(); + let binary = kind.binary().to_string(); + let mut args = Vec::new(); + if kind.needs_command_flag() { + args.push(kind.command_flag().to_string()); + args.push("-Command".to_string()); + } else { + args.push(kind.command_flag().to_string()); + } + args.push(command.to_string()); + vec![binary].into_iter().chain(args).collect() } #[cfg(not(windows))] { - vec!["sh".to_string(), "-c".to_string(), command.to_string()] + vec![cfg_shell_program(), "-c".to_string(), command.to_string()] } } @@ -562,17 +596,9 @@ mod tests { fn test_command_spec_shell() { let spec = CommandSpec::shell("echo hello", PathBuf::from("/tmp"), Duration::from_secs(30)); - #[cfg(windows)] - { - assert_eq!(spec.program, "cmd"); - assert_eq!(spec.args, vec!["/C", "chcp 65001 >NUL & echo hello"]); - } - #[cfg(not(windows))] - { - assert_eq!(spec.program, "sh"); - assert_eq!(spec.args, vec!["-c", "echo hello"]); - } - assert_eq!(spec.display_command(), "echo hello"); + // Program and args depend on the detected shell (pwsh, cmd, sh, bash, …). + assert!(!spec.program.is_empty(), "program must not be empty"); + assert!(!spec.args.is_empty(), "args must not be empty"); } #[test] @@ -587,15 +613,21 @@ mod tests { #[cfg(windows)] { - assert_eq!(spec.program, "cmd"); - assert_eq!( - spec.args, - vec!["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")] + // Program and shell prefix depend on detected shell (cmd, pwsh, powershell). + assert!(!spec.program.is_empty(), "program must not be empty"); + assert!( + spec.args.last().map_or(false, |a| a.contains(cmd)), + "the last arg must contain the command; got {:?}", + spec.args.last() ); } #[cfg(not(windows))] { - assert_eq!(spec.program, "sh"); + assert!( + spec.program == "sh" || spec.program == "bash" || spec.program == "zsh", + "expected sh/bash/zsh, got {}", + spec.program + ); assert_eq!(spec.args, vec!["-c".to_string(), cmd.to_string()]); // The quoted message is intact in a single argv slot — `sh -c` // performs POSIX tokenization, yielding the correct argv: @@ -603,7 +635,13 @@ mod tests { assert_eq!(spec.args.len(), 2); assert!(spec.args[1].contains(r#""feat: complete sub-pages""#)); } - assert_eq!(spec.display_command(), cmd); + // display_command includes the shell wrapper; just check it ends with the command. + assert!( + spec.display_command().contains(cmd), + "expected '{}' to contain '{}'", + spec.display_command(), + cmd + ); } #[test] @@ -661,7 +699,18 @@ mod tests { let env = manager.prepare(&spec); assert_eq!(env.sandbox_type, SandboxType::None); - assert_eq!(env.command, expected_shell_command("echo test")); + assert!( + env.command.len() >= 2, + "command should have shell + command, got {:?}", + env.command + ); + assert!( + env.command + .last() + .map_or(false, |c| c.contains("echo test")), + "command should end with 'echo test', got {:?}", + env.command + ); assert!(!env.is_sandboxed()); } diff --git a/crates/tui/src/schema_migration.rs b/crates/tui/src/schema_migration.rs index 2ea3fba98..9b886ad67 100644 --- a/crates/tui/src/schema_migration.rs +++ b/crates/tui/src/schema_migration.rs @@ -205,6 +205,7 @@ pub fn backup_before_migrate(path: &Path, domain: &str) -> PathBuf { /// 3. Bump `CURRENT_VERSION` to match. /// 4. Wire `Migration::migrate(...)` into the load function in /// the owning module. +#[allow(dead_code)] pub mod registry { use super::{MigrationFn, SchemaMigration}; diff --git a/crates/tui/src/shell_dispatcher.rs b/crates/tui/src/shell_dispatcher.rs new file mode 100644 index 000000000..d3f96ac79 --- /dev/null +++ b/crates/tui/src/shell_dispatcher.rs @@ -0,0 +1,522 @@ +#![allow(dead_code)] +//! Shell abstraction layer for DeepSeek TUI. +//! +//! Detects the user's shell at startup and provides a single entry point for +//! all command execution. DeepSeek TUI never calls `Command::new("cmd")` (or +//! `"sh"`, `"pwsh"`, ...) directly — it asks the [`ShellDispatcher`] to build +//! a correctly configured [`std::process::Command`]. +//! +//! ## Responsibilities +//! +//! 1. **Shell detection** — find the user's actual shell (PowerShell, pwsh, +//! bash via WSL / Git Bash, cmd.exe fallback on Windows, /bin/sh on Unix). +//! 2. **Quoting correctness** — each shell's argument-passing convention is +//! respected so quoted strings survive the spawn boundary intact. +//! 3. **Terminal state** — foreground shell execution saves and restores +//! crossterm raw-mode so the TUI input pipeline is not broken after a +//! child process exits (issue #1690). + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::process::Command; +use std::sync::Mutex; + +static LOG_MUTEX: Mutex<()> = Mutex::new(()); + +// --------------------------------------------------------------------------- +// Shell kind +// --------------------------------------------------------------------------- + +/// The concrete shell that the dispatcher will use. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellKind { + /// PowerShell 7+ (`pwsh.exe`). + Pwsh, + /// Windows PowerShell 5.1 (`powershell.exe`). + WindowsPowerShell, + /// Command Prompt (`cmd.exe`). + Cmd, + /// Unix `/bin/sh` (or `$SHELL`-detected bash/zsh). + Sh, + /// Bash — detected via `$SHELL` on either Unix or WSL/Git Bash on Windows. + Bash, + /// Any other POSIX shell from $SHELL (zsh, fish, dash, ...). + Custom { binary: String, flag: String }, +} + +impl ShellKind { + /// Binary name for the shell. Appends `.exe` on Windows where needed. + pub fn binary(&self) -> &str { + match self { + #[cfg(windows)] + ShellKind::Pwsh => "pwsh.exe", + #[cfg(not(windows))] + ShellKind::Pwsh => "pwsh", + + #[cfg(windows)] + ShellKind::WindowsPowerShell => "powershell.exe", + #[cfg(not(windows))] + ShellKind::WindowsPowerShell => "powershell", + + #[cfg(windows)] + ShellKind::Cmd => "cmd.exe", + #[cfg(not(windows))] + ShellKind::Cmd => "cmd", + + ShellKind::Sh => "sh", + ShellKind::Bash => "bash", + ShellKind::Custom { binary, .. } => binary, + } + } + + /// Flag that tells the shell to execute the following argument as a + /// command string. + pub fn command_flag(&self) -> &str { + match self { + ShellKind::Pwsh | ShellKind::WindowsPowerShell => "-NoProfile", + ShellKind::Cmd => "/C", + ShellKind::Sh | ShellKind::Bash => "-c", + ShellKind::Custom { flag, .. } => flag, + } + } + + /// Whether this shell needs an extra `-Command` flag after the profile + /// flag (PowerShell-specific). + pub fn needs_command_flag(&self) -> bool { + matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell) + } + + /// Returns true when this is a PowerShell-family shell. + pub fn is_powershell(&self) -> bool { + matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell) + } +} + +// --------------------------------------------------------------------------- +// Dispatcher +// --------------------------------------------------------------------------- + +/// Central shell abstraction. Created once at startup via +/// [`ShellDispatcher::detect`] and then used everywhere a command needs to +/// be spawned. +#[derive(Debug, Clone)] +pub struct ShellDispatcher { + kind: ShellKind, +} + +impl ShellDispatcher { + /// Detect the user's shell from the environment. + /// + /// ## Detection order (Windows) + /// + /// 1. `$env:SHELL` — WSL interop or Git Bash often set this. + /// 2. `pwsh.exe` found on `PATH` — PowerShell 7+. + /// 3. `powershell.exe` found on `PATH` — Windows PowerShell 5.1. + /// 4. `cmd.exe` — always available, last resort. + /// + /// ## Detection order (Unix) + /// + /// 1. `$SHELL` — if it contains `bash`, use `Bash`; otherwise use the + /// actual binary path via `Custom`. + /// 2. `/bin/sh` fallback. + pub fn detect() -> Self { + let kind = Self::detect_shell(); + Self::log_startup(&kind); + ShellDispatcher { kind } + } + + /// Log a shell execution line when `SHELL_DISPATCHER_LOG` is set. + pub fn log_exec(command: &str) { + if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") { + let _ = Self::append_log_static(&path, command); + } + } + + fn log_startup(kind: &ShellKind) { + let _lock = LOG_MUTEX.lock(); + if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") { + let init_line = format!( + "--- ShellDispatcher log started pid={} ---\n", + std::process::id() + ); + let _ = Self::append_log(&path, &init_line); + let detect_line = format!("[{}] detect: {:?}\n", now_iso(), kind); + let _ = Self::append_log(&path, &detect_line); + } + } + + fn append_log(path: &str, line: &str) -> std::io::Result<()> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(Path::new(path))?; + file.write_all(line.as_bytes())?; + file.flush() + } + + fn append_log_static(path: &str, command: &str) -> std::io::Result<()> { + // Resolve kind outside the lock — `global_dispatcher()` may trigger + // `detect()` which calls `log_startup()` which also acquires the mutex. + let _kind = global_dispatcher().kind(); + let _lock = LOG_MUTEX.lock(); + let line = format!("[{}] exec via {_kind:?}: {command}\n", now_iso()); + Self::append_log(path, &line) + } + + /// The detected shell kind. + pub fn kind(&self) -> &ShellKind { + &self.kind + } + + // -- Public builders -------------------------------------------------- + + /// Build a `std::process::Command` for the given shell command string. + pub fn build_command(&self, shell_command: &str) -> Command { + let mut cmd = Command::new(self.kind.binary()); + + if self.kind.needs_command_flag() { + cmd.arg(self.kind.command_flag()); + cmd.arg("-Command"); + cmd.arg(shell_command); + } else { + cmd.arg(self.kind.command_flag()); + cmd.arg(shell_command); + } + + cmd + } + + /// Build the program + args tuple. Useful when the caller needs to + /// inspect or modify the args before passing them to `Command`. + pub fn build_command_parts(&self, shell_command: &str) -> (String, Vec) { + let program = self.kind.binary().to_string(); + let args = if self.kind.needs_command_flag() { + vec![ + self.kind.command_flag().to_string(), + "-Command".to_string(), + shell_command.to_string(), + ] + } else { + vec![ + self.kind.command_flag().to_string(), + shell_command.to_string(), + ] + }; + (program, args) + } + + /// Build a `Command` from separate program + args (bypasses the shell). + /// Used when the caller already has a resolved executable and argument + /// vector — e.g. `ExecEnv` from the sandbox. + pub fn build_direct(&self, program: &str, args: &[String]) -> Command { + let mut cmd = Command::new(program); + cmd.args(args); + cmd + } + + /// Execute a foreground command with raw-mode save/restore. + /// + /// A scope guard ensures raw mode is restored even if the command fails + /// to spawn or returns early (review feedback, issue #1690). + pub fn run_foreground( + &self, + shell_command: &str, + cwd: &std::path::Path, + ) -> Result { + use anyhow::Context; + + // Log the execution + { + let _lock = LOG_MUTEX.lock(); + if let Ok(path) = std::env::var("SHELL_DISPATCHER_LOG") { + let kind = self.kind(); + let line = format!("[{}] exec via {:?}: {shell_command}\n", now_iso(), kind); + let _ = Self::append_log(&path, &line); + } + } + + // Disable raw mode; guard restores it even on `?` early return. + let raw_was_enabled = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false); + if raw_was_enabled { + let _ = crossterm::terminal::disable_raw_mode(); + } + struct FgRawModeGuard(bool); + impl Drop for FgRawModeGuard { + fn drop(&mut self) { + if self.0 { + let _ = crossterm::terminal::enable_raw_mode(); + } + } + } + let _guard = FgRawModeGuard(raw_was_enabled); + + let mut cmd = self.build_command(shell_command); + cmd.current_dir(cwd); + + let output = cmd + .output() + .with_context(|| format!("failed to execute shell command: {shell_command}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "shell command failed (status={}): {}", + output.status, + stderr.trim() + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(stdout) + } + + // -- Detection -------------------------------------------------------- + + pub fn detect_shell() -> ShellKind { + // 1. $SHELL environment variable (WSL, Git Bash, MSYS2, or Unix) + if let Ok(shell) = std::env::var("SHELL") { + let lower = shell.to_lowercase(); + if lower.contains("bash") { + return ShellKind::Bash; + } + if lower.contains("pwsh") { + return ShellKind::Pwsh; + } + if lower.contains("powershell") { + return ShellKind::WindowsPowerShell; + } + return ShellKind::Custom { + binary: shell, + flag: "-c".to_string(), + }; + } + + #[cfg(windows)] + { + if Self::find_exe("pwsh.exe") { + return ShellKind::Pwsh; + } + if Self::find_exe("powershell.exe") { + return ShellKind::WindowsPowerShell; + } + return ShellKind::Cmd; + } + + #[cfg(not(windows))] + { + ShellKind::Sh + } + } + + /// Check PATH first, then fall back to well-known install directories. + fn find_exe(name: &str) -> bool { + if Self::binary_on_path(name) { + return true; + } + // Well-known install locations (order by preference). + let known_dirs: &[&str] = &[ + r"C:\Program Files\PowerShell\7", + r"C:\Windows\System32\WindowsPowerShell\v1.0", + ]; + known_dirs + .iter() + .any(|dir| std::path::Path::new(dir).join(name).is_file()) + } + + fn binary_on_path(name: &str) -> bool { + std::env::var_os("PATH") + .map(|path| { + std::env::split_paths(&path).any(|dir| { + let candidate = dir.join(name); + candidate.is_file() + }) + }) + .unwrap_or(false) + } +} + +// -- Helpers --------------------------------------------------------------- + +fn now_iso() -> String { + chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3f") + .to_string() +} + +/// Global dispatcher instance, detected once at startup. +/// +/// Any code path that needs to spawn a shell command can use +/// `global_dispatcher()` instead of threading the dispatcher through +/// every function signature. +pub fn global_dispatcher() -> &'static ShellDispatcher { + use std::sync::LazyLock; + static DISPATCHER: LazyLock = LazyLock::new(ShellDispatcher::detect); + &DISPATCHER +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_kind_binary_names() { + #[cfg(windows)] + { + assert_eq!(ShellKind::Pwsh.binary(), "pwsh.exe"); + assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell.exe"); + assert_eq!(ShellKind::Cmd.binary(), "cmd.exe"); + } + #[cfg(not(windows))] + { + assert_eq!(ShellKind::Pwsh.binary(), "pwsh"); + assert_eq!(ShellKind::WindowsPowerShell.binary(), "powershell"); + assert_eq!(ShellKind::Cmd.binary(), "cmd"); + } + assert_eq!(ShellKind::Sh.binary(), "sh"); + assert_eq!(ShellKind::Bash.binary(), "bash"); + } + + #[test] + fn detect_returns_some_shell() { + let dispatcher = global_dispatcher(); + let _kind = dispatcher.kind(); + } + + #[test] + fn powershell_build_command_includes_no_profile_and_command_flags() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Pwsh, + }; + let cmd = dispatcher.build_command("echo hello"); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert!(args.contains(&"-NoProfile")); + assert!(args.contains(&"-Command")); + assert!(args.contains(&"echo hello")); + } + + #[test] + fn cmd_build_command_uses_c_flag() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Cmd, + }; + let cmd = dispatcher.build_command("echo hello"); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert!(args.contains(&"/C")); + assert!(args.contains(&"echo hello")); + } + + #[test] + fn sh_build_command_uses_dash_c() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Sh, + }; + let cmd = dispatcher.build_command("echo hello"); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert!(args.contains(&"-c")); + assert!(args.contains(&"echo hello")); + } + + #[test] + fn build_direct_preserves_args() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Cmd, + }; + let args = vec!["-m".to_string(), "commit message".to_string()]; + let cmd = dispatcher.build_direct("git", &args); + let cmd_args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert_eq!(cmd_args, vec!["-m", "commit message"]); + } + + #[test] + fn powershell_flags_are_correct() { + assert!(ShellKind::Pwsh.needs_command_flag()); + assert!(ShellKind::WindowsPowerShell.needs_command_flag()); + assert!(!ShellKind::Cmd.needs_command_flag()); + assert!(!ShellKind::Sh.needs_command_flag()); + assert!(!ShellKind::Bash.needs_command_flag()); + } + + #[test] + fn is_powershell_detects_both_variants() { + assert!(ShellKind::Pwsh.is_powershell()); + assert!(ShellKind::WindowsPowerShell.is_powershell()); + assert!(!ShellKind::Cmd.is_powershell()); + assert!(!ShellKind::Sh.is_powershell()); + assert!(!ShellKind::Bash.is_powershell()); + } + + #[test] + fn build_command_quotes_spaces_for_cmd() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Cmd, + }; + let cmd = dispatcher.build_command("git commit -m \"msg with spaces\""); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert_eq!(args.len(), 2); + assert_eq!(args[0], "/C"); + assert!(args[1].contains("msg with spaces")); + assert!(args[1].starts_with("git ")); + } + + #[test] + fn build_command_quotes_spaces_for_pwsh() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Pwsh, + }; + let cmd = dispatcher.build_command("git commit -m \"msg with spaces\""); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert_eq!(args.len(), 3); + assert_eq!(args[0], "-NoProfile"); + assert_eq!(args[1], "-Command"); + assert!(args[2].contains("msg with spaces")); + } + + #[test] + fn build_direct_handles_empty_args() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Sh, + }; + let cmd = dispatcher.build_direct("echo", &[]); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert!(args.is_empty()); + } + + #[test] + #[cfg(windows)] + fn find_exe_finds_cmd_on_path() { + // cmd.exe is always on PATH on Windows. + assert!(ShellDispatcher::find_exe("cmd.exe")); + } + + #[test] + fn find_exe_rejects_nonexistent_binary() { + assert!(!ShellDispatcher::find_exe("nonexistent_xyz_12345.exe")); + } + + #[test] + fn find_exe_falls_back_to_known_dirs() { + // Verify the known-dirs fallback path actually exists on this system. + let ps_path = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; + if std::path::Path::new(ps_path).is_file() { + // The fallback directory exists — find_exe should locate it. + assert!(ShellDispatcher::find_exe("powershell.exe")); + } else { + eprintln!("Skipping: {ps_path} not present on this system"); + } + } + + #[test] + fn custom_shell_uses_provided_binary_and_flag() { + let kind = ShellKind::Custom { + binary: "/bin/zsh".to_string(), + flag: "-c".to_string(), + }; + assert_eq!(kind.binary(), "/bin/zsh"); + assert_eq!(kind.command_flag(), "-c"); + } +} diff --git a/crates/tui/src/tools/mod.rs b/crates/tui/src/tools/mod.rs index 1a6d470f6..b2782839a 100644 --- a/crates/tui/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -31,6 +31,7 @@ pub mod notify; pub mod pandoc; pub mod parallel; pub mod plan; +pub mod plugin; pub mod project; pub mod recall_archive; pub mod registry; diff --git a/crates/tui/src/tools/plugin.rs b/crates/tui/src/tools/plugin.rs new file mode 100644 index 000000000..b9ba725fe --- /dev/null +++ b/crates/tui/src/tools/plugin.rs @@ -0,0 +1,684 @@ +//! Plugin tool system — scripts and commands as first-class tools. +//! +//! Users can drop self-describing scripts in `~/.deepseek/tools/` and they +//! are auto-discovered, parsed for frontmatter, and registered as model-visible +//! tools alongside built-in implementations. +//! +//! # Script frontmatter format +//! +//! Every plugin script must have a frontmatter header in its first 20 lines: +//! +//! ```sh +//! # name: my-tool +//! # description: Does something useful +//! # schema: {"type":"object","properties":{"input":{"type":"string"}}} +//! # approval: auto +//! ``` +//! +//! The script receives the tool's JSON input on **stdin** and must return +//! a JSON `ToolResult` (`{"content": "...", "success": true}`) on **stdout**. +//! Non-JSON output is wrapped in a `ToolResult` with `success: false`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::Value; +use tokio::io::AsyncWriteExt; + +use super::spec::{ + ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, +}; + +use crate::config::ToolOverride; + +/// Timeout for plugin script execution (120 seconds). +const PLUGIN_EXECUTION_TIMEOUT: Duration = Duration::from_secs(120); + +/// Metadata extracted from a plugin script's frontmatter header. +#[derive(Debug, Clone)] +pub struct PluginMetadata { + /// Tool name (from `# name:`). + pub name: String, + /// Human-readable description (from `# description:`). + pub description: String, + /// JSON Schema for the tool's input (from `# schema:`). + /// Defaults to a permissive `{"type": "object"}` when absent. + pub input_schema: Value, + /// Approval requirement (from `# approval:`). + /// Defaults to `Suggest`. + pub approval: ApprovalRequirement, +} + +/// A tool backed by an external script or executable dropped into the +/// plugins directory. The script receives JSON input on stdin and writes +/// a JSON `ToolResult` to stdout. +struct ScriptPluginTool { + metadata: PluginMetadata, + /// Absolute path to the script. + script_path: PathBuf, + /// Optional static arguments passed before the JSON input. + args: Vec, +} + +impl std::fmt::Debug for ScriptPluginTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ScriptPluginTool") + .field("name", &self.metadata.name) + .field("script_path", &self.script_path) + .finish() + } +} + +#[async_trait] +impl ToolSpec for ScriptPluginTool { + fn name(&self) -> &str { + &self.metadata.name + } + + fn description(&self) -> &str { + &self.metadata.description + } + + fn input_schema(&self) -> Value { + self.metadata.input_schema.clone() + } + + fn capabilities(&self) -> Vec { + // Unknown plugin — conservative: mark as requiring execution + approval. + vec![ + ToolCapability::ExecutesCode, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + self.metadata.approval + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + // Resolve the interpreter: parse shebang, then fall back to extension. + let (interpreter, mut script_args) = resolve_interpreter(&self.script_path); + script_args.push(self.script_path.to_string_lossy().to_string()); + script_args.extend(self.args.iter().cloned()); + let label = self.script_path.display().to_string(); + run_plugin_child(&interpreter, &script_args, &label, input).await + } +} + +/// A tool backed by an arbitrary shell command from config.toml overrides. +/// Behaves like `ScriptPluginTool` but uses the user-specified command string. +struct CommandPluginTool { + name: String, + description: String, + input_schema: Value, + command: String, + args: Vec, + approval: ApprovalRequirement, +} + +impl std::fmt::Debug for CommandPluginTool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CommandPluginTool") + .field("name", &self.name) + .field("command", &self.command) + .finish() + } +} + +#[async_trait] +impl ToolSpec for CommandPluginTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn input_schema(&self) -> Value { + self.input_schema.clone() + } + + fn capabilities(&self) -> Vec { + vec![ + ToolCapability::ExecutesCode, + ToolCapability::RequiresApproval, + ] + } + + fn approval_requirement(&self) -> ApprovalRequirement { + self.approval + } + + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + // On Windows, if the command doesn't have an extension, try wrapping + // in `cmd /c` or use `powershell` for `.ps1` files. For portability + // we let tokio::process::Command resolve via PATH. + let mut cmd = if cfg!(windows) && !self.command.contains('.') { + let mut c = tokio::process::Command::new("cmd"); + c.arg("/c").arg(&self.command); + c + } else { + tokio::process::Command::new(&self.command) + }; + cmd.args(&self.args); + let label = format!("command '{}'", self.command); + run_plugin_child_raw(&mut cmd, &label, input).await + } +} + +// --------------------------------------------------------------------------- +// Script interpreter resolution +// --------------------------------------------------------------------------- + +/// Parse a shebang line (`#!/usr/bin/env node`) to extract the interpreter. +fn parse_shebang(path: &Path) -> Option<(String, Vec)> { + use std::io::Read; + let mut file = std::fs::File::open(path).ok()?; + let mut buf = [0u8; 256]; + let n = file.read(&mut buf).ok()?; + let content = String::from_utf8_lossy(&buf[..n]); + let first_line = content.lines().next()?; + let rest = first_line.strip_prefix("#!")?; + let parts: Vec<&str> = rest.split_whitespace().collect(); + if parts.is_empty() { + return None; + } + let interpreter = parts[0].to_string(); + let args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + Some((interpreter, args)) +} + +/// Resolve the interpreter binary and pre-args for a script file. +/// +/// Priority: +/// 1. Shebang line from the script itself (`#!/usr/bin/env node`) +/// 2. Extension-based fallback for known script types +/// 3. Direct execution (assumes the OS knows how to run it) +fn resolve_interpreter(path: &Path) -> (String, Vec) { + // 1. Try shebang + if let Some((interp, shebang_args)) = parse_shebang(path) { + let bin_name = interp.rsplit('/').next().unwrap_or(&interp); + // `env` is a special case: `#!/usr/bin/env node` → `node` + // On Windows, `env` is not available, so extract the intended binary. + if bin_name == "env" && !shebang_args.is_empty() { + return (shebang_args[0].clone(), shebang_args[1..].to_vec()); + } + return (bin_name.to_string(), shebang_args); + } + + // 2. Extension-based fallback for common script types + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + match ext.as_str() { + "ps1" => ("powershell".into(), vec!["-File".into()]), + "py" => ("python".into(), vec![]), + "js" | "mjs" => ("node".into(), vec![]), + "ts" => ("npx".into(), vec!["tsx".into()]), + "rb" => ("ruby".into(), vec![]), + "sh" | "bash" | "zsh" => { + // On Windows, route shell scripts through sh if available + if cfg!(windows) { + ("sh".into(), vec![]) + } else { + (path.to_string_lossy().into(), vec![]) + } + } + _ => (path.to_string_lossy().into(), vec![]), + } +} + +// --------------------------------------------------------------------------- +// Shared child process helpers +// --------------------------------------------------------------------------- + +/// Spawn a command, pipe JSON input to stdin, collect ToolResult from stdout. +async fn run_plugin_child( + command: &str, + args: &[String], + label: &str, + input: Value, +) -> Result { + let mut cmd = tokio::process::Command::new(command); + cmd.args(args); + run_plugin_child_raw(&mut cmd, label, input).await +} + +/// Run a pre-configured tokio Command, pipe JSON input, collect ToolResult. +async fn run_plugin_child_raw( + cmd: &mut tokio::process::Command, + label: &str, + input: Value, +) -> Result { + let input_bytes = serde_json::to_vec(&input) + .map_err(|e| ToolError::invalid_input(format!("failed to serialize input: {e}")))?; + + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| ToolError::execution_failed(format!("failed to spawn {label}: {e}")))?; + + if let Some(ref mut stdin) = child.stdin { + stdin + .write_all(&input_bytes) + .await + .map_err(|e| ToolError::execution_failed(format!("failed to write stdin: {e}")))?; + stdin + .shutdown() + .await + .map_err(|e| ToolError::execution_failed(format!("failed to close stdin: {e}")))?; + } + + let output = tokio::time::timeout(PLUGIN_EXECUTION_TIMEOUT, child.wait_with_output()) + .await + .map_err(|_| ToolError::Timeout { + seconds: PLUGIN_EXECUTION_TIMEOUT.as_secs(), + })? + .map_err(|e| ToolError::execution_failed(format!("process error: {e}")))?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + if let Ok(parsed) = serde_json::from_str::(&stdout) { + Ok(parsed) + } else { + Ok(ToolResult::success(stdout)) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let combined = if stderr.is_empty() { + stdout + } else if stdout.is_empty() { + stderr + } else { + format!("{stdout}\n{stderr}") + }; + Err(ToolError::execution_failed(combined)) + } +} + +// --------------------------------------------------------------------------- +// Frontmatter parsing +// --------------------------------------------------------------------------- + +/// Parse frontmatter header from the first `max_lines` lines of a text file. +/// +/// Expected format (one `# key: value` per line): +/// ```text +/// # name: my-tool +/// # description: Does something +/// # schema: {"type":"object"} +/// # approval: auto +/// ``` +/// +/// Also supports `// ` prefix for JavaScript/TypeScript scripts and `-- ` for Lua. +pub fn parse_frontmatter(content: &str) -> PluginMetadata { + let mut name = String::new(); + let mut description = String::new(); + let mut schema_str = String::new(); + let mut approval_str = String::new(); + + for line in content.lines().take(20) { + let line = line.trim(); + // Strip leading comment markers: `# `, `// `, `-- ` + let rest = line + .strip_prefix("# ") + .or_else(|| line.strip_prefix("// ")) + .or_else(|| line.strip_prefix("-- ")); + let Some(rest) = rest else { continue }; + if let Some((key, value)) = rest.split_once(": ") { + let key = key.trim().to_lowercase(); + let value = value.trim(); + match key.as_str() { + "name" => name = value.to_string(), + "description" => description = value.to_string(), + "schema" => schema_str = value.to_string(), + "approval" => approval_str = value.to_string(), + _ => {} + } + } + } + + let input_schema = if schema_str.is_empty() { + // Default: accept any object payload + serde_json::json!({"type": "object"}) + } else { + serde_json::from_str(&schema_str).unwrap_or_else(|_| serde_json::json!({"type": "object"})) + }; + + let approval = match approval_str.to_lowercase().as_str() { + "auto" => ApprovalRequirement::Auto, + "required" => ApprovalRequirement::Required, + _ => ApprovalRequirement::Suggest, + }; + + PluginMetadata { + name: if name.is_empty() { + "unnamed-plugin".to_string() + } else { + name + }, + description: if description.is_empty() { + "User-provided plugin tool".to_string() + } else { + description + }, + input_schema, + approval, + } +} + +/// Read the first 4 KB of a file and parse its frontmatter. +fn read_script_metadata(path: &Path) -> Option { + use std::io::Read; + let mut file = std::fs::File::open(path).ok()?; + let mut buf = [0u8; 4096]; + let n = file.read(&mut buf).ok()?; + let content = String::from_utf8_lossy(&buf[..n]); + let meta = parse_frontmatter(&content); + // Require at least the `name` field to consider it a valid plugin. + if meta.name == "unnamed-plugin" { + return None; + } + Some(meta) +} + +// --------------------------------------------------------------------------- +// Directory scanning +// --------------------------------------------------------------------------- + +/// Scan a directory for plugin script files with frontmatter headers. +/// +/// Files are considered eligible when: +/// - They are regular files (not directories, not symlinks) +/// - They don't start with `.` (hidden files) +/// - They are not `README.md` +/// - Their first 20 lines contain `# name:` frontmatter +pub fn scan_plugin_dir(dir: &Path) -> Vec<(PathBuf, PluginMetadata)> { + let mut results = Vec::new(); + + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + tracing::warn!("Failed to read plugin directory {}: {e}", dir.display()); + return results; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Skip directories and hidden files + if path.is_dir() { + continue; + } + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with('.') || name == "README.md" { + continue; + } + } + + // Try to parse frontmatter + if let Some(meta) = read_script_metadata(&path) { + results.push((path, meta)); + } + } + + results +} + +/// Load all plugin tools from a directory. Each eligible script becomes +/// a registered `ScriptPluginTool`. +pub fn load_plugin_tools(plugin_dir: &Path) -> Vec> { + let discovered = scan_plugin_dir(plugin_dir); + let mut tools: Vec> = Vec::with_capacity(discovered.len()); + + for (path, meta) in discovered { + tracing::info!( + "Discovered plugin tool '{}' at {}", + meta.name, + path.display() + ); + tools.push(Arc::new(ScriptPluginTool { + metadata: meta, + script_path: path, + args: Vec::new(), + })); + } + + tools +} + +/// Create a single tool from a `ToolOverride` config entry. +/// +/// Returns `None` for `Disabled` (the caller handles removal separately). +pub fn tool_from_override( + tool_name: &str, + override_cfg: &ToolOverride, + plugin_dir: &Path, +) -> Option> { + match override_cfg { + ToolOverride::Disabled => None, + ToolOverride::Script { path, args } => { + let script_path = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + // Relative paths resolve relative to the plugin directory. + plugin_dir.join(path) + }; + + if !script_path.exists() { + tracing::warn!( + "Override script for '{}' not found at {}", + tool_name, + script_path.display() + ); + return None; + } + + // Read the script's own frontmatter for metadata, or provide + // defaults if it has none. + let meta = read_script_metadata(&script_path).unwrap_or_else(|| PluginMetadata { + name: tool_name.to_string(), + description: format!("Override for built-in tool '{tool_name}'"), + input_schema: serde_json::json!({"type": "object"}), + approval: ApprovalRequirement::Suggest, + }); + + Some(Arc::new(ScriptPluginTool { + metadata: meta, + script_path, + args: args.clone().unwrap_or_default(), + }) as Arc) + } + ToolOverride::Command { command, args } => { + // Build a description that includes the command. + let description = format!("Override for '{tool_name}' — runs: {command}"); + let cmd_args = args.clone().unwrap_or_default(); + + Some(Arc::new(CommandPluginTool { + name: tool_name.to_string(), + description, + input_schema: serde_json::json!({"type": "object"}), + command: command.clone(), + args: cmd_args, + approval: ApprovalRequirement::Suggest, + }) as Arc) + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_parse_frontmatter_full() { + let content = "\ +#!/usr/bin/env sh +# name: my-tool +# description: A useful custom tool +# schema: {\"type\":\"object\",\"properties\":{\"input\":{\"type\":\"string\"}}} +# approval: required +echo hello +"; + let meta = parse_frontmatter(content); + assert_eq!(meta.name, "my-tool"); + assert_eq!(meta.description, "A useful custom tool"); + assert_eq!(meta.approval, ApprovalRequirement::Required); + assert_eq!( + meta.input_schema, + serde_json::json!({"type":"object","properties":{"input":{"type":"string"}}}) + ); + } + + #[test] + fn test_parse_frontmatter_minimal() { + let content = "# name: mini"; + let meta = parse_frontmatter(content); + assert_eq!(meta.name, "mini"); + assert_eq!(meta.description, "User-provided plugin tool"); + assert_eq!(meta.approval, ApprovalRequirement::Suggest); + } + + #[test] + fn test_parse_frontmatter_missing_name() { + let content = "# description: no name here"; + let meta = parse_frontmatter(content); + assert_eq!(meta.name, "unnamed-plugin"); + // read_script_metadata would return None for this. + } + + #[test] + fn test_scan_plugin_dir_finds_scripts() { + let dir = TempDir::new().unwrap(); + + // Valid plugin + std::fs::write( + dir.path().join("my-plugin.sh"), + "# name: my-plugin\n# description: test\n", + ) + .unwrap(); + + // Hidden file — should be skipped + std::fs::write( + dir.path().join(".hidden.sh"), + "# name: hidden\n# description: should skip\n", + ) + .unwrap(); + + // README — should be skipped + std::fs::write(dir.path().join("README.md"), "# Tools\n").unwrap(); + + // No frontmatter — should be skipped + std::fs::write(dir.path().join("random.sh"), "echo hi\n").unwrap(); + + let discovered = scan_plugin_dir(dir.path()); + assert_eq!(discovered.len(), 1); + assert_eq!(discovered[0].1.name, "my-plugin"); + } + + #[test] + fn test_load_plugin_tools_creates_tools() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("greet.sh"), + "# name: greet\n# description: Say hello\n# schema: {\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}},\"required\":[\"name\"]}\n", + ) + .unwrap(); + + let tools = load_plugin_tools(dir.path()); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name(), "greet"); + assert_eq!(tools[0].description(), "Say hello"); + } + + #[test] + fn test_tool_from_override_script() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("wrapper.sh"), + "# name: exec_shell\n# description: Audit wrapper for exec_shell\n", + ) + .unwrap(); + + let override_cfg = ToolOverride::Script { + path: "wrapper.sh".to_string(), + args: None, + }; + + let tool = tool_from_override("exec_shell", &override_cfg, dir.path()); + assert!(tool.is_some()); + assert_eq!(tool.unwrap().name(), "exec_shell"); + } + + #[test] + fn test_tool_from_override_disabled() { + let dir = TempDir::new().unwrap(); + let override_cfg = ToolOverride::Disabled; + let tool = tool_from_override("code_execution", &override_cfg, dir.path()); + assert!(tool.is_none()); + } + + #[test] + fn test_tool_from_override_command() { + let dir = TempDir::new().unwrap(); + let override_cfg = ToolOverride::Command { + command: "my-custom-reader".to_string(), + args: Some(vec!["--format".to_string(), "json".to_string()]), + }; + let tool = tool_from_override("read_file", &override_cfg, dir.path()); + assert!(tool.is_some()); + assert_eq!(tool.unwrap().name(), "read_file"); + } + + #[test] + fn test_tool_from_override_script_absolute_path() { + let dir = TempDir::new().unwrap(); + let script_path = dir.path().join("audit.sh"); + std::fs::write(&script_path, "# name: exec_shell\n# description: Audit\n").unwrap(); + + let override_cfg = ToolOverride::Script { + path: script_path.to_str().unwrap().to_string(), + args: None, + }; + + let tool = tool_from_override("exec_shell", &override_cfg, dir.path()); + assert!(tool.is_some()); + } + + #[test] + fn test_approval_variants() { + let check = |content: &str, expected: ApprovalRequirement| { + assert_eq!(parse_frontmatter(content).approval, expected); + }; + + check("# name: x\n# approval: auto", ApprovalRequirement::Auto); + check( + "# name: x\n# approval: required", + ApprovalRequirement::Required, + ); + check( + "# name: x\n# approval: suggest", + ApprovalRequirement::Suggest, + ); + check( + "# name: x\n# approval: unknown", + ApprovalRequirement::Suggest, + ); + check("# name: x", ApprovalRequirement::Suggest); + } +} diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index f84a49278..466df8f82 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -9,6 +9,8 @@ use std::collections::HashMap; use std::sync::{Arc, OnceLock}; +use std::path::Path; + use serde_json::Value; use crate::client::DeepSeekClient; @@ -388,6 +390,77 @@ impl ToolRegistry { self.tools.clear(); self.invalidate_api_cache(); } + + /// Remove a tool from the registry by name. Returns `true` if the tool + /// was present and removed, `false` if no tool with that name existed. + pub fn remove_tool(&mut self, name: &str) -> bool { + let existed = self.tools.remove(name).is_some(); + if existed { + self.invalidate_api_cache(); + } + existed + } + + /// Apply config.toml tool overrides to this registry. + /// + /// For each entry in `overrides`: + /// - `Disabled` removes the tool. + /// - `Script` / `Command` replaces the tool with the user's implementation. + /// + /// `plugin_dir` is used as the base for relative script paths. + pub fn apply_overrides( + &mut self, + overrides: &std::collections::HashMap, + plugin_dir: &Path, + ) { + for (tool_name, override_cfg) in overrides { + match override_cfg { + crate::config::ToolOverride::Disabled => { + if self.remove_tool(tool_name) { + tracing::info!("Tool '{}' disabled via config override", tool_name); + } else { + tracing::warn!("Cannot disable tool '{}': not registered", tool_name); + } + } + _ => { + // Script and Command overrides create replacement tools. + use crate::tools::plugin::tool_from_override; + if let Some(replacement) = + tool_from_override(tool_name, override_cfg, plugin_dir) + { + self.register(replacement); + tracing::info!("Tool '{}' replaced via config override", tool_name); + } + } + } + } + } + + /// Load and register plugin tools from a directory. + /// + /// Each script with valid frontmatter (`# name:`, `# description:`, etc.) + /// becomes a registered `ScriptPluginTool`. Tools whose name matches an + /// already-registered tool will overwrite it. + pub fn load_plugins(&mut self, plugin_dir: &Path) { + if !plugin_dir.exists() { + tracing::debug!( + "Plugin directory {} does not exist, skipping", + plugin_dir.display() + ); + return; + } + let plugins = crate::tools::plugin::load_plugin_tools(plugin_dir); + let count = plugins.len(); + for tool in plugins { + self.register(tool); + } + if count > 0 { + tracing::info!( + "Loaded {count} plugin tool(s) from {}", + plugin_dir.display() + ); + } + } } /// Builder for constructing a `ToolRegistry` with common tools. diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index bb3932675..c1c36a69d 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -722,6 +722,9 @@ impl ShellManager { policy_override: Option, extra_env: HashMap, ) -> Result { + // Log execution via ShellDispatcher when SHELL_DISPATCHER_LOG is set. + crate::shell_dispatcher::ShellDispatcher::log_exec(command); + let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); // Clamp timeout to max 10 minutes (600000ms) @@ -785,6 +788,8 @@ impl ShellManager { policy_override: Option, extra_env: HashMap, ) -> Result { + crate::shell_dispatcher::ShellDispatcher::log_exec(command); + let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from); let timeout_ms = timeout_ms.clamp(1000, 600_000); @@ -832,6 +837,17 @@ impl ShellManager { child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env)); + // Disable raw mode before spawn; restore on drop regardless of + // success/failure/timeout (issue #1690). + let _ = crossterm::terminal::disable_raw_mode(); + struct SyncRawModeGuard; + impl Drop for SyncRawModeGuard { + fn drop(&mut self) { + let _ = crossterm::terminal::enable_raw_mode(); + } + } + let _guard = SyncRawModeGuard; + let mut child = cmd .spawn() .with_context(|| format!("Failed to execute: {original_command}"))?; @@ -966,6 +982,16 @@ impl ShellManager { } install_parent_death_signal(&mut cmd); + // Disable raw mode before spawn; restore on drop (issue #1690). + let _ = crossterm::terminal::disable_raw_mode(); + struct InteractiveRawModeGuard; + impl Drop for InteractiveRawModeGuard { + fn drop(&mut self) { + let _ = crossterm::terminal::enable_raw_mode(); + } + } + let _guard = InteractiveRawModeGuard; + child_env::apply_to_command(&mut cmd, child_env::string_map_env(&exec_env.env)); let mut child = cmd diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 08b1f42da..606d89cbb 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -821,10 +821,14 @@ fn issue_1691_quoted_commit_message_round_trips() { #[cfg(not(windows))] { - // `sh -c `: the whole command (with quotes) is a single argv - // entry. `sh` then POSIX-tokenizes it → correct git argv. We never - // split the command string ourselves. - assert_eq!(spec.program, "sh"); + // `sh -c ` (or bash/zsh): the whole command (with quotes) is a + // single argv entry. The shell then POSIX-tokenizes it → correct git + // argv. We never split the command string ourselves. + assert!( + spec.program == "sh" || spec.program == "bash" || spec.program == "zsh", + "expected sh/bash/zsh, got {}", + spec.program + ); assert_eq!(spec.args, ["-c".to_string(), cmd.to_string()]); assert_eq!(spec.args.len(), 2); @@ -840,13 +844,13 @@ fn issue_1691_quoted_commit_message_round_trips() { #[cfg(windows)] { - // `cmd /C `: payload carries the quotes verbatim. The fix - // routes /C + payload through `raw_arg` so `cmd.exe` (not MSVCRT) - // parses it, matching what a terminal does. - assert_eq!(spec.program, "cmd"); - assert_eq!( - spec.args, - ["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")] + // Shell program and args depend on detected shell (cmd, pwsh, ...). + // Verify command is the last arg and push_shell_args round-trips. + assert!(!spec.program.is_empty(), "program must not be empty"); + assert!( + spec.args.last().map_or(false, |a| a.contains(cmd)), + "the last arg must contain the command; got {:?}", + spec.args.last() ); let mut built = Command::new(&spec.program); push_shell_args(&mut built, &spec.program, &spec.args); diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index 2c3ce95b9..647a7d145 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -11,6 +11,7 @@ use tokio::process::Command; use uuid::Uuid; use crate::command_safety::{SafetyLevel, analyze_command}; +use crate::dependencies::ExternalTool; use crate::task_manager::{ NewTaskRequest, TaskArtifactRef, TaskAttemptRecord, TaskGateRecord, TaskRecord, }; @@ -287,9 +288,10 @@ impl ToolSpec for TaskGateRunTool { } let started = Instant::now(); - let mut cmd = Command::new("/bin/sh"); - cmd.arg("-lc") - .arg(&command) + let (program, args) = + crate::shell_dispatcher::global_dispatcher().build_command_parts(&command); + let mut cmd = Command::new(&program); + cmd.args(&args) .current_dir(&cwd) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -748,10 +750,12 @@ impl ToolSpec for PrAttemptPreflightTool { .as_ref() .ok_or_else(|| ToolError::invalid_input("Attempt has no patch artifact"))?; let patch_path = manager.artifact_absolute_path(patch_ref); - let out = Command::new("git") + let workspace = context.workspace.clone(); + let out = crate::dependencies::Git::tokio_command() + .ok_or_else(|| ToolError::execution_failed("git not found on PATH"))? .args(["apply", "--check"]) .arg(&patch_path) - .current_dir(&context.workspace) + .current_dir(&workspace) .output() .await .map_err(|e| ToolError::execution_failed(format!("git apply --check failed: {e}")))?; @@ -900,10 +904,7 @@ fn task_id_schema() -> Value { } fn git_output(workspace: &Path, args: &[&str]) -> Result { - let out = std::process::Command::new("git") - .args(args) - .current_dir(workspace) - .output() + let out = crate::dependencies::Git::output(args, workspace) .map_err(|e| ToolError::execution_failed(format!("failed to run git: {e}")))?; if !out.status.success() { return Err(ToolError::execution_failed(format!( diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index d62a00da9..f3fa5f0f0 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -3575,6 +3575,7 @@ impl App { /// In a multiline composer, jump to the start of the current line. /// On single-line input this is equivalent to `move_cursor_start`. + #[allow(dead_code)] pub fn move_cursor_line_start(&mut self) { let byte_pos = byte_index_at_char(&self.input, self.cursor_position); let before = &self.input[..byte_pos]; @@ -3590,6 +3591,7 @@ impl App { /// In a multiline composer, jump to the end of the current line /// (just before the next `\n` or at the end of input). /// On single-line input this is equivalent to `move_cursor_end`. + #[allow(dead_code)] pub fn move_cursor_line_end(&mut self) { let search_start = byte_index_at_char(&self.input, self.cursor_position); if let Some(offset) = self.input[search_start..].find('\n') { @@ -4080,6 +4082,7 @@ impl App { Some(input) } + #[allow(dead_code)] pub fn restore_last_submitted_prompt_if_empty(&mut self) -> bool { if !self.input.is_empty() { return false; @@ -4442,6 +4445,7 @@ impl App { self.last_effective_model = None; } + #[allow(dead_code)] pub fn model_selection_for_persistence(&self) -> String { if self.auto_model || self.model.trim().eq_ignore_ascii_case("auto") { "auto".to_string() diff --git a/crates/tui/src/tui/persistence_actor.rs b/crates/tui/src/tui/persistence_actor.rs index 4c1b58050..da28c0f5d 100644 --- a/crates/tui/src/tui/persistence_actor.rs +++ b/crates/tui/src/tui/persistence_actor.rs @@ -43,11 +43,13 @@ pub enum PersistRequest { /// Write a full session snapshot (completed turn, durable save). SessionSnapshot(SavedSession), /// Write queued/draft offline input for crash recovery. + #[allow(dead_code)] OfflineQueue { state: OfflineQueueState, session_id: Option, }, /// Remove the queued/draft offline input file. + #[allow(dead_code)] ClearOfflineQueue, /// Remove the crash-recovery checkpoint file. ClearCheckpoint, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index c0008c915..275b05640 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -20,6 +20,7 @@ use crossterm::{ // On Windows the push/pop helpers write the escapes directly; crossterm's // PushKeyboardEnhancementFlags / PopKeyboardEnhancementFlags commands are // never referenced, so the imports are gated to avoid -D warnings failures. +use crate::logging; #[cfg(not(windows))] use crossterm::event::{ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, @@ -135,6 +136,7 @@ const SLASH_MENU_LIMIT: usize = 128; const MENTION_MENU_LIMIT: usize = 6; const MIN_CHAT_HEIGHT: u16 = 3; const MIN_COMPOSER_HEIGHT: u16 = 2; +const CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT: f64 = 60.0; const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; const UI_IDLE_POLL_MS: u64 = 48; @@ -255,7 +257,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { // Terminal probe with timeout to prevent hanging on unresponsive terminals let probe_timeout = terminal_probe_timeout(config); let enable_raw = tokio::task::spawn_blocking(move || { - enable_raw_mode().map_err(|e| anyhow::anyhow!("Failed to enable raw mode: {e}")) + enable_raw_mode().map_err(|e| anyhow::anyhow!("Failed to enable raw mode: {}", e)) }); match tokio::time::timeout(probe_timeout, enable_raw).await { @@ -278,6 +280,11 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { if use_alt_screen { execute!(stdout, EnterAlternateScreen)?; } + // On Windows, stderr cannot be redirected to the log file (no dup2). + // Suppress verbose CLI logging once the alt-screen is active so + // eprintln! calls from crate::logging don't leak into the TUI buffer. + #[cfg(windows)] + crate::logging::set_verbose(false); // Initialize the file-backed TUI log and (on Unix) redirect raw stderr // away from the alt-screen for the lifetime of this guard. Any // `eprintln!`, panic message, or third-party stderr write that would @@ -723,6 +730,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { .and_then(|s| s.provider) .unwrap_or_default(), search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()), + tools: config.tools.clone(), } } @@ -732,29 +740,11 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { /// Tasks completing during the current session always show (until the /// next session boundary). Tasks that completed shortly before the /// session also show, so users coming back to a terminal see "you just -/// finished X". Anything older than this window is hidden — preventing -/// the sidebar from accumulating indefinitely (bug #1913). +/// finished X". Anything older than this window is hidden. const WORK_SIDEBAR_RECENT_COMPLETED_TTL: chrono::Duration = chrono::Duration::hours(2); /// Choose which durable-task summaries should appear in the Work /// sidebar's Tasks panel. -/// -/// Active tasks (`Queued`/`Running`) are always included. Terminal -/// tasks (`Completed`/`Failed`/`Canceled`) are kept only if their -/// `ended_at` falls within the "recent" window — defined as either: -/// -/// - within the current TUI session (`ended_at >= session_started_at`), or -/// - within `recent_ttl` of `now` (so a task that finished a few -/// minutes before the session started still shows). -/// -/// Anything older than that — including the multi-day-old completed -/// tasks reported in bug #1913 — is excluded so the sidebar does not -/// accumulate indefinitely across sessions. -/// -/// A terminal task missing `ended_at` is treated as not-recent and -/// dropped: durable tasks always stamp `ended_at` when they reach a -/// terminal state, so absence of it indicates a record from a much -/// older schema and isn't worth surfacing. pub(crate) fn select_work_sidebar_tasks( tasks: Vec, session_started_at: chrono::DateTime, @@ -889,7 +879,17 @@ async fn run_event_loop( .checked_sub(Duration::from_secs(60)) .unwrap_or_else(Instant::now); + let mut loop_ticks: u64 = 0; + loop { + loop_ticks += 1; + if loop_ticks % 100 == 0 { + logging::info(format!( + "[FREEZE-DEBUG] event_loop tick={loop_ticks}, mode={:?}, streaming={}", + app.mode, app.streaming_state.is_active + )); + } + if !drain_web_config_events(&mut web_config_session, app, config, &engine_handle).await { web_config_session = None; } @@ -992,16 +992,20 @@ async fn run_event_loop( let mut transcript_batch_updated = false; let mut queued_to_send: Option = None; { + if loop_ticks % 100 == 0 { + logging::info(format!( + "[FREEZE-DEBUG] engine_poll_enter tick={loop_ticks}" + )); + } let mut rx = engine_handle.rx_event.write().await; + let _poll_start = Instant::now(); while let Ok(event) = rx.try_recv() { received_engine_event = true; + logging::info(format!( + "[FREEZE-DEBUG] engine_event_rcvd tick={loop_ticks}" + )); if app.suppress_stream_events_until_turn_complete { if matches!(event, EngineEvent::TurnStarted { .. }) { - // Ctrl+C can race with the engine's per-turn token - // reset: the first cancel may hit the previous token - // if SendMessage is queued but TurnStarted has not - // arrived yet. Reassert cancellation once the real - // turn starts, then keep hiding its queued deltas. engine_handle.cancel(); continue; } @@ -1013,6 +1017,7 @@ async fn run_event_loop( } match event { EngineEvent::MessageStarted { .. } => { + logging::info("[FREEZE-DEBUG] EngineEvent::MessageStarted"); // Assistant text starting after parallel tool work // means the tool group is done. Flush the active // cell first so the message lands BELOW the @@ -1045,6 +1050,7 @@ async fn run_event_loop( } } EngineEvent::MessageComplete { .. } => { + let complete_char_count = current_streaming_text.len(); // #861 RC3: defensive drain of a still-active thinking // entry. Normally `ThinkingComplete` arrives first and // populates `last_reasoning` before we get here, but @@ -1134,6 +1140,9 @@ async fn run_event_loop( tool_uses, ); } + logging::info(format!( + "[FREEZE-DEBUG] EngineEvent::MessageComplete chars={complete_char_count}" + )); } EngineEvent::ThinkingStarted { .. } => { // P2.3: thinking lives in the active cell so it groups @@ -1320,7 +1329,6 @@ async fn run_event_loop( } } EngineEvent::TurnStarted { turn_id } => { - app.suppress_stream_events_until_turn_complete = false; app.is_loading = true; app.offline_mode = false; app.turn_error_posted = false; @@ -2648,6 +2656,19 @@ async fn run_event_loop( continue; } + // Ctrl+L: manual context compaction — breaks the chicken-and-egg + // deadlock when the model is too slow to suggest /compact at high + // context saturation. Fires even when a turn is streaming so the + // user can compact between turns without canceling. + if matches!(key.code, KeyCode::Char('l') | KeyCode::Char('L')) + && key.modifiers.contains(KeyModifiers::CONTROL) + && app.view_stack.is_empty() + { + app.status_message = Some("Compacting context (Ctrl+L)...".to_string()); + let _ = engine_handle.send(Op::CompactContext).await; + continue; + } + if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) && key.modifiers.contains(KeyModifiers::ALT) && !key.modifiers.contains(KeyModifiers::CONTROL) @@ -2702,7 +2723,7 @@ async fn run_event_loop( // Insert @path into the composer. let path_str = rel_path.to_string_lossy().to_string(); app.status_message = Some(format!("Attached @{path_str}")); - app.insert_str(&format!("@{path_str} ")); + app.insert_str(&format!("@{} ", path_str)); } else { // Directory was expanded/collapsed; rebuild. app.needs_redraw = true; @@ -2815,24 +2836,6 @@ async fn run_event_loop( { continue; } - // Space toggles collapse/expand of the focused thinking block - // when the composer is empty (#1972). - KeyCode::Char(' ') - if key.modifiers == KeyModifiers::NONE && app.input.is_empty() => - { - if let Some(idx) = detail_target_cell_index(app) { - if app.collapsed_cells.contains(&idx) { - app.collapsed_cells.remove(&idx); - app.status_message = Some("Thinking block expanded".to_string()); - } else { - app.collapsed_cells.insert(idx); - app.status_message = Some("Thinking block collapsed".to_string()); - } - app.mark_history_updated(); - app.needs_redraw = true; - } - continue; - } KeyCode::Char('t') | KeyCode::Char('T') if key.modifiers == KeyModifiers::CONTROL => { @@ -2928,16 +2931,18 @@ async fn run_event_loop( CtrlCDisposition::CancelTurn => { engine_handle.cancel(); mark_active_turn_cancelled_locally(app); - current_streaming_text.clear(); - let prompt_restored = app.restore_last_submitted_prompt_if_empty(); - app.status_message = Some( - if prompt_restored { - "Request cancelled; prompt restored to composer" - } else { - "Request cancelled" - } - .to_string(), - ); + app.is_loading = false; + app.dispatch_started_at = None; + app.streaming_state.reset(); + app.suppress_stream_events_until_turn_complete = true; + // Optimistically clear the turn-in-progress flag + // so the footer wave animation halts immediately — + // without this, the strip keeps animating until + // the engine eventually emits TurnComplete (#5a). + // The engine's eventual TurnComplete event will + // overwrite with the real outcome ("interrupted"). + app.runtime_turn_status = None; + app.status_message = Some("Request cancelled".to_string()); app.disarm_quit(); } CtrlCDisposition::ConfirmExit => { @@ -2985,7 +2990,25 @@ async fn run_event_loop( app.backtrack.reset(); engine_handle.cancel(); mark_active_turn_cancelled_locally(app); - current_streaming_text.clear(); + app.is_loading = false; + app.dispatch_started_at = None; + app.streaming_state.reset(); + app.suppress_stream_events_until_turn_complete = true; + // Optimistically halt the wave + working label — + // engine's TurnComplete will resync with the real + // outcome. Fixes #5a (wave kept animating after Esc). + app.runtime_turn_status = None; + // Finalize any in-flight tool entries optimistically so + // the composer regains focus and the footer's "tool ... + // · X active" chip clears immediately rather than + // waiting for the engine's TurnComplete echo to drain. + // Idempotent with the TurnComplete handler that runs + // when the engine actually echoes the cancel (#243). + // Background sub-agents continue running — they are + // tracked via `subagent_cache` independently of the + // foreground turn. + app.finalize_active_cell_as_interrupted(); + app.finalize_streaming_assistant_as_interrupted(); app.status_message = Some("Request cancelled".to_string()); } EscapeAction::DiscardQueuedDraft => { @@ -3453,10 +3476,10 @@ async fn run_event_loop( app.move_cursor_start(); } KeyCode::Home => { - app.move_cursor_line_start(); + app.move_cursor_start(); } KeyCode::End => { - app.move_cursor_line_end(); + app.move_cursor_end(); } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.move_cursor_end(); @@ -3869,23 +3892,22 @@ pub(crate) fn apply_engine_error_to_app( } fn persist_offline_queue_state(app: &App) { - if app.queued_messages.is_empty() && app.queued_draft.is_none() { - persistence_actor::persist(PersistRequest::ClearOfflineQueue); - return; + if let Ok(manager) = SessionManager::default_location() { + if app.queued_messages.is_empty() && app.queued_draft.is_none() { + let _ = manager.clear_offline_queue_state(); + return; + } + let state = OfflineQueueState { + messages: app + .queued_messages + .iter() + .map(queued_ui_to_session) + .collect(), + draft: app.queued_draft.as_ref().map(queued_ui_to_session), + ..OfflineQueueState::default() + }; + let _ = manager.save_offline_queue_state(&state, app.current_session_id.as_deref()); } - let state = OfflineQueueState { - messages: app - .queued_messages - .iter() - .map(queued_ui_to_session) - .collect(), - draft: app.queued_draft.as_ref().map(queued_ui_to_session), - ..OfflineQueueState::default() - }; - persistence_actor::persist(PersistRequest::OfflineQueue { - state, - session_id: app.current_session_id.clone(), - }); } /// Strip ANSI control codes / non-printable bytes from a streaming @@ -4325,19 +4347,22 @@ async fn apply_model_picker_choice( // Best-effort persist; surface a status warning if the settings file // can't be written rather than aborting the in-memory change. let mut persist_warning: Option = None; - let persist_result = (|| -> anyhow::Result<()> { - let mut settings = crate::settings::Settings::load()?; - if model_changed { - settings.set("default_model", &model)?; - settings.set_model_for_provider(app.api_provider.as_str(), &model); + match crate::settings::Settings::load() { + Ok(mut settings) => { + if model_changed { + let _ = settings.set("default_model", &model); + settings.set_model_for_provider(app.api_provider.as_str(), &model); + } + if effort_changed { + let _ = settings.set("reasoning_effort", effort.as_setting()); + } + if let Err(err) = settings.save() { + persist_warning = Some(format!("(not persisted: {err})")); + } } - if effort_changed { - settings.set("reasoning_effort", effort.as_setting())?; + Err(err) => { + persist_warning = Some(format!("(not persisted: {err})")); } - settings.save() - })(); - if let Err(err) = persist_result { - persist_warning = Some(format!("(not persisted: {err})")); } if model_changed { @@ -5556,6 +5581,18 @@ fn build_pending_input_preview(app: &App) -> PendingInputPreview { } fn render(f: &mut Frame, app: &mut App) { + { + use std::sync::atomic::{AtomicU64, Ordering}; + static RENDER_COUNT: AtomicU64 = AtomicU64::new(0); + let n = RENDER_COUNT.fetch_add(1, Ordering::Relaxed); + if n % 100 == 0 || app.needs_redraw { + logging::info(format!( + "[FREEZE-DEBUG] render #{n} cells={} needs_redraw={}", + app.history.len(), + app.needs_redraw + )); + } + } let size = f.area(); // Clear entire area with the configured app background. @@ -5751,25 +5788,6 @@ fn render(f: &mut Frame, app: &mut App) { // burst of events isn't collapsed to a single visible message. render_toast_stack_overlay(f, size, chunks[3], chunks[4], app); - // Decision card overlay (v0.8.43 truth-surface). When a decision card is - // active, render it centered on top of the transcript. - if let Some(ref card) = app.decision_card { - let card_width = size.width.clamp(30, 60); - let card_height = card.desired_height(card_width); - let card_area = ratatui::layout::Rect { - x: size - .x - .saturating_add(size.width.saturating_sub(card_width) / 2), - y: size - .y - .saturating_add(size.height.saturating_sub(card_height) / 2), - width: card_width, - height: card_height.min(size.height), - }; - let buf = f.buffer_mut(); - card.render(card_area, buf); - } - if !app.view_stack.is_empty() { // The live transcript overlay snapshots the app's history + active // cell on each render so streaming mutations propagate. Other views @@ -6212,6 +6230,13 @@ async fn handle_view_events( app.backtrack.reset(); engine_handle.cancel(); mark_active_turn_cancelled_locally(app); + app.is_loading = false; + app.dispatch_started_at = None; + app.streaming_state.reset(); + app.suppress_stream_events_until_turn_complete = true; + app.runtime_turn_status = None; + app.finalize_active_cell_as_interrupted(); + app.finalize_streaming_assistant_as_interrupted(); app.status_message = Some("Request cancelled".to_string()); } } @@ -6639,6 +6664,10 @@ fn resume_terminal( enable_raw_mode()?; if use_alt_screen { execute!(terminal.backend_mut(), EnterAlternateScreen)?; + // Re-entering alt-screen after mode recovery — suppress verbose + // CLI logging again so eprintln! doesn't leak into the TUI. + #[cfg(windows)] + crate::logging::set_verbose(false); } recover_terminal_modes( terminal.backend_mut(), @@ -6701,6 +6730,7 @@ fn push_keyboard_enhancement_flags(writer: &mut W) { "PushKeyboardEnhancementFlags direct write failed on Windows" ); } + return; } #[cfg(not(windows))] if let Err(err) = execute!( @@ -6732,6 +6762,7 @@ pub(crate) fn pop_keyboard_enhancement_flags(writer: &mut W) { "PopKeyboardEnhancementFlags direct write failed on Windows" ); } + return; } #[cfg(not(windows))] let _ = execute!(writer, PopKeyboardEnhancementFlags); @@ -7046,6 +7077,48 @@ fn maybe_warn_context_pressure(app: &mut App) { return; }; + // Early heads-up at 60% — the same threshold the model is told to + // suggest /compact at. Non-intrusive: only sets the status message when + // it's currently empty, so it never stomps on a more important message. + let effective_warn_threshold = if app.auto_compact { + CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT.min(CONTEXT_CRITICAL_THRESHOLD_PERCENT) + } else { + CONTEXT_SUGGEST_COMPACT_THRESHOLD_PERCENT + }; + + if percent >= effective_warn_threshold + && percent < CONTEXT_WARNING_THRESHOLD_PERCENT + && app.status_message.is_none() + { + let hint = if app.auto_compact { + let below_floor = (used as usize) < crate::compaction::MINIMUM_AUTO_COMPACTION_TOKENS; + let below_threshold = percent < CONTEXT_CRITICAL_THRESHOLD_PERCENT; + + if below_floor && below_threshold { + format!( + "Auto-compact enabled but below 500K floor and {:.0}% threshold; won't fire yet.", + CONTEXT_CRITICAL_THRESHOLD_PERCENT + ) + } else if below_floor { + "Auto-compact enabled but below 500K token floor; won't fire yet.".to_string() + } else if below_threshold { + format!( + "Auto-compact will fire at {:.0}% (currently {:.0}%).", + CONTEXT_CRITICAL_THRESHOLD_PERCENT, percent + ) + } else { + "Auto-compaction would fire now; it will run before the next send.".to_string() + } + } else { + "Consider enabling auto_compact or use /compact.".to_string() + }; + app.status_message = Some(format!( + "Context building: {:.0}% ({used}/{max} tokens). {hint}", + percent + )); + return; + } + if percent < CONTEXT_WARNING_THRESHOLD_PERCENT { return; } @@ -7058,14 +7131,16 @@ fn maybe_warn_context_pressure(app: &mut App) { if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT { app.status_message = Some(format!( - "Context critical: {percent:.0}% ({used}/{max} tokens). {recommendation}" + "Context critical: {:.0}% ({used}/{max} tokens). {recommendation}", + percent )); return; } if app.status_message.is_none() { app.status_message = Some(format!( - "Context high: {percent:.0}% ({used}/{max} tokens). {recommendation}" + "Context high: {:.0}% ({used}/{max} tokens). {recommendation}", + percent )); } } @@ -7074,8 +7149,20 @@ fn should_auto_compact_before_send(app: &App) -> bool { if !app.auto_compact { return false; } + // Use the configurable threshold (default 70%) instead of the old + // hardcoded 95% critical threshold. The 500K-token hard floor from + // compaction.rs is also respected here so small sessions are never + // auto-compacted even if the percentage looks high. context_usage_snapshot(app) - .map(|(_, _, pct)| pct >= CONTEXT_CRITICAL_THRESHOLD_PERCENT) + .map(|(used, _, pct)| { + // Hard floor: below 500K tokens, auto-compaction is refused + // because rewriting the prefix kills V4's prefix cache for + // little budget recovery. + if (used as usize) < crate::compaction::MINIMUM_AUTO_COMPACTION_TOKENS { + return false; + } + pct >= CONTEXT_CRITICAL_THRESHOLD_PERCENT + }) .unwrap_or(false) } @@ -7436,7 +7523,7 @@ fn activity_status_line(cell: &HistoryCell) -> Option { } Some(line) } - HistoryCell::Error { severity, .. } => Some(format!("Status: {severity:?}")), + HistoryCell::Error { severity, .. } => Some(format!("Status: {:?}", severity)), HistoryCell::SubAgent(_) => None, _ => None, }