diff --git a/v2/bterminal-core/src/sidecar.rs b/v2/bterminal-core/src/sidecar.rs index 5d28ec7..bc6e5f6 100644 --- a/v2/bterminal-core/src/sidecar.rs +++ b/v2/bterminal-core/src/sidecar.rs @@ -19,6 +19,11 @@ pub struct AgentQueryOptions { pub max_budget_usd: Option, pub resume_session_id: Option, pub permission_mode: Option, + pub setting_sources: Option>, + pub system_prompt: Option, + pub model: Option, + pub claude_config_dir: Option, + pub additional_directories: Option>, } /// Directories to search for sidecar scripts. @@ -178,6 +183,11 @@ impl SidecarManager { "maxBudgetUsd": options.max_budget_usd, "resumeSessionId": options.resume_session_id, "permissionMode": options.permission_mode, + "settingSources": options.setting_sources, + "systemPrompt": options.system_prompt, + "model": options.model, + "claudeConfigDir": options.claude_config_dir, + "additionalDirectories": options.additional_directories, }); self.send_message(&msg) diff --git a/v2/sidecar/agent-runner-deno.ts b/v2/sidecar/agent-runner-deno.ts index ceb21c9..0470e36 100644 --- a/v2/sidecar/agent-runner-deno.ts +++ b/v2/sidecar/agent-runner-deno.ts @@ -28,6 +28,11 @@ interface QueryMessage { maxBudgetUsd?: number; resumeSessionId?: string; permissionMode?: string; + settingSources?: string[]; + systemPrompt?: string; + model?: string; + claudeConfigDir?: string; + additionalDirectories?: string[]; } interface StopMessage { @@ -52,7 +57,7 @@ function handleMessage(msg: Record) { } async function handleQuery(msg: QueryMessage) { - const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode } = msg; + const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories } = msg; if (sessions.has(sessionId)) { send({ type: "error", sessionId, message: "Session already running" }); @@ -70,6 +75,10 @@ async function handleQuery(msg: QueryMessage) { cleanEnv[key] = value; } } + // Override CLAUDE_CONFIG_DIR for multi-account support + if (claudeConfigDir) { + cleanEnv["CLAUDE_CONFIG_DIR"] = claudeConfigDir; + } if (!claudePath) { send({ type: "agent_error", sessionId, message: "Claude CLI not found. Install Claude Code first." }); @@ -93,6 +102,10 @@ async function handleQuery(msg: QueryMessage) { ], permissionMode: (permissionMode ?? "bypassPermissions") as "bypassPermissions" | "default", allowDangerouslySkipPermissions: (permissionMode ?? "bypassPermissions") === "bypassPermissions", + settingSources: settingSources ?? ["user", "project"], + systemPrompt: systemPrompt ?? undefined, + model: model ?? undefined, + additionalDirectories: additionalDirectories ?? undefined, }, }); diff --git a/v2/sidecar/agent-runner.ts b/v2/sidecar/agent-runner.ts index 1d8e318..667f517 100644 --- a/v2/sidecar/agent-runner.ts +++ b/v2/sidecar/agent-runner.ts @@ -41,6 +41,11 @@ interface QueryMessage { maxBudgetUsd?: number; resumeSessionId?: string; permissionMode?: string; + settingSources?: string[]; + systemPrompt?: string; + model?: string; + claudeConfigDir?: string; + additionalDirectories?: string[]; } interface StopMessage { @@ -65,7 +70,7 @@ function handleMessage(msg: Record) { } async function handleQuery(msg: QueryMessage) { - const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode } = msg; + const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories } = msg; if (sessions.has(sessionId)) { send({ type: 'error', sessionId, message: 'Session already running' }); @@ -83,6 +88,10 @@ async function handleQuery(msg: QueryMessage) { cleanEnv[key] = value; } } + // Override CLAUDE_CONFIG_DIR for multi-account support + if (claudeConfigDir) { + cleanEnv['CLAUDE_CONFIG_DIR'] = claudeConfigDir; + } try { if (!claudePath) { @@ -106,6 +115,10 @@ async function handleQuery(msg: QueryMessage) { ], permissionMode: (permissionMode ?? 'bypassPermissions') as 'bypassPermissions' | 'default', allowDangerouslySkipPermissions: (permissionMode ?? 'bypassPermissions') === 'bypassPermissions', + settingSources: settingSources ?? ['user', 'project'], + systemPrompt: systemPrompt ?? undefined, + model: model ?? undefined, + additionalDirectories: additionalDirectories ?? undefined, }, }); diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 73377d4..029e648 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -205,6 +205,170 @@ fn ctx_search(state: State<'_, AppState>, query: String) -> Result, + subscription_type: Option, + display_name: Option, + config_dir: String, +} + +#[tauri::command] +fn claude_list_profiles() -> Vec { + let mut profiles = Vec::new(); + + // Read profiles from ~/.config/switcher/profiles/ + let config_dir = dirs::config_dir().unwrap_or_default(); + let profiles_dir = config_dir.join("switcher").join("profiles"); + let alt_dir_root = config_dir.join("switcher-claude"); + + if let Ok(entries) = std::fs::read_dir(&profiles_dir) { + for entry in entries.flatten() { + if !entry.path().is_dir() { continue; } + let name = entry.file_name().to_string_lossy().to_string(); + + // Read profile.toml for metadata + let toml_path = entry.path().join("profile.toml"); + let (email, subscription_type, display_name) = if toml_path.exists() { + let content = std::fs::read_to_string(&toml_path).unwrap_or_default(); + ( + extract_toml_value(&content, "email"), + extract_toml_value(&content, "subscription_type"), + extract_toml_value(&content, "display_name"), + ) + } else { + (None, None, None) + }; + + // Alt dir for CLAUDE_CONFIG_DIR + let alt_path = alt_dir_root.join(&name); + let config_dir_str = if alt_path.exists() { + alt_path.to_string_lossy().to_string() + } else { + // Fallback to default ~/.claude + dirs::home_dir() + .unwrap_or_default() + .join(".claude") + .to_string_lossy() + .to_string() + }; + + profiles.push(ClaudeProfile { + name, + email, + subscription_type, + display_name, + config_dir: config_dir_str, + }); + } + } + + // Always include a "default" profile for ~/.claude + if profiles.is_empty() { + let home = dirs::home_dir().unwrap_or_default(); + profiles.push(ClaudeProfile { + name: "default".to_string(), + email: None, + subscription_type: None, + display_name: None, + config_dir: home.join(".claude").to_string_lossy().to_string(), + }); + } + + profiles +} + +fn extract_toml_value(content: &str, key: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix(key) { + if let Some(rest) = rest.trim().strip_prefix('=') { + let val = rest.trim().trim_matches('"'); + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + } + None +} + +// --- Skill discovery commands --- + +#[derive(serde::Serialize)] +struct ClaudeSkill { + name: String, + description: String, + source_path: String, +} + +#[tauri::command] +fn claude_list_skills() -> Vec { + let mut skills = Vec::new(); + let home = dirs::home_dir().unwrap_or_default(); + + // Search for skills in ~/.claude/skills/ (same as Claude Code CLI) + let skills_dir = home.join(".claude").join("skills"); + if let Ok(entries) = std::fs::read_dir(&skills_dir) { + for entry in entries.flatten() { + let path = entry.path(); + // Skills can be directories with SKILL.md or standalone .md files + let (name, skill_file) = if path.is_dir() { + let skill_md = path.join("SKILL.md"); + if skill_md.exists() { + (entry.file_name().to_string_lossy().to_string(), skill_md) + } else { + continue; + } + } else if path.extension().map_or(false, |e| e == "md") { + let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + (stem, path.clone()) + } else { + continue; + }; + + // Extract description from first non-empty, non-heading line + let description = if let Ok(content) = std::fs::read_to_string(&skill_file) { + content.lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .next() + .unwrap_or("") + .trim() + .chars() + .take(120) + .collect() + } else { + String::new() + }; + + skills.push(ClaudeSkill { + name, + description, + source_path: skill_file.to_string_lossy().to_string(), + }); + } + } + + skills +} + +#[tauri::command] +fn claude_read_skill(path: String) -> Result { + std::fs::read_to_string(&path).map_err(|e| format!("Failed to read skill: {e}")) +} + +// --- Directory picker command --- + +#[tauri::command] +fn pick_directory() -> Option { + // Use native file dialog via rfd + // Fallback: return None and let frontend use a text input + None +} + // --- Remote machine commands --- #[tauri::command] @@ -307,6 +471,10 @@ pub fn run() { remote_pty_write, remote_pty_resize, remote_pty_kill, + claude_list_profiles, + claude_list_skills, + claude_read_skill, + pick_directory, ]) .plugin(tauri_plugin_updater::Builder::new().build()) .setup(move |app| { diff --git a/v2/src/lib/adapters/agent-bridge.ts b/v2/src/lib/adapters/agent-bridge.ts index c317579..344d28b 100644 --- a/v2/src/lib/adapters/agent-bridge.ts +++ b/v2/src/lib/adapters/agent-bridge.ts @@ -12,6 +12,11 @@ export interface AgentQueryOptions { max_budget_usd?: number; resume_session_id?: string; permission_mode?: string; + setting_sources?: string[]; + system_prompt?: string; + model?: string; + claude_config_dir?: string; + additional_directories?: string[]; remote_machine_id?: string; } diff --git a/v2/src/lib/adapters/claude-bridge.ts b/v2/src/lib/adapters/claude-bridge.ts new file mode 100644 index 0000000..a03c318 --- /dev/null +++ b/v2/src/lib/adapters/claude-bridge.ts @@ -0,0 +1,28 @@ +// Claude Bridge — Tauri IPC adapter for Claude profiles and skills +import { invoke } from '@tauri-apps/api/core'; + +export interface ClaudeProfile { + name: string; + email: string | null; + subscription_type: string | null; + display_name: string | null; + config_dir: string; +} + +export interface ClaudeSkill { + name: string; + description: string; + source_path: string; +} + +export async function listProfiles(): Promise { + return invoke('claude_list_profiles'); +} + +export async function listSkills(): Promise { + return invoke('claude_list_skills'); +} + +export async function readSkill(path: string): Promise { + return invoke('claude_read_skill', { path }); +} diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte index 7e15322..ace63bd 100644 --- a/v2/src/lib/components/Agent/AgentPane.svelte +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -12,6 +12,7 @@ } from '../../stores/agents.svelte'; import { focusPane } from '../../stores/layout.svelte'; import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher'; + import { listProfiles, listSkills, readSkill, type ClaudeProfile, type ClaudeSkill } from '../../adapters/claude-bridge'; import AgentTree from './AgentTree.svelte'; import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight'; import type { @@ -31,7 +32,7 @@ onExit?: () => void; } - let { sessionId, prompt: initialPrompt = '', cwd, onExit }: Props = $props(); + let { sessionId, prompt: initialPrompt = '', cwd: initialCwd, onExit }: Props = $props(); let session = $derived(getAgentSession(sessionId)); let inputPrompt = $state(initialPrompt); @@ -44,6 +45,24 @@ let childSessions = $derived(session ? getChildSessions(session.id) : []); let totalCost = $derived(session && childSessions.length > 0 ? getTotalCost(session.id) : null); + // Working directory + let cwdInput = $state(initialCwd ?? ''); + let showCwdPicker = $state(false); + + // Profile selector + let profiles = $state([]); + let selectedProfile = $state(''); + + // Skill autocomplete + let skills = $state([]); + let showSkillMenu = $state(false); + let filteredSkills = $derived( + inputPrompt.startsWith('/') + ? skills.filter(s => s.name.toLowerCase().startsWith(inputPrompt.slice(1).toLowerCase())) + : [] + ); + let skillMenuIndex = $state(0); + const mdRenderer = new Renderer(); mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) { if (lang) { @@ -63,6 +82,13 @@ onMount(async () => { await getHighlighter(); + // Load profiles and skills in parallel + const [profileList, skillList] = await Promise.all([ + listProfiles().catch(() => []), + listSkills().catch(() => []), + ]); + profiles = profileList; + skills = skillList; if (initialPrompt) { await startQuery(initialPrompt); } @@ -93,20 +119,44 @@ updateAgentStatus(sessionId, 'starting'); } + const profile = profiles.find(p => p.name === selectedProfile); await queryAgent({ session_id: sessionId, prompt: text, - cwd, + cwd: cwdInput || undefined, max_turns: 50, resume_session_id: resumeId, + setting_sources: ['user', 'project'], + claude_config_dir: profile?.config_dir, }); inputPrompt = ''; followUpPrompt = ''; } - function handleSubmit(e: Event) { + async function expandSkillPrompt(text: string): Promise { + if (!text.startsWith('/')) return text; + const skillName = text.slice(1).split(/\s+/)[0]; + const skill = skills.find(s => s.name === skillName); + if (!skill) return text; + try { + const content = await readSkill(skill.source_path); + const args = text.slice(1 + skillName.length).trim(); + return args ? `${content}\n\nUser input: ${args}` : content; + } catch { + return text; + } + } + + async function handleSubmit(e: Event) { e.preventDefault(); - startQuery(inputPrompt); + const expanded = await expandSkillPrompt(inputPrompt); + showSkillMenu = false; + startQuery(expanded); + } + + function handleSkillSelect(skill: ClaudeSkill) { + inputPrompt = `/${skill.name} `; + showSkillMenu = false; } function handleStop() { @@ -174,19 +224,93 @@
{#if !session || session.messages.length === 0}
+
+
+ + {#if profiles.length > 1} + + {/if} +
+
- +
+ + {#if showSkillMenu && filteredSkills.length > 0} +
+ {#each filteredSkills as skill, i (skill.name)} + + {/each} +
+ {/if} +
@@ -814,4 +938,132 @@ .follow-up-btn:hover { opacity: 0.9; } .follow-up-btn:disabled { opacity: 0.4; cursor: not-allowed; } + + /* Session toolbar */ + .session-toolbar { + width: 100%; + max-width: 600px; + margin-bottom: 8px; + } + + .toolbar-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .toolbar-label { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 180px; + } + + .toolbar-icon { + font-size: 9px; + font-weight: 700; + color: var(--ctp-crust); + background: var(--ctp-overlay1); + padding: 2px 5px; + border-radius: 3px; + letter-spacing: 0.5px; + flex-shrink: 0; + } + + .toolbar-input { + flex: 1; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-primary); + font-size: 11px; + padding: 3px 6px; + font-family: var(--font-mono); + } + + .toolbar-input:focus { + outline: none; + border-color: var(--accent); + } + + .toolbar-select { + flex: 1; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-primary); + font-size: 11px; + padding: 3px 4px; + font-family: inherit; + } + + .toolbar-select:focus { + outline: none; + border-color: var(--accent); + } + + /* Skill autocomplete */ + .prompt-wrapper { + position: relative; + width: 100%; + } + + .skill-menu { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--border-radius); + max-height: 200px; + overflow-y: auto; + z-index: 10; + margin-bottom: 4px; + } + + .skill-item { + display: flex; + gap: 8px; + align-items: baseline; + padding: 6px 10px; + width: 100%; + text-align: left; + background: none; + border: none; + color: var(--text-primary); + font-size: 12px; + cursor: pointer; + font-family: inherit; + } + + .skill-item:hover, .skill-item.active { + background: var(--accent); + color: var(--ctp-crust); + } + + .skill-name { + font-weight: 600; + font-family: var(--font-mono); + color: var(--ctp-green); + flex-shrink: 0; + } + + .skill-item:hover .skill-name, .skill-item.active .skill-name { + color: var(--ctp-crust); + } + + .skill-desc { + color: var(--text-muted); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .skill-item:hover .skill-desc, .skill-item.active .skill-desc { + color: var(--ctp-crust); + opacity: 0.8; + }