Skip to content
Merged
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CLAUDE.md
# Repository Guidance

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to AI agents when working with code in this repository.

## Essential Commands

Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,21 @@ The code-assistant uses two JSON configuration files to manage LLM providers and

**Full Examples**: See [`providers.example.json`](providers.example.json) and [`models.example.json`](models.example.json) for complete configuration examples with all supported providers (Anthropic, OpenAI, Ollama, SAP AI Core, Vertex AI, Groq, Cerebras, MistralAI, OpenRouter).

### Tool Configuration

Some tools require external API keys to function. Configure these in `~/.config/code-assistant/tools.json`:

```json
{
"perplexity_api_key": "${PERPLEXITY_API_KEY}"
}
```

**Available Tool Settings**:
- `perplexity_api_key` - Enables the `perplexity_ask` tool for AI-powered web search

Tools without their required configuration will not be available to the assistant.

**List Available Models**:
```bash
# See all configured models
Expand All @@ -217,7 +232,6 @@ Configure in Claude Desktop settings (**Developer** tab → **Edit Config**):
"command": "/path/to/code-assistant/target/release/code-assistant",
"args": ["server"],
"env": {
"PERPLEXITY_API_KEY": "pplx-...", // Optional, enables perplexity_ask tool
"SHELL": "/bin/zsh" // Your login shell
}
}
Expand Down
9 changes: 9 additions & 0 deletions crates/code_assistant/src/acp/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,11 +593,20 @@ impl acp::Agent for ACPAgentImpl {

let use_acp_fs = filesystem_supported && client_connection.is_some();

// Get the ACP root path for this session (the working directory opened in Zed)
let acp_root = {
let manager = session_manager.lock().await;
manager
.get_session(&arguments.session_id.0)
.and_then(|session| session.session.config.init_path.clone())
};

// Create project manager and command executor
let project_manager: Box<dyn ProjectManager> = if use_acp_fs {
Box::new(AcpProjectManager::new(
DefaultProjectManager::new(),
arguments.session_id.clone(),
acp_root,
))
} else {
Box::new(DefaultProjectManager::new())
Expand Down
46 changes: 40 additions & 6 deletions crates/code_assistant/src/acp/explorer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,18 +330,39 @@ impl CodeExplorer for AcpCodeExplorer {
pub struct AcpProjectManager {
inner: DefaultProjectManager,
session_id: acp::SessionId,
/// The root directory of the ACP session (i.e., the project opened in Zed).
/// Only projects with a path matching this root will use the ACP explorer.
acp_root: Option<PathBuf>,
}

impl AcpProjectManager {
pub fn new(inner: DefaultProjectManager, session_id: acp::SessionId) -> Self {
Self { inner, session_id }
pub fn new(
inner: DefaultProjectManager,
session_id: acp::SessionId,
acp_root: Option<PathBuf>,
) -> Self {
let acp_root = acp_root.and_then(|p| p.canonicalize().ok());
Self {
inner,
session_id,
acp_root,
}
}

fn maybe_project(&self, name: &str) -> Result<Project> {
self.inner
.get_project(name)?
.ok_or_else(|| anyhow!("Project not found: {name}"))
}

/// Returns true if the given project path matches the ACP session root.
fn is_acp_project(&self, project_path: &Path) -> bool {
let Some(acp_root) = &self.acp_root else {
return false;
};
let canonical = project_path.canonicalize().ok();
canonical.as_ref() == Some(acp_root)
}
}

impl ProjectManager for AcpProjectManager {
Expand All @@ -359,9 +380,22 @@ impl ProjectManager for AcpProjectManager {

fn get_explorer_for_project(&self, name: &str) -> Result<Box<dyn CodeExplorer>> {
let project = self.maybe_project(name)?;
Ok(Box::new(AcpCodeExplorer::new(
project.path,
self.session_id.clone(),
)))

// Use ACP explorer only for the project that matches the ACP session root.
// Other projects (e.g., referenced via settings) use the standard local explorer
// because Zed's ACP filesystem access is restricted to the current project.
if self.is_acp_project(&project.path) {
Ok(Box::new(AcpCodeExplorer::new(
project.path,
self.session_id.clone(),
)))
} else {
tracing::debug!(
"Using local explorer for project '{}' at {} (outside ACP root)",
name,
project.path.display()
);
Ok(Box::new(Explorer::new(project.path)))
}
}
}
4 changes: 3 additions & 1 deletion crates/code_assistant/src/acp/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub fn fragment_to_content_block(fragment: &DisplayFragment) -> acp::ContentBloc
meta: None,
})
}

// Tool-related fragments are not converted to content blocks
// They are handled separately as ToolCall updates
DisplayFragment::ToolName { .. }
Expand All @@ -40,7 +41,8 @@ pub fn fragment_to_content_block(fragment: &DisplayFragment) -> acp::ContentBloc
| DisplayFragment::ToolTerminal { .. }
| DisplayFragment::ReasoningSummaryStart
| DisplayFragment::ReasoningSummaryDelta(_)
| DisplayFragment::ReasoningComplete => {
| DisplayFragment::ReasoningComplete
| DisplayFragment::HiddenToolCompleted => {
// These should not be converted to content blocks
// Return empty text as placeholder
acp::ContentBlock::Text(acp::TextContent {
Expand Down
87 changes: 82 additions & 5 deletions crates/code_assistant/src/acp/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ use serde_json::{Map as JsonMap, Value as JsonValue};
use crate::acp::types::{fragment_to_content_block, map_tool_kind, map_tool_status};
use crate::ui::{DisplayFragment, UIError, UiEvent, UserInterface};

/// Tracks the last type of content for paragraph breaks after hidden tools
#[derive(Debug, Clone, Copy, PartialEq)]
enum LastContentType {
None,
Text,
Thinking,
}

/// UserInterface implementation that sends session/update notifications via ACP
pub struct ACPUserUI {
session_id: acp::SessionId,
Expand All @@ -21,6 +29,10 @@ pub struct ACPUserUI {
// Track if we should continue streaming (atomic for lock-free access from sync callbacks)
should_continue: Arc<AtomicBool>,
last_error: Arc<Mutex<Option<String>>>,
// Track last content type for paragraph breaks after hidden tools
last_content_type: Arc<Mutex<LastContentType>>,
// Flag indicating a hidden tool completed and we may need a paragraph break
needs_paragraph_break_after_hidden_tool: Arc<Mutex<bool>>,
}

#[derive(Default, Clone)]
Expand Down Expand Up @@ -427,6 +439,8 @@ impl ACPUserUI {
base_path,
should_continue: Arc::new(AtomicBool::new(true)),
last_error: Arc::new(Mutex::new(None)),
last_content_type: Arc::new(Mutex::new(LastContentType::None)),
needs_paragraph_break_after_hidden_tool: Arc::new(Mutex::new(false)),
}
}

Expand Down Expand Up @@ -520,6 +534,38 @@ impl ACPUserUI {
}
}

/// Check if we need a paragraph break after a hidden tool and emit it if so
fn maybe_emit_paragraph_break(&self, current_type: LastContentType) -> Result<(), UIError> {
let mut needs_break = self.needs_paragraph_break_after_hidden_tool.lock().unwrap();
if !*needs_break {
return Ok(());
}

// Reset the flag
*needs_break = false;

// Check if the content type matches the last one
let last_type = *self.last_content_type.lock().unwrap();
if last_type == current_type {
// Same type as before the hidden tool - emit paragraph break
let content = acp::ContentBlock::Text(acp::TextContent {
annotations: None,
text: "\n\n".to_string(),
meta: None,
});
let chunk = Self::content_chunk(content);

// Use the appropriate update type based on content type
let update = match current_type {
LastContentType::Thinking => acp::SessionUpdate::AgentThoughtChunk(chunk),
_ => acp::SessionUpdate::AgentMessageChunk(chunk),
};
self.queue_session_update(update);
}

Ok(())
}

pub fn tool_call_update(&self, tool_id: &str) -> Option<acp::ToolCallUpdate> {
let base_path = self.base_path.as_deref();
let tool_calls = self.tool_calls.lock().unwrap();
Expand Down Expand Up @@ -709,7 +755,8 @@ impl UserInterface for ACPUserUI {
| UiEvent::ClearError
| UiEvent::UpdateCurrentModel { .. }
| UiEvent::UpdateSandboxPolicy { .. }
| UiEvent::CancelSubAgent { .. } => {
| UiEvent::CancelSubAgent { .. }
| UiEvent::HiddenToolCompleted => {
// These are UI management events, not relevant for ACP
}
UiEvent::DisplayError { message } => {
Expand All @@ -724,7 +771,22 @@ impl UserInterface for ACPUserUI {

fn display_fragment(&self, fragment: &DisplayFragment) -> Result<(), UIError> {
match fragment {
DisplayFragment::PlainText(_) | DisplayFragment::Image { .. } => {
DisplayFragment::PlainText(text) => {
// Check if we need a paragraph break after a hidden tool
self.maybe_emit_paragraph_break(LastContentType::Text)?;

// Track content type for future hidden tool events
*self.last_content_type.lock().unwrap() = LastContentType::Text;

let content = acp::ContentBlock::Text(acp::TextContent {
annotations: None,
text: text.clone(),
meta: None,
});
let chunk = Self::content_chunk(content);
self.queue_session_update(acp::SessionUpdate::AgentMessageChunk(chunk));
}
DisplayFragment::Image { .. } => {
let content = fragment_to_content_block(fragment);
let chunk = Self::content_chunk(content);
self.queue_session_update(acp::SessionUpdate::AgentMessageChunk(chunk));
Expand All @@ -734,8 +796,18 @@ impl UserInterface for ACPUserUI {
let chunk = Self::content_chunk(content);
self.queue_session_update(acp::SessionUpdate::AgentMessageChunk(chunk));
}
DisplayFragment::ThinkingText(_) => {
let content = fragment_to_content_block(fragment);
DisplayFragment::ThinkingText(text) => {
// Check if we need a paragraph break after a hidden tool
self.maybe_emit_paragraph_break(LastContentType::Thinking)?;

// Track content type for future hidden tool events
*self.last_content_type.lock().unwrap() = LastContentType::Thinking;

let content = acp::ContentBlock::Text(acp::TextContent {
annotations: None,
text: text.clone(),
meta: None,
});
let chunk = Self::content_chunk(content);
self.queue_session_update(acp::SessionUpdate::AgentThoughtChunk(chunk));
}
Expand Down Expand Up @@ -837,8 +909,13 @@ impl UserInterface for ACPUserUI {

self.queue_session_update(acp::SessionUpdate::ToolCallUpdate(tool_call_update));
}

DisplayFragment::ReasoningSummaryStart | DisplayFragment::ReasoningComplete => {
// No ACP representation needed yet
// No ACP representation needed
}
DisplayFragment::HiddenToolCompleted => {
// Mark that a hidden tool completed - paragraph break may be needed before next text
*self.needs_paragraph_break_after_hidden_tool.lock().unwrap() = true;
}
DisplayFragment::ReasoningSummaryDelta(delta) => {
// Reasoning summaries are emitted as AgentThoughtChunk, same as ThinkingText
Expand Down
8 changes: 4 additions & 4 deletions crates/code_assistant/src/agent/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ use llm::{
};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use tracing::{debug, trace, warn};
Expand Down Expand Up @@ -1024,9 +1023,10 @@ impl Agent {
/// Attempt to read AGENTS.md or CLAUDE.md from the initial project root.
/// Prefers AGENTS.md when both exist. Returns (file_name, content) on success.
fn read_repository_guidance(&self) -> Option<(String, String)> {
// Determine search root
let root_path = if !self.session_config.initial_project.is_empty() {
PathBuf::from(&self.session_config.initial_project)
// Determine search root from init_path (the actual directory path),
// not initial_project (which is just the project name)
let root_path = if let Some(path) = &self.session_config.init_path {
path.clone()
} else {
std::env::current_dir().ok()?
};
Expand Down
Loading