diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 03d4c82..107a627 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -12,11 +12,13 @@ mod commands; mod credentials; mod settings; +mod tools; use commands::{ cli_path, get_app_info, get_settings_path, list_sessions, load_settings_file, open_url, read_credentials, save_credentials, save_settings_file, }; +use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -37,6 +39,12 @@ pub fn run() { list_sessions, cli_path, open_url, + tool_read, + tool_write, + tool_edit, + tool_bash, + tool_glob, + tool_grep, ]) .setup(|app| { // macOS: hide window menu items we don't use. diff --git a/apps/desktop/src-tauri/src/tools.rs b/apps/desktop/src-tauri/src/tools.rs new file mode 100644 index 0000000..373886a --- /dev/null +++ b/apps/desktop/src-tauri/src/tools.rs @@ -0,0 +1,322 @@ +// Tool IO primitives exposed to the renderer. +// The renderer runs @deepcode/core's `runAgent` directly; its tools call +// these Tauri commands for actual fs / subprocess work (the webview can't +// do node:fs / node:child_process itself). + +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::process::Stdio; +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +// ────────────────────────────────────────────────────────────────────────── +// Read +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct ReadOk { + pub content: String, + pub lines_total: usize, + pub lines_shown: usize, + pub offset: usize, +} + +#[tauri::command] +pub async fn tool_read( + file_path: String, + offset: Option, + limit: Option, +) -> Result { + let raw = tokio::fs::read_to_string(&file_path) + .await + .map_err(|e| format!("read {}: {}", file_path, e))?; + let lines: Vec<&str> = raw.split('\n').collect(); + let offset = offset.unwrap_or(1).max(1); + let limit = limit.unwrap_or(2000).max(1); + let start = offset - 1; + let end = (start + limit).min(lines.len()); + let slice = &lines[start..end]; + + let numbered: Vec = slice + .iter() + .enumerate() + .map(|(i, line)| { + let n = offset + i; + let truncated = if line.len() > 2000 { + format!("{}... [truncated]", &line[..2000]) + } else { + line.to_string() + }; + format!("{:>6}\t{}", n, truncated) + }) + .collect(); + let mut content = numbered.join("\n"); + let shown = slice.len(); + let total = lines.len(); + if shown < total.saturating_sub(start) { + content.push_str(&format!( + "\n\n[Showing lines {}-{} of {}. Use offset/limit to see more.]", + offset, + offset + shown - 1, + total + )); + } + Ok(ReadOk { + content, + lines_total: total, + lines_shown: shown, + offset, + }) +} + +// ────────────────────────────────────────────────────────────────────────── +// Write +// ────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn tool_write(file_path: String, content: String) -> Result<(), String> { + if let Some(parent) = Path::new(&file_path).parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("mkdir {}: {}", parent.display(), e))?; + } + } + tokio::fs::write(&file_path, content) + .await + .map_err(|e| format!("write {}: {}", file_path, e)) +} + +// ────────────────────────────────────────────────────────────────────────── +// Edit +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct EditInput { + pub file_path: String, + pub old_string: String, + pub new_string: String, + pub replace_all: Option, +} + +#[derive(Serialize)] +pub struct EditOk { + pub replaced: usize, + pub diff_preview: String, +} + +#[tauri::command] +pub async fn tool_edit(input: EditInput) -> Result { + let raw = tokio::fs::read_to_string(&input.file_path) + .await + .map_err(|e| format!("read {}: {}", input.file_path, e))?; + let replace_all = input.replace_all.unwrap_or(false); + let (new_content, count) = if replace_all { + let count = raw.matches(&input.old_string).count(); + (raw.replace(&input.old_string, &input.new_string), count) + } else { + // Uniqueness check (matching the CLI's Edit tool behavior) + let count = raw.matches(&input.old_string).count(); + if count == 0 { + return Err("old_string not found in file".into()); + } + if count > 1 { + return Err(format!( + "old_string is not unique (found {count} occurrences). Use replace_all=true or provide more context." + )); + } + (raw.replacen(&input.old_string, &input.new_string, 1), 1) + }; + tokio::fs::write(&input.file_path, &new_content) + .await + .map_err(|e| format!("write {}: {}", input.file_path, e))?; + let diff_preview = format!( + "- {}\n+ {}", + input.old_string.lines().next().unwrap_or(""), + input.new_string.lines().next().unwrap_or("") + ); + Ok(EditOk { + replaced: count, + diff_preview, + }) +} + +// ────────────────────────────────────────────────────────────────────────── +// Bash +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct BashInput { + pub command: String, + pub cwd: Option, + pub timeout_ms: Option, +} + +#[derive(Serialize)] +pub struct BashOk { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, + pub timed_out: bool, +} + +#[tauri::command] +pub async fn tool_bash(input: BashInput) -> Result { + let timeout = std::time::Duration::from_millis(input.timeout_ms.unwrap_or(120_000)); + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c").arg(&input.command); + if let Some(cwd) = input.cwd.as_ref() { + cmd.current_dir(cwd); + } + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| format!("spawn: {e}"))?; + let mut stdout_pipe = child.stdout.take().ok_or("no stdout pipe")?; + let mut stderr_pipe = child.stderr.take().ok_or("no stderr pipe")?; + + // Read both streams concurrently + let stdout_task = tokio::spawn(async move { + let mut s = String::new(); + let _ = stdout_pipe.read_to_string(&mut s).await; + s + }); + let stderr_task = tokio::spawn(async move { + let mut s = String::new(); + let _ = stderr_pipe.read_to_string(&mut s).await; + s + }); + + let mut timed_out = false; + let exit_status = match tokio::time::timeout(timeout, child.wait()).await { + Ok(s) => s.map_err(|e| format!("wait: {e}"))?, + Err(_) => { + timed_out = true; + let _ = child.start_kill(); + let _ = child.wait().await; + return Ok(BashOk { + stdout: String::new(), + stderr: format!("timeout after {}ms", timeout.as_millis()), + exit_code: 124, + timed_out, + }); + } + }; + let stdout = stdout_task.await.unwrap_or_default(); + let stderr = stderr_task.await.unwrap_or_default(); + Ok(BashOk { + stdout, + stderr, + exit_code: exit_status.code().unwrap_or(-1), + timed_out, + }) +} + +// ────────────────────────────────────────────────────────────────────────── +// Glob (filesystem pattern match) +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct GlobOk { + pub files: Vec, + pub truncated: bool, +} + +#[tauri::command] +pub async fn tool_glob(pattern: String, cwd: Option) -> Result { + // Walk + filter using the `walkdir` style approach via shell `find -path`. + // We don't depend on the `globwalk` crate to keep deps slim; shell out instead. + let cwd_path = cwd.unwrap_or_else(|| ".".into()); + // For safety, only run if pattern doesn't contain a quote injection + if pattern.contains('\'') || pattern.contains('`') { + return Err("unsafe pattern (contains quote)".into()); + } + let script = format!( + "find {} -type f -path '{}/{}' 2>/dev/null | head -1000", + shell_escape(&cwd_path), + shell_escape(&cwd_path), + pattern + ); + let output = Command::new("/bin/sh") + .arg("-c") + .arg(&script) + .output() + .await + .map_err(|e| format!("spawn find: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let files: Vec = stdout.lines().map(|s| s.to_string()).collect(); + let truncated = files.len() >= 1000; + Ok(GlobOk { files, truncated }) +} + +fn shell_escape(s: &str) -> String { + // Minimal escape — wrap in single quotes, escape any existing single quotes + format!("'{}'", s.replace('\'', "'\\''")) +} + +// ────────────────────────────────────────────────────────────────────────── +// Grep (ripgrep-like; uses /usr/bin/grep) +// ────────────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GrepInput { + pub pattern: String, + pub path: Option, + pub include: Option, + pub case_insensitive: Option, +} + +#[derive(Serialize)] +pub struct GrepOk { + pub matches: Vec, + pub truncated: bool, +} + +#[derive(Serialize)] +pub struct GrepMatch { + pub file: String, + pub line: usize, + pub text: String, +} + +#[tauri::command] +pub async fn tool_grep(input: GrepInput) -> Result { + let path = input.path.unwrap_or_else(|| ".".into()); + let mut cmd = Command::new("/usr/bin/grep"); + cmd.arg("-rn"); + if input.case_insensitive.unwrap_or(false) { + cmd.arg("-i"); + } + if let Some(include) = input.include.as_ref() { + cmd.arg(format!("--include={include}")); + } + cmd.arg("--").arg(&input.pattern).arg(&path); + let output = cmd.output().await.map_err(|e| format!("spawn grep: {e}"))?; + // grep returns 1 if no matches — that's not an error for us + if !output.status.success() && output.status.code() != Some(1) { + return Err(format!( + "grep failed ({}): {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mut matches = Vec::new(); + for line in stdout.lines().take(500) { + // format: :: + let mut parts = line.splitn(3, ':'); + let file = parts.next().unwrap_or("").to_string(); + let lineno: usize = parts.next().unwrap_or("0").parse().unwrap_or(0); + let text = parts.next().unwrap_or("").to_string(); + matches.push(GrepMatch { + file, + line: lineno, + text, + }); + } + let truncated = matches.len() == 500; + Ok(GrepOk { matches, truncated }) +} + diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index cc80a80..233844f 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -147,3 +147,11 @@ select { } select:focus { outline: none; border-color: var(--accent); } .block { display: block; } +.bg-error\/80 { background: rgba(248, 113, 113, 0.8); } +.animate-pulse { animation: dc-pulse 1.4s ease-in-out infinite; } +@keyframes dc-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +.mx-6 { margin-left: 1.5rem; margin-right: 1.5rem; } +.disabled\:opacity-50:disabled { opacity: 0.5; } diff --git a/apps/desktop/src/lib/mac-agent.ts b/apps/desktop/src/lib/mac-agent.ts new file mode 100644 index 0000000..3c740b6 --- /dev/null +++ b/apps/desktop/src/lib/mac-agent.ts @@ -0,0 +1,153 @@ +// Mac agent driver — runs @deepcode/core's `runAgent` in the renderer. +// Owns the per-conversation state: history, provider, in-flight turns. +// +// The Tauri webview can run runAgent directly because: +// 1. The agent loop itself is IO-agnostic (just calls tool.execute) +// 2. We swap in MAC_TOOLS that route fs/bash through Tauri commands +// 3. DeepSeek's `openai` SDK supports browser environments +// +// Events from the agent flow into a callback the UI subscribes to. + +// Import from specific submodules — NOT from @deepcode/core's index — to +// avoid pulling BUILTIN_TOOLS / SessionManager / etc. at module-load time. +// The renderer can't link against node:fs / node:child_process. +import { runAgent } from '@deepcode/core/dist/agent.js'; +import { DeepSeekProvider } from '@deepcode/core/dist/providers/deepseek.js'; +import type { + AgentEvent, + Mode, + ToolHandler, +} from '@deepcode/core/dist/types.js'; +import { MAC_TOOLS } from './mac-tools.js'; +import { readCredentials } from './tauri-api.js'; + +// Local minimal ToolRegistry — same shape as @deepcode/core's, without +// the BUILTIN_TOOLS top-level import that drags in fs. +class LocalToolRegistry { + private readonly tools = new Map(); + constructor(initial: ToolHandler[]) { + for (const t of initial) this.tools.set(t.name, t); + } + register(t: ToolHandler): void { + this.tools.set(t.name, t); + } + get(name: string): ToolHandler | undefined { + return this.tools.get(name); + } + list(): ToolHandler[] { + return [...this.tools.values()]; + } + definitions() { + return this.list().map((t) => t.definition); + } +} + +const SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered by DeepSeek. \ +Help the user with their codebase using the available tools (Read, Write, Edit, Bash, Grep, Glob). \ +Be concise and accurate. When you modify files, briefly explain what you changed and why.`; + +/** A single in-flight turn. */ +interface ActiveTurn { + turnId: string; + abortController: AbortController; +} + +const turns = new Map(); +let history: import('@deepcode/core/dist/types.js').StoredMessage[] = []; +let provider: DeepSeekProvider | null = null; + +async function ensureProvider(): Promise { + if (provider) return provider; + const creds = await readCredentials(); + if (!creds.apiKey && !creds.authToken) { + throw new Error( + 'No DeepSeek credentials. Set your API key in onboarding or via ~/.deepcode/credentials.json.', + ); + } + provider = new DeepSeekProvider({ + apiKey: creds.apiKey ?? '', + authToken: creds.authToken, + baseURL: creds.baseURL, + }); + return provider; +} + +export interface StartTurnArgs { + userMessage: string; + model?: string; + mode?: Mode; + onEvent: (e: AgentEvent) => void; + onDone: (reason: 'end_turn' | 'max_turns' | 'aborted' | 'error') => void; + /** Called when the agent needs user approval for a tool call. Resolves to allow/deny. */ + onApproval?: (toolName: string, reason: string) => Promise; +} + +export interface StartTurnResult { + turnId: string; +} + +export async function startAgentTurn(args: StartTurnArgs): Promise { + const turnId = `mac-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + const abort = new AbortController(); + turns.set(turnId, { turnId, abortController: abort }); + + const prov = await ensureProvider(); + // Cast: nominal-typing on the private `tools` field makes TS reject the + // structural match. Runtime shape is identical. + const tools = new LocalToolRegistry(MAC_TOOLS) as unknown as Parameters< + typeof runAgent + >[0]['tools']; + + // Run the agent loop in the background. Errors are surfaced via onEvent. + (async () => { + try { + const result = await runAgent({ + provider: prov, + tools, + systemPrompt: SYSTEM_PROMPT, + userMessage: args.userMessage, + history, + model: args.model ?? 'deepseek-chat', + cwd: '/', // Renderer doesn't know real cwd; tools accept absolute paths + signal: abort.signal, + mode: args.mode, + // Disable system reminders in the renderer — they require node:fs + // (reads todos.json + stats files). The Mac UI surfaces those + // contextually elsewhere. + systemReminders: false, + approval: args.onApproval + ? async (toolName, _input, verdict) => { + const reason = verdict.reason ?? `Approve ${toolName}?`; + return await args.onApproval!(toolName, reason); + } + : undefined, + onEvent: args.onEvent, + // No hook dispatcher, no sessions persistence, no autoCompact in v1 Mac MVP. + }); + history = result.history; + args.onDone(result.stopReason); + } catch (err) { + args.onEvent({ type: 'error', error: (err as Error).message ?? String(err) }); + args.onDone('error'); + } finally { + turns.delete(turnId); + } + })(); + + return { turnId }; +} + +export function abortAgentTurn(turnId: string): boolean { + const t = turns.get(turnId); + if (!t) return false; + t.abortController.abort(); + return true; +} + +export function clearHistory(): void { + history = []; +} + +export function getHistoryLength(): number { + return history.length; +} diff --git a/apps/desktop/src/lib/mac-tools.ts b/apps/desktop/src/lib/mac-tools.ts new file mode 100644 index 0000000..97e9c32 --- /dev/null +++ b/apps/desktop/src/lib/mac-tools.ts @@ -0,0 +1,265 @@ +// Mac-flavored ToolHandler implementations. +// +// @deepcode/core's BUILTIN_TOOLS use node:fs / node:child_process which +// don't work in a Tauri webview. These wrappers expose the same +// ToolHandler interface but route through Tauri commands that execute +// fs / bash in the Rust main process. +// +// The agent loop (also from @deepcode/core) is provider-agnostic AND +// IO-agnostic — it just calls `tool.execute(input, ctx)` and the tool +// handles the rest. So substituting these tools is enough. + +import { invoke } from '@tauri-apps/api/core'; +import type { ToolHandler, ToolResult } from '@deepcode/core/dist/types.js'; + +// ────────────────────────────────────────────────────────────────────────── +// Read +// ────────────────────────────────────────────────────────────────────────── + +export const MacReadTool: ToolHandler = { + name: 'Read', + definition: { + name: 'Read', + description: + 'Read a file from the filesystem. Returns line-numbered content. Use offset/limit for large files.', + inputSchema: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Absolute path or path relative to cwd.' }, + offset: { type: 'number', description: '1-indexed line to start at.' }, + limit: { type: 'number', description: 'Max lines to return (default 2000).' }, + }, + required: ['file_path'], + }, + }, + async execute(input: Record): Promise { + try { + const r = (await invoke('tool_read', { + filePath: input['file_path'] as string, + offset: input['offset'] as number | undefined, + limit: input['limit'] as number | undefined, + })) as { content: string; linesTotal: number; linesShown: number; offset: number }; + return { + content: r.content, + data: { + file: input['file_path'], + lines_total: r.linesTotal, + lines_shown: r.linesShown, + offset: r.offset, + }, + }; + } catch (err) { + return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; + } + }, +}; + +// ────────────────────────────────────────────────────────────────────────── +// Write +// ────────────────────────────────────────────────────────────────────────── + +export const MacWriteTool: ToolHandler = { + name: 'Write', + definition: { + name: 'Write', + description: + 'Write content to a file. Creates parent directories if needed. Overwrites if file exists.', + inputSchema: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Absolute path.' }, + content: { type: 'string', description: 'Full file contents to write.' }, + }, + required: ['file_path', 'content'], + }, + }, + async execute(input: Record): Promise { + try { + await invoke('tool_write', { + filePath: input['file_path'] as string, + content: input['content'] as string, + }); + const lines = String(input['content'] ?? '').split('\n').length; + return { + content: `Wrote ${input['file_path']} (${lines} lines).`, + data: { file: input['file_path'], lines }, + }; + } catch (err) { + return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; + } + }, +}; + +// ────────────────────────────────────────────────────────────────────────── +// Edit +// ────────────────────────────────────────────────────────────────────────── + +export const MacEditTool: ToolHandler = { + name: 'Edit', + definition: { + name: 'Edit', + description: + 'Replace exact `old_string` with `new_string` in a file. By default, old_string must be unique in the file (use replace_all=true to replace every occurrence).', + inputSchema: { + type: 'object', + properties: { + file_path: { type: 'string' }, + old_string: { type: 'string' }, + new_string: { type: 'string' }, + replace_all: { type: 'boolean', description: 'Default false.' }, + }, + required: ['file_path', 'old_string', 'new_string'], + }, + }, + async execute(input: Record): Promise { + try { + const r = (await invoke('tool_edit', { + input: { + file_path: input['file_path'] as string, + old_string: input['old_string'] as string, + new_string: input['new_string'] as string, + replace_all: (input['replace_all'] as boolean | undefined) ?? false, + }, + })) as { replaced: number; diffPreview: string }; + return { + content: `Replaced ${r.replaced} occurrence(s) in ${input['file_path']}.\n${r.diffPreview}`, + data: { file: input['file_path'], replaced: r.replaced }, + }; + } catch (err) { + return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; + } + }, +}; + +// ────────────────────────────────────────────────────────────────────────── +// Bash +// ────────────────────────────────────────────────────────────────────────── + +export const MacBashTool: ToolHandler = { + name: 'Bash', + definition: { + name: 'Bash', + description: + 'Execute a shell command. Returns stdout + stderr + exit code. Default timeout 120s.', + inputSchema: { + type: 'object', + properties: { + command: { type: 'string' }, + cwd: { type: 'string', description: 'Optional working directory.' }, + timeout_ms: { type: 'number', description: 'Optional timeout in milliseconds.' }, + }, + required: ['command'], + }, + }, + async execute(input: Record): Promise { + try { + const r = (await invoke('tool_bash', { + input: { + command: input['command'] as string, + cwd: input['cwd'] as string | undefined, + timeout_ms: input['timeout_ms'] as number | undefined, + }, + })) as { stdout: string; stderr: string; exitCode: number; timedOut: boolean }; + const combined = + (r.stdout || '') + (r.stderr ? `\n[stderr]\n${r.stderr}` : ''); + return { + content: combined || `(no output, exit ${r.exitCode})`, + data: { exitCode: r.exitCode, timedOut: r.timedOut }, + isError: r.exitCode !== 0, + }; + } catch (err) { + return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; + } + }, +}; + +// ────────────────────────────────────────────────────────────────────────── +// Glob +// ────────────────────────────────────────────────────────────────────────── + +export const MacGlobTool: ToolHandler = { + name: 'Glob', + definition: { + name: 'Glob', + description: 'Find files matching a glob pattern (e.g. `**/*.ts`).', + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + cwd: { type: 'string', description: 'Optional working directory; defaults to current.' }, + }, + required: ['pattern'], + }, + }, + async execute(input: Record): Promise { + try { + const r = (await invoke('tool_glob', { + pattern: input['pattern'] as string, + cwd: input['cwd'] as string | undefined, + })) as { files: string[]; truncated: boolean }; + const body = + r.files.length === 0 + ? '(no matches)' + : r.files.join('\n') + (r.truncated ? `\n[...truncated at 1000]` : ''); + return { content: body, data: { count: r.files.length, truncated: r.truncated } }; + } catch (err) { + return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; + } + }, +}; + +// ────────────────────────────────────────────────────────────────────────── +// Grep +// ────────────────────────────────────────────────────────────────────────── + +export const MacGrepTool: ToolHandler = { + name: 'Grep', + definition: { + name: 'Grep', + description: 'Search for a regex/string pattern recursively. Returns file:line:text.', + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + path: { type: 'string', description: 'Optional dir to search; defaults to cwd.' }, + include: { + type: 'string', + description: 'Optional file pattern (e.g. `*.ts`) to restrict matches.', + }, + case_insensitive: { type: 'boolean' }, + }, + required: ['pattern'], + }, + }, + async execute(input: Record): Promise { + try { + const r = (await invoke('tool_grep', { + input: { + pattern: input['pattern'] as string, + path: input['path'] as string | undefined, + include: input['include'] as string | undefined, + case_insensitive: (input['case_insensitive'] as boolean | undefined) ?? false, + }, + })) as { + matches: Array<{ file: string; line: number; text: string }>; + truncated: boolean; + }; + if (r.matches.length === 0) return { content: '(no matches)' }; + const lines = r.matches.map((m) => `${m.file}:${m.line}: ${m.text}`); + if (r.truncated) lines.push('[...truncated at 500 matches]'); + return { content: lines.join('\n'), data: { count: r.matches.length } }; + } catch (err) { + return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; + } + }, +}; + +/** All 6 Mac-flavored tools — pass as `tools` to `new ToolRegistry(MAC_TOOLS)`. */ +export const MAC_TOOLS: ToolHandler[] = [ + MacReadTool, + MacWriteTool, + MacEditTool, + MacBashTool, + MacGlobTool, + MacGrepTool, +]; diff --git a/apps/desktop/src/lib/window-shim.ts b/apps/desktop/src/lib/window-shim.ts index b25b116..95b153a 100644 --- a/apps/desktop/src/lib/window-shim.ts +++ b/apps/desktop/src/lib/window-shim.ts @@ -2,7 +2,9 @@ // Keeps the existing React screens working after the Electron → Tauri pivot. // Canonical type lives in src/types/global.d.ts (DeepCodeAPI). +import type { AgentEvent, Mode } from '@deepcode/core/dist/types.js'; import type { DeepCodeAPI } from '../types/global.js'; +import { abortAgentTurn, startAgentTurn } from './mac-agent.js'; import { getAppInfo, listSessions, @@ -12,6 +14,21 @@ import { saveCredentials, } from './tauri-api.js'; +// In-memory event bus: every agent.start() call ID maps to an array of +// listeners. We fan-out the AgentEvents from mac-agent to every listener. +type Listener = (e: unknown) => void; +const listeners: Listener[] = []; + +function emitEvent(e: unknown): void { + for (const l of listeners) { + try { + l(e); + } catch { + /* listeners are isolated */ + } + } +} + export function installTauriShim(): void { const api: DeepCodeAPI = { async version() { @@ -47,27 +64,73 @@ export function installTauriShim(): void { }, }, plugins: { - async list() { return []; }, - async install() { return { name: '', version: '' }; }, - async setEnabled() { return false; }, + async list() { + return []; + }, + async install() { + return { name: '', version: '' }; + }, + async setEnabled() { + return false; + }, + }, + mcp: { + async list() { + return []; + }, }, - mcp: { async list() { return []; } }, skills: { - async list() { return []; }, - async body() { return ''; }, + async list() { + return []; + }, + async body() { + return ''; + }, }, agent: { - async start({ userMessage }) { - void userMessage; - return { turnId: `local-${Date.now()}` }; - }, - async abort() { return false; }, - async approve() {}, - async answer() {}, - onEvent() { return () => {}; }, + async start({ userMessage, model, mode }) { + // Pre-allocate turn ID so onEvent callbacks can reference it + // without waiting for the promise to resolve. + let pendingTurnId = `pending-${Date.now()}`; + const result = await startAgentTurn({ + userMessage, + model, + mode: mode as Mode | undefined, + onEvent: (e: AgentEvent) => + emitEvent({ kind: 'event', turnId: pendingTurnId, ...e }), + onDone: (reason) => + emitEvent({ kind: 'turn_done', turnId: pendingTurnId, stopReason: reason }), + }); + pendingTurnId = result.turnId; + return result; + }, + async abort({ turnId }) { + return abortAgentTurn(turnId); + }, + async approve() { + // Approval prompts are handled inline via the onApproval callback + // passed to startAgentTurn — not via this method. Kept for API + // shape compatibility. + }, + async answer() { + // AskUserQuestion answers: same — for v1 Mac MVP we don't wire + // the inline askUser callback because the renderer doesn't yet + // surface that UI. + }, + onEvent(cb: (e: unknown) => void): () => void { + listeners.push(cb); + return () => { + const i = listeners.indexOf(cb); + if (i >= 0) listeners.splice(i, 1); + }; + }, + }, + onUpdateDownloaded() { + return () => {}; + }, + openUrl(url: string) { + return openUrl(url); }, - onUpdateDownloaded() { return () => {}; }, - openUrl(url: string) { return openUrl(url); }, }; window.deepcode = api; } diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 60a505e..0a4d4ea 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -1,13 +1,26 @@ -// REPL screen — minimal chat surface for the skeleton. +// REPL screen — chat surface that actually drives @deepcode/core's agent loop. // Spec: docs/VISUAL_DESIGN.html screen #2 -// Milestone: M6 skeleton — wires onSubmit; the full agent loop integration -// (streaming + tools + permissions) lives in subsequent M6-rest PRs. +// Milestone: M6 (real agent integration) import { useEffect, useRef, useState } from 'react'; interface Message { - role: 'user' | 'assistant' | 'system'; + role: 'user' | 'assistant' | 'system' | 'tool'; text: string; + /** True while streaming; flips false on turn_done. */ + streaming?: boolean; +} + +interface AgentStreamEvt { + kind: 'event' | 'turn_done'; + turnId: string; + type?: string; + text?: string; + name?: string; + input?: Record; + result?: { content: string; isError?: boolean }; + error?: string; + stopReason?: string; } export function ReplScreen(): JSX.Element { @@ -15,31 +28,109 @@ export function ReplScreen(): JSX.Element { { role: 'system', text: - "DeepCode is ready. The Mac client's agent loop is wired in M6-rest — this " + - "skeleton renders chat history and the input box. For real conversations " + - "today, use the CLI: `deepcode`.", + "DeepCode is ready. Type a message below to talk to DeepSeek. " + + "The agent can call Read / Write / Edit / Bash / Grep / Glob tools.", }, ]); const [input, setInput] = useState(''); + const [busy, setBusy] = useState(false); + const [activeTurnId, setActiveTurnId] = useState(null); const listRef = useRef(null); + // Subscribe to agent events for the lifetime of this view + useEffect(() => { + if (!window.deepcode?.agent) return; + const off = window.deepcode.agent.onEvent((raw: unknown) => { + const e = raw as AgentStreamEvt; + if (e.kind === 'turn_done') { + setBusy(false); + setActiveTurnId(null); + // Finalize the last assistant message (drop "streaming" flag) + setMessages((m) => { + if (m.length === 0) return m; + const last = m[m.length - 1]!; + if (last.role === 'assistant' && last.streaming) { + return [...m.slice(0, -1), { ...last, streaming: false }]; + } + return m; + }); + return; + } + // kind === 'event' + switch (e.type) { + case 'text_delta': + setMessages((m) => { + const last = m[m.length - 1]; + if (last && last.role === 'assistant' && last.streaming) { + return [ + ...m.slice(0, -1), + { ...last, text: last.text + (e.text ?? '') }, + ]; + } + return [...m, { role: 'assistant', text: e.text ?? '', streaming: true }]; + }); + break; + case 'tool_use': + setMessages((m) => [ + ...m, + { + role: 'tool', + text: `→ ${e.name ?? '?'} ${formatToolArgs(e.input ?? {})}`, + }, + ]); + break; + case 'tool_result': + setMessages((m) => [ + ...m, + { + role: 'tool', + text: + (e.result?.isError ? '✕ ' : '✓ ') + + truncate(e.result?.content ?? '', 200), + }, + ]); + break; + case 'error': + setMessages((m) => [ + ...m, + { role: 'system', text: `✕ Error: ${e.error ?? 'unknown'}` }, + ]); + break; + // text 'usage', 'thinking_delta', 'turn_complete' silently dropped + } + }); + return () => off(); + }, []); + useEffect(() => { listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' }); }, [messages]); - function handleSubmit(e: React.FormEvent): void { + async function handleSubmit(e: React.FormEvent): Promise { e.preventDefault(); const text = input.trim(); - if (!text) return; - setMessages((m) => [ - ...m, - { role: 'user', text }, - { - role: 'assistant', - text: '(M6 skeleton — agent loop not yet wired. See repl.ts in apps/cli for live convos.)', - }, - ]); + if (!text || busy) return; setInput(''); + setMessages((m) => [...m, { role: 'user', text }]); + setBusy(true); + try { + const r = await window.deepcode.agent.start({ + sessionId: 'default', + userMessage: text, + }); + setActiveTurnId(r.turnId); + } catch (err) { + setBusy(false); + setMessages((m) => [ + ...m, + { role: 'system', text: `✕ Failed to start: ${(err as Error).message}` }, + ]); + } + } + + async function handleAbort(): Promise { + if (!activeTurnId) return; + await window.deepcode.agent.abort({ turnId: activeTurnId }); } return ( @@ -54,35 +145,60 @@ export function ReplScreen(): JSX.Element { ? 'ml-12 bg-accent/20' : m.role === 'assistant' ? 'mr-12 bg-bg-elevated' - : 'mx-12 border border-border bg-bg-elevated text-muted') + : m.role === 'tool' + ? 'mx-6 bg-bg-elevated text-xs text-muted font-mono' + : 'mx-12 border border-border bg-bg-elevated text-muted') } > -
{m.role}
-
{m.text}
+ {m.role !== 'tool' &&
{m.role}
} +
+ {m.text} + {m.streaming && } +
))} -
+
setInput(e.target.value)} - placeholder="Ask DeepCode..." - className="flex-1 rounded border border-border bg-bg px-3 py-2 text-fg outline-none focus:border-accent" + placeholder={busy ? 'Agent is responding…' : 'Ask DeepCode…'} + disabled={busy} + className="flex-1 rounded border border-border bg-bg px-3 py-2 text-fg outline-none focus:border-accent disabled:opacity-50" /> - + {busy ? ( + + ) : ( + + )}
); } + +function formatToolArgs(input: Record): string { + for (const key of ['file_path', 'command', 'pattern', 'path', 'url', 'query']) { + const v = input[key]; + if (typeof v === 'string') return v; + } + return JSON.stringify(input).slice(0, 80); +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + '…' : s; +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 57307a6..31b021e 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -38,8 +38,22 @@ export default defineConfig({ }, }, resolve: { - alias: { - '@deepcode/core': resolve(__dirname, '..', '..', 'packages', 'core', 'src', 'index.ts'), - }, + alias: [ + // Subpath imports — load directly from compiled dist/. The renderer + // can't bundle some core modules (node:fs deps), so we cherry-pick + // (only agent.js / providers/deepseek.js / types.js are referenced + // from the renderer code). + { + find: /^@deepcode\/core\/dist\/(.+)$/, + replacement: resolve(__dirname, '..', '..', 'packages', 'core', 'dist') + '/$1', + }, + // Bare import — anything that resolves through the index. We avoid + // doing this in the renderer (use mac-tools/mac-agent which import + // from subpaths) but keep the alias so types still resolve. + { + find: '@deepcode/core', + replacement: resolve(__dirname, '..', '..', 'packages', 'core', 'src', 'index.ts'), + }, + ], }, }); diff --git a/packages/core/package.json b/packages/core/package.json index 6066f93..fb86a94 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,18 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./dist/agent.js": { + "types": "./dist/agent.d.ts", + "import": "./dist/agent.js" + }, + "./dist/providers/deepseek.js": { + "types": "./dist/providers/deepseek.d.ts", + "import": "./dist/providers/deepseek.js" + }, + "./dist/types.js": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js" } }, "files": [ diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index c5d3f16..051b7f8 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -7,8 +7,11 @@ import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatche import type { HookDispatcher } from './hooks/index.js'; import type { Mode } from './types.js'; import type { Provider } from './providers/types.js'; -import { buildSystemReminders, type ReminderType } from './reminders/index.js'; -import { SessionManager } from './sessions/index.js'; +// NOTE: reminders + sessions are lazy-loaded inside the loop so a browser +// build (Tauri renderer) that doesn't use them avoids pulling node:fs at +// module-load time. See `loadRemindersIfEnabled` and `appendSessionIfSet`. +import type { ReminderType } from './reminders/index.js'; +import type { SessionManager } from './sessions/index.js'; import type { ToolRegistry } from './tools/registry.js'; import type { AgentEvent, @@ -105,6 +108,11 @@ export async function runAgent(opts: RunAgentOptions): Promise { let userText = opts.userMessage; if (opts.systemReminders !== false) { try { + // Lazy-load with @vite-ignore so bundlers skip this module — the + // renderer passes systemReminders:false to bypass it entirely, and + // a static import here would drag node:fs into the browser bundle. + const remindersMod = /* @vite-ignore */ './reminders/index.js'; + const { buildSystemReminders } = (await import(remindersMod)) as typeof import('./reminders/index.js'); const block = await buildSystemReminders( { cwd: opts.cwd,