Skip to content
Open
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
14 changes: 7 additions & 7 deletions _docs/config/opencode-compatibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ Blank cells mean that runtime behavior is not supported by that project today. `
| `notifications` | | ✅ | crabcode-specific sounds, desktop notifications, and terminal alert signals such as Zed tab dots. |
| `mcp` | ✅ | ✅ | Uses the OpenCode MCP config shape. Enabled servers are connected at runtime and their tools are exposed as crabcode tools. |
| `permission` | ✅ | ✅ | Global tool permission rules are enforced during AI SDK tool execution. |
| `instructions` | ✅ | | Accepted at the top level, but config-driven instruction files are not loaded yet. |
| `tools` | ✅ | | Accepted at the top level, not used as global tool config yet. |
| `compaction` | ✅ | | Accepted at the top level, not used as config yet. |
| `watcher` | ✅ | | Accepted at the top level, not used as config yet. |
| `formatter` | ✅ | | Accepted at the top level, not used as config yet. |
| `disabled_providers` | ✅ | | Accepted at the top level, not applied yet. |
| `enabled_providers` | ✅ | | Accepted at the top level, not applied yet. |
| `instructions` | ✅ | | Loads the listed files relative to the project root (or from absolute and `~/` paths) and appends their contents to the system prompt. Unreadable files produce a config warning. |
| `tools` | ✅ | | Global tool enable/disable map. Disabled tools are removed before agent-specific tool policies are applied. |
| `compaction` | ✅ | | Accepted and parsed (`false` or `{ "auto", "prune" }`), but compaction behavior is not applied yet. |
| `watcher` | ✅ | | Controls file suggestions in command autocomplete. Use `false` to disable them or `{ "ignore": ["path"] }` to exclude paths. |
| `formatter` | ✅ | | Accepted and parsed by file extension, but configured formatter commands are not run yet. |
| `disabled_providers` | ✅ | | Removes the listed provider IDs from model discovery and selection. |
| `enabled_providers` | ✅ | | Restricts model discovery and selection to the listed provider IDs. `disabled_providers` takes precedence when both are set. |
| `keybinds` | ✅ | ❌ | Ignored because crabcode does not use OpenCode keybind config. |
| `share` | ✅ | ❌ | Ignored. |
| `tui` | ✅ | ❌ | Ignored. crabcode owns its terminal UI. |
Expand Down
9 changes: 7 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1017,8 +1017,13 @@ impl App {
})
.collect();
input.autocomplete = Some(
AutoComplete::new_at(crate::autocomplete::CommandAuto::new(&registry), &cwd_path)
.with_agents(agent_suggestions),
AutoComplete::new_at_with_file_config(
crate::autocomplete::CommandAuto::new(&registry),
&cwd_path,
loaded_config.merged_config.watcher.is_enabled(),
loaded_config.merged_config.watcher.ignored_paths().to_vec(),
)
.with_agents(agent_suggestions),
);

if let Some(default_agent) = loaded_config.merged_config.default_agent.clone() {
Expand Down
43 changes: 35 additions & 8 deletions src/autocomplete/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ impl FileAuto {
}

pub fn new_at(root: impl Into<PathBuf>) -> Self {
Self::new_at_with_config(root, true, Vec::new())
}

pub fn new_at_with_config(
root: impl Into<PathBuf>,
watcher_enabled: bool,
ignored_paths: Vec<String>,
) -> Self {
let root = root.into();
let (refresh_tx, refresh_rx) = mpsc::sync_channel(1);
let inner = Arc::new(FileAutoInner {
Expand All @@ -67,10 +75,19 @@ impl FileAuto {

if thread::Builder::new()
.name("crabcode-file-index".to_string())
.spawn(move || run_indexer(root, weak_inner, refresh_tx, refresh_rx))
.spawn(move || {
run_indexer(
root,
weak_inner,
refresh_tx,
refresh_rx,
watcher_enabled,
ignored_paths,
)
})
.is_err()
{
publish_entries(&fallback_inner, collect_entries(&fallback_root));
publish_entries(&fallback_inner, collect_entries(&fallback_root, &[]));
}

Self { inner }
Expand Down Expand Up @@ -173,16 +190,20 @@ fn run_indexer(
inner: Weak<FileAutoInner>,
refresh_tx: SyncSender<()>,
refresh_rx: Receiver<()>,
watcher_enabled: bool,
ignored_paths: Vec<String>,
) {
let watcher = create_watcher(&root, refresh_tx);
let watcher = watcher_enabled
.then(|| create_watcher(&root, refresh_tx))
.flatten();
let safety_refresh_interval = if watcher.is_some() {
WATCHED_SAFETY_REFRESH_INTERVAL
} else {
UNWATCHED_REFRESH_INTERVAL
};
let mut last_refresh = Instant::now();

if !refresh_index(&root, &inner) {
if !refresh_index(&root, &inner, &ignored_paths) {
return;
}

Expand All @@ -202,7 +223,7 @@ fn run_indexer(
}

if refresh_requested || last_refresh.elapsed() >= safety_refresh_interval {
if !refresh_index(&root, &inner) {
if !refresh_index(&root, &inner, &ignored_paths) {
break;
}
last_refresh = Instant::now();
Expand Down Expand Up @@ -263,8 +284,8 @@ fn event_requires_refresh(event: &Event) -> bool {
})
}

fn refresh_index(root: &Path, inner: &Weak<FileAutoInner>) -> bool {
let entries = collect_entries(root);
fn refresh_index(root: &Path, inner: &Weak<FileAutoInner>, ignored_paths: &[String]) -> bool {
let entries = collect_entries(root, ignored_paths);
let Some(inner) = inner.upgrade() else {
return false;
};
Expand All @@ -282,7 +303,7 @@ fn publish_entries(inner: &FileAutoInner, entries: Vec<FileEntry>) {
inner.state_changed.notify_all();
}

fn collect_entries(root: &Path) -> Vec<FileEntry> {
fn collect_entries(root: &Path, ignored_paths: &[String]) -> Vec<FileEntry> {
let mut builder = WalkBuilder::new(root);
builder
.hidden(false)
Expand Down Expand Up @@ -310,6 +331,12 @@ fn collect_entries(root: &Path) -> Vec<FileEntry> {
if display.is_empty() {
return None;
}
if ignored_paths.iter().any(|pattern| {
let pattern = pattern.trim_end_matches('/');
display == pattern || display.starts_with(&format!("{pattern}/"))
}) {
return None;
}
if is_directory && !display.ends_with('/') {
display.push('/');
}
Expand Down
11 changes: 10 additions & 1 deletion src/autocomplete/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,18 @@ impl AutoComplete {
}

pub fn new_at(command_auto: CommandAuto, root: impl Into<std::path::PathBuf>) -> Self {
Self::new_at_with_file_config(command_auto, root, true, Vec::new())
}

pub fn new_at_with_file_config(
command_auto: CommandAuto,
root: impl Into<std::path::PathBuf>,
watcher_enabled: bool,
ignored_paths: Vec<String>,
) -> Self {
Self {
command_auto,
file_auto: FileAuto::new_at(root),
file_auto: FileAuto::new_at_with_config(root, watcher_enabled, ignored_paths),
agents: Vec::new(),
mode: AutoCompleteMode::Command,
}
Expand Down
191 changes: 190 additions & 1 deletion src/config/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::tools::{
use anyhow::{anyhow, Context, Result};
use regex::Regex;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -373,6 +373,53 @@ impl McpServerConfig {

pub type McpConfig = BTreeMap<String, McpServerConfig>;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum CompactionConfig {
#[default]
Enabled,
Disabled,
Settings {
auto: bool,
prune: bool,
},
}

impl CompactionConfig {
pub fn is_enabled(&self) -> bool {
!matches!(self, Self::Disabled)
}
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum WatcherConfig {
#[default]
Enabled,
Disabled,
Settings {
ignore: Vec<String>,
},
}

impl WatcherConfig {
pub fn is_enabled(&self) -> bool {
!matches!(self, Self::Disabled)
}

pub fn ignored_paths(&self) -> &[String] {
match self {
Self::Settings { ignore } => ignore,
_ => &[],
}
}
}

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum FormatterConfig {
#[default]
Disabled,
Command(String),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderTimeout {
Millis(u64),
Expand All @@ -397,6 +444,23 @@ pub struct MergedConfig {
pub images: ImagesConfig,
pub websearch: WebsearchConfig,
pub mcp: McpConfig,
pub instructions: Vec<String>,
pub tools: HashMap<String, bool>,
pub compaction: CompactionConfig,
pub watcher: WatcherConfig,
pub formatter: HashMap<String, FormatterConfig>,
pub disabled_providers: HashSet<String>,
pub enabled_providers: Option<HashSet<String>>,
}

impl MergedConfig {
pub fn provider_is_enabled(&self, provider_id: &str) -> bool {
!self.disabled_providers.contains(provider_id)
&& self
.enabled_providers
.as_ref()
.is_none_or(|enabled| enabled.contains(provider_id))
}
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -532,6 +596,8 @@ impl ConfigLoader {
&mut diagnostics,
);
let mut merged_config = parse_merged_config(&merged, &mut diagnostics);
merged_config.instructions =
load_instruction_files(&merged_config.instructions, &project_root, &mut diagnostics);
let mut agent_definitions = crate::agent::definition::load_markdown_agent_definitions(
&inventory.opencode_agents,
&mut diagnostics.warnings,
Expand Down Expand Up @@ -1159,6 +1225,29 @@ fn trim_trailing_newlines(s: &str) -> String {
s.trim_end_matches(['\n', '\r']).to_string()
}

fn load_instruction_files(
paths: &[String],
project_root: &Path,
diagnostics: &mut ConfigDiagnostics,
) -> Vec<String> {
paths
.iter()
.filter_map(|configured_path| {
let path = expand_path(configured_path, project_root);
match fs::read_to_string(&path) {
Ok(contents) => Some(contents),
Err(error) => {
diagnostics.warnings.push(format!(
"Failed to read instruction file {}: {error}",
path.display()
));
None
}
}
})
.collect()
}

fn expand_path(arg: &str, base_dir: &Path) -> PathBuf {
let arg = arg.trim();
if let Some(rest) = arg.strip_prefix("~/") {
Expand Down Expand Up @@ -1229,6 +1318,76 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M
out.images = parse_images(obj.get("images"), diagnostics);
out.websearch = parse_websearch(obj.get("websearch"), diagnostics);
out.mcp = parse_mcp(obj.get("mcp"), diagnostics);
out.instructions = obj
.get("instructions")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|instruction| !instruction.is_empty())
.map(ToOwned::to_owned)
.collect();
out.tools = obj
.get("tools")
.and_then(Value::as_object)
.into_iter()
.flatten()
.filter_map(|(tool, enabled)| enabled.as_bool().map(|enabled| (tool.clone(), enabled)))
.collect();
out.compaction = match obj.get("compaction") {
Some(Value::Bool(false)) => CompactionConfig::Disabled,
Some(Value::Object(settings)) => CompactionConfig::Settings {
auto: settings
.get("auto")
.and_then(Value::as_bool)
.unwrap_or(true),
prune: settings
.get("prune")
.and_then(Value::as_bool)
.unwrap_or(false),
},
_ => CompactionConfig::Enabled,
};
out.watcher = match obj.get("watcher") {
Some(Value::Bool(false)) => WatcherConfig::Disabled,
Some(Value::Object(settings)) => WatcherConfig::Settings {
ignore: settings
.get("ignore")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
.map(ToOwned::to_owned)
.collect(),
},
_ => WatcherConfig::Enabled,
};
out.formatter = obj
.get("formatter")
.and_then(Value::as_object)
.into_iter()
.flatten()
.filter_map(|(extension, formatter)| match formatter {
Value::String(command) if !command.trim().is_empty() => Some((
extension.trim_start_matches('.').to_string(),
FormatterConfig::Command(command.trim().to_string()),
)),
Value::Bool(false) => Some((
extension.trim_start_matches('.').to_string(),
FormatterConfig::Disabled,
)),
_ => None,
})
.collect();
out.disabled_providers = parse_string_array(obj.get("disabled_providers"))
.into_iter()
.collect();
out.enabled_providers = obj
.get("enabled_providers")
.map(|value| parse_string_array(Some(value)).into_iter().collect());

out
}
Expand Down Expand Up @@ -2404,6 +2563,36 @@ mod tests {
use super::*;
use serde_json::json;

#[test]
fn parses_and_applies_top_level_runtime_configuration() {
let mut diagnostics = ConfigDiagnostics::default();
let config = parse_merged_config(
&json!({
"instructions": ["AGENTS.md"],
"tools": { "bash": false, "read": true },
"compaction": false,
"watcher": { "ignore": ["generated", "tmp/cache"] },
"formatter": { "rs": "rustfmt", ".md": false },
"disabled_providers": ["openai"],
"enabled_providers": ["anthropic", "google"]
}),
&mut diagnostics,
);

assert_eq!(config.instructions, vec!["AGENTS.md"]);
assert_eq!(config.tools.get("bash"), Some(&false));
assert!(!config.compaction.is_enabled());
assert_eq!(config.watcher.ignored_paths(), ["generated", "tmp/cache"]);
assert_eq!(
config.formatter.get("rs"),
Some(&FormatterConfig::Command("rustfmt".into()))
);
assert_eq!(config.formatter.get("md"), Some(&FormatterConfig::Disabled));
assert!(!config.provider_is_enabled("openai"));
assert!(config.provider_is_enabled("anthropic"));
assert!(!config.provider_is_enabled("mistral"));
}

#[test]
fn parses_small_model_aliases() {
let mut diagnostics = ConfigDiagnostics::default();
Expand Down
Loading
Loading