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
491 changes: 232 additions & 259 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

193 changes: 191 additions & 2 deletions src-tauri/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -761,12 +761,201 @@ pub async fn write_file_async(path: String, content: Option<Vec<u8>>, source_pat
#[tauri::command]
pub fn get_file_metadata(path: String) -> Result<serde_json::Value, String> {
use std::fs;

let metadata = fs::metadata(&path).map_err(|e| e.to_string())?;

Ok(serde_json::json!({
"size": metadata.len(),
"isFile": metadata.is_file(),
"isDirectory": metadata.is_dir()
}))
}

#[tauri::command]
pub async fn check_claude_code_available() -> Result<serde_json::Value, String> {
use std::process::Command;

let result = tauri::async_runtime::spawn_blocking(|| {
let which_output = Command::new("which")
.arg("claude")
.output();

let cli_exists = match which_output {
Ok(output) => output.status.success(),
Err(_) => false,
};

if !cli_exists {
return serde_json::json!({
"available": false,
"version": null,
"authenticated": false
});
}

let version_output = Command::new("claude")
.arg("--version")
.output();

let version = match version_output {
Ok(output) if output.status.success() => {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
_ => None,
};

serde_json::json!({
"available": true,
"version": version,
"authenticated": true
})
}).await.map_err(|e| format!("Failed to check Claude Code availability: {}", e))?;

Ok(result)
}

/// Stream a response from Claude Code CLI
/// This spawns the claude CLI process and emits events as responses stream in
#[tauri::command]
pub async fn stream_claude_code_response(
app_handle: AppHandle,
request_id: String,
prompt: String,
system_prompt: Option<String>,
model: Option<String>,
disable_project_context: Option<bool>,
) -> Result<(), String> {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};

let mut cmd = Command::new("claude");
cmd.arg("-p") // Print mode (non-interactive)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--permission-mode")
.arg("bypassPermissions"); // Auto-approve for chat use

// If we want to disable project context, only load user settings
// but keep tools enabled so we can see tool calls in the UI
let disable_context = disable_project_context.unwrap_or(true);
if disable_context {
// Only load user settings, not project-specific ones
cmd.arg("--setting-sources").arg("user");
// Run from home directory to avoid picking up any project context
if let Ok(home) = std::env::var("HOME") {
cmd.current_dir(home);
}
}

if let Some(m) = model {
cmd.arg("--model").arg(m);
}

// Add system prompt - use append to keep Claude Code's base capabilities
// but add our general assistant instructions
if let Some(sp) = system_prompt {
if !sp.is_empty() {
cmd.arg("--append-system-prompt").arg(sp);
}
} else if disable_context {
// Default prompt for general assistant mode
cmd.arg("--append-system-prompt")
.arg("You are a helpful general assistant. Answer questions directly and helpfully. Do not reference any specific project or codebase context.");
}

cmd.arg(&prompt);

cmd.stdout(Stdio::piped())
.stderr(Stdio::piped());

let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn claude CLI: {}", e))?;

let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
let request_id_clone = request_id.clone();
let app_handle_clone = app_handle.clone();

// Spawn a thread to read stdout and emit final result only (no streaming to avoid freeze)
std::thread::spawn(move || {
let reader = BufReader::new(stdout);
let mut last_assistant_line: Option<String> = None;

// Read all lines, keep only the last assistant message
for line in reader.lines() {
if let Ok(json_line) = line {
if json_line.contains("\"type\":\"assistant\"") {
last_assistant_line = Some(json_line);
}
}
}

// Emit once at the end with the final content
if let Some(final_line) = last_assistant_line {
let _ = app_handle_clone.emit(
&format!("claude-code-stream-{}", request_id_clone),
serde_json::json!({
"type": "data",
"data": final_line
})
);
}
});

let request_id_clone2 = request_id.clone();
let app_handle_clone2 = app_handle.clone();

std::thread::spawn(move || {
let reader = BufReader::new(stderr);
let mut stderr_content = String::new();

for line in reader.lines() {
if let Ok(l) = line {
stderr_content.push_str(&l);
stderr_content.push('\n');
}
}

if !stderr_content.is_empty() {
let _ = app_handle_clone2.emit(
&format!("claude-code-stream-{}", request_id_clone2),
serde_json::json!({
"type": "stderr",
"data": stderr_content
})
);
}
});

// Wait for the process to complete in a regular thread (not spawn_blocking)
// to avoid potential thread pool issues
let request_id_clone3 = request_id.clone();
let app_handle_clone3 = app_handle.clone();

std::thread::spawn(move || {
match child.wait() {
Ok(status) => {
// Small delay to ensure stdout thread finishes emitting before done
std::thread::sleep(std::time::Duration::from_millis(50));
let _ = app_handle_clone3.emit(
&format!("claude-code-stream-{}", request_id_clone3),
serde_json::json!({
"type": "done",
"exitCode": status.code()
})
);
}
Err(e) => {
let _ = app_handle_clone3.emit(
&format!("claude-code-stream-{}", request_id_clone3),
serde_json::json!({
"type": "error",
"error": format!("Process error: {}", e)
})
);
}
}
});

Ok(())
}
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,8 @@ pub fn run() {
command::get_instance_name,
command::write_file_async,
command::get_file_metadata,
command::check_claude_code_available,
command::stream_claude_code_response,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
21 changes: 20 additions & 1 deletion src-tauri/src/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2554,5 +2554,24 @@ You have full access to bash commands on the user''''s computer. If you write a
UPDATE projects SET total_cost_usd = 0.0 WHERE total_cost_usd IS NULL;
"#,
},
Migration {
version: 139,
description: "add claude code provider models for using claude code subscription",
kind: MigrationKind::Up,
sql: r#"
-- Add Claude models via Claude Code CLI
-- These use the local Claude Code CLI and subscription instead of an API key
INSERT OR REPLACE INTO models (id, display_name, is_enabled, supported_attachment_types) VALUES
('claude-code::claude-sonnet-4-5-20250929', 'Claude Sonnet 4.5 (via Claude Code)', 1, '["text", "webpage", "image", "pdf"]'),
('claude-code::claude-opus-4-20250514', 'Claude Opus 4 (via Claude Code)', 1, '["text", "webpage", "image", "pdf"]'),
('claude-code::claude-sonnet-4-20250514', 'Claude Sonnet 4 (via Claude Code)', 1, '["text", "webpage", "image", "pdf"]');

-- Add model configs for each variant
INSERT OR REPLACE INTO model_configs (author, id, model_id, display_name, system_prompt, is_default, new_until) VALUES
('system', 'claude-code::claude-sonnet-4-5-20250929', 'claude-code::claude-sonnet-4-5-20250929', 'Claude Sonnet 4.5 (via Claude Code)', '', 0, '2026-01-15 00:00:00'),
('system', 'claude-code::claude-opus-4-20250514', 'claude-code::claude-opus-4-20250514', 'Claude Opus 4 (via Claude Code)', '', 0, '2026-01-15 00:00:00'),
('system', 'claude-code::claude-sonnet-4-20250514', 'claude-code::claude-sonnet-4-20250514', 'Claude Sonnet 4 (via Claude Code)', '', 0, '2026-01-15 00:00:00');
"#,
},
];
}
}
Loading