Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro

# AtlasCloud OpenAI-compatible endpoint
# DEEPSEEK_PROVIDER=atlascloud
# ATLASCLOUD_API_KEY=
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1
# ATLASCLOUD_MODEL=deepseek-ai/deepseek-v4-flash

# Logging
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
# TUI's lightweight verbose logs for info/debug/trace directives.
Expand Down
53 changes: 53 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,59 @@ 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 `~/.codewhale/tools/`)
# plugin_dir = "~/.codewhale/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 `~/.codewhale/tools/` and enable with:
#
# [tools.overrides]
# "exec_shell" = { type = "script", path = "audit-exec-shell.sh" }
#
# The wrapper logs every request to `~/.codewhale/audit/exec_shell.log`, then
# delegates to your own approved shell executor. Do not pipe the raw JSON
# request into `sh -s`; parse the command field and enforce your policy first.
#
# ```sh
# #!/usr/bin/env sh
# # name: exec_shell
# # description: Audit-logging wrapper for exec_shell
# # approval: required
# LOGDIR="${HOME}/.codewhale/audit"
# mkdir -p "$LOGDIR"
# LOGFILE="$LOGDIR/exec_shell.log"
# input=$(cat)
# echo "[$(date -Iseconds)] $input" >> "$LOGFILE"
# printf '%s\n' '{"content":"audit wrapper placeholder: configure an executor","success":false}'
# ```

# ─────────────────────────────────────────────────────────────────────────────────
# Requirements (admin constraints) example file
# ─────────────────────────────────────────────────────────────────────────────────
Expand Down
48 changes: 48 additions & 0 deletions crates/agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/deepseek-v4-flash".to_string(),
provider: ProviderKind::Atlascloud,
aliases: vec![
"deepseek-v4-flash".to_string(),
"atlascloud-deepseek-v4-flash".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/deepseek-v4-pro".to_string(),
provider: ProviderKind::Atlascloud,
aliases: vec![
"deepseek-v4-pro".to_string(),
"atlascloud-deepseek-v4-pro".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-reasoner".to_string(),
provider: ProviderKind::WanjieArk,
Expand Down Expand Up @@ -434,6 +454,34 @@ mod tests {
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
}

#[test]
fn atlascloud_default_uses_namespaced_model_id() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(None, Some(ProviderKind::Atlascloud));

assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
assert!(resolved.resolved.supports_reasoning);
}

#[test]
fn deepseek_v4_flash_alias_resolves_to_atlascloud_when_provider_hinted() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Atlascloud));

assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
}

#[test]
fn deepseek_v4_pro_alias_resolves_to_atlascloud_when_provider_hinted() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::Atlascloud));

assert_eq!(resolved.resolved.provider, ProviderKind::Atlascloud);
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
}

#[test]
fn openrouter_default_uses_namespaced_model_id() {
let registry = ModelRegistry::default();
Expand Down
7 changes: 6 additions & 1 deletion crates/tui/src/client/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2853,8 +2853,12 @@ mod stream_decoder_tests {
}

#[test]
fn request_builder_deduplicates_large_identical_tool_results_with_retrieval_hint() {
fn request_builder_deduplicates_medium_identical_tool_results_with_retrieval_hint() {
with_tool_result_sha_spillover_root(|| {
// 2,000 chars is intentionally above TOOL_RESULT_DEDUP_MIN_CHARS
// (1,024) but below TOOL_RESULT_SENT_CHAR_BUDGET (12,000). This
// verifies the cache-saving path for repeated medium outputs that
// do not otherwise need truncation.
let output = "A".repeat(2_000);
let messages = vec![
tool_use_message("tool-1", "read_file", json!({"path": "README.md"})),
Expand All @@ -2868,6 +2872,7 @@ mod stream_decoder_tests {
let second = tool_message_content(&built, 1);

assert_eq!(first, output);
assert!(!first.contains("[TOOL_RESULT_TRUNCATED]"), "got: {first}");
assert!(
second.starts_with("<TOOL_RESULT_REF sha=\""),
"got: {second}"
Expand Down
32 changes: 21 additions & 11 deletions crates/tui/src/commands/debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::time::Instant;
use super::CommandResult;
use crate::client::{PromptInspection, inspect_prompt_for_request};
use crate::compaction::estimate_input_tokens_conservative;
use crate::dependencies::{ExternalTool, Git};
use crate::localization::{Locale, MessageId, tr};
use crate::models::{ContentBlock, MessageRequest, SystemPrompt, context_window_for_model};
use crate::tui::app::{App, AppAction, TurnCacheRecord};
Expand Down Expand Up @@ -1857,15 +1858,18 @@ pub fn patch_undo(app: &mut App) -> CommandResult {
}

// Show diff stat so the user knows what changed.
let diff_stat = std::process::Command::new("git")
.args(["diff", "--stat"])
.current_dir(&workspace)
.output()
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
});
let diff_stat = Git::command()
.map(|mut git| {
git.args(["diff", "--stat"])
.current_dir(&workspace)
.output()
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
})
})
.unwrap_or(None);

let short = &target.id.as_str()[..target.id.as_str().len().min(8)];
let summary = match diff_stat {
Expand Down Expand Up @@ -1936,11 +1940,17 @@ pub fn edit(app: &mut App) -> CommandResult {
pub fn diff(app: &mut App) -> CommandResult {
let workspace = app.workspace.clone();

let name_only_output = std::process::Command::new("git")
let Some(mut name_only_cmd) = Git::command() else {
return CommandResult::error("git not found on PATH");
};
let Some(mut stat_cmd) = Git::command() else {
return CommandResult::error("git not found on PATH");
};
let name_only_output = name_only_cmd
.args(["diff", "--name-only"])
.current_dir(&workspace)
.output();
let stat_output = std::process::Command::new("git")
let stat_output = stat_cmd
.args(["diff", "--stat"])
.current_dir(&workspace)
.output();
Expand Down
16 changes: 11 additions & 5 deletions crates/tui/src/commands/share.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -155,20 +156,25 @@ fn write_temp_html(html: &str) -> Result<tempfile::NamedTempFile, String> {

/// Upload a file as a GitHub Gist using the `gh` CLI.
async fn upload_gist(path: &Path) -> Result<String, String> {
let output = tokio::process::Command::new("gh")
.args([
let path_owned = path.to_path_buf();
let output = tokio::task::spawn_blocking(move || {
let mut cmd = crate::dependencies::Gh::command()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "gh not found"))?;
cmd.args([
"gist",
"create",
"--public",
&path.to_string_lossy(),
&path_owned.to_string_lossy().to_string(),
"--filename",
"session-export.html",
"--desc",
"codewhale Session Export",
])
.output()
.await
.map_err(|e| format!("Failed to run `gh gist create`: {e}"))?;
})
.await
.map_err(|join_err| format!("gh gist create panicked: {join_err}"))?
.map_err(|e| format!("Failed to run `gh gist create`: {e}"))?;

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
Expand Down
40 changes: 40 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,19 @@ pub struct ToolsConfig {
/// default core catalog. Unknown names are harmless and simply never match.
#[serde(default)]
pub always_load: Vec<String>,

/// 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 `~/.codewhale/tools/` when `None`.
#[serde(default)]
pub plugin_dir: Option<String>,

/// Per-tool overrides keyed by built-in tool name.
/// Each override replaces or disables the named tool.
#[serde(default)]
pub overrides: Option<HashMap<String, ToolOverride>>,
}

/// One configurable footer item.
Expand Down Expand Up @@ -1301,6 +1314,33 @@ pub struct Config {
pub vision_model: Option<VisionModelConfig>,
}

/// 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 `~/.codewhale/tools/`).
path: String,
/// Optional static arguments prepended before the tool's JSON input.
#[serde(default)]
args: Option<Vec<String>>,
},
/// 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<Vec<String>>,
},
/// 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.
/// Uses an OpenAI-compatible vision model API.
#[derive(Debug, Clone, Deserialize)]
Expand Down
33 changes: 1 addition & 32 deletions crates/tui/src/config_ui.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
#[cfg(feature = "web")]
use std::net::SocketAddr;
#[cfg(feature = "web")]
use std::process::Command;
#[cfg(feature = "web")]
use std::time::Duration;

use anyhow::{Context, Result, bail};
Expand Down Expand Up @@ -605,36 +603,7 @@ pub fn parse_document(value: Value) -> Result<ConfigUiDocument> {

#[cfg(feature = "web")]
pub fn open_browser(url: &str) -> Result<()> {
#[cfg(target_os = "macos")]
let mut command = {
let mut command = Command::new("open");
command.arg(url);
command
};
#[cfg(target_os = "linux")]
let mut command = {
let mut command = Command::new("xdg-open");
command.arg(url);
command
};
#[cfg(target_os = "windows")]
let mut command = {
let mut command = Command::new("cmd");
command.args(["/C", "start", "", url]);
command
};
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
return Err(anyhow::anyhow!(
"browser opening is unsupported on this platform"
));

let status = command
.status()
.context("failed to launch browser command")?;
if !status.success() {
bail!("browser command exited with status {status}");
}
Ok(())
crate::utils::open_url(url)
}

fn validate_document(doc: &ConfigUiDocument) -> Result<()> {
Expand Down
Loading