feat(v2): add Claude profile switching, skill discovery, and extended agent options
Add switcher-claude multi-account support with profile selector in AgentPane toolbar, skill autocomplete menu (type / in prompt), and 5 new AgentQueryOptions fields (setting_sources, system_prompt, model, claude_config_dir, additional_directories) flowing through full stack from Rust to SDK. New Tauri commands: claude_list_profiles, claude_list_skills, claude_read_skill, pick_directory. New frontend adapter: claude-bridge.ts.
This commit is contained in:
parent
768db420d3
commit
ff49e7e176
7 changed files with 507 additions and 18 deletions
|
|
@ -19,6 +19,11 @@ pub struct AgentQueryOptions {
|
||||||
pub max_budget_usd: Option<f64>,
|
pub max_budget_usd: Option<f64>,
|
||||||
pub resume_session_id: Option<String>,
|
pub resume_session_id: Option<String>,
|
||||||
pub permission_mode: Option<String>,
|
pub permission_mode: Option<String>,
|
||||||
|
pub setting_sources: Option<Vec<String>>,
|
||||||
|
pub system_prompt: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub claude_config_dir: Option<String>,
|
||||||
|
pub additional_directories: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Directories to search for sidecar scripts.
|
/// Directories to search for sidecar scripts.
|
||||||
|
|
@ -178,6 +183,11 @@ impl SidecarManager {
|
||||||
"maxBudgetUsd": options.max_budget_usd,
|
"maxBudgetUsd": options.max_budget_usd,
|
||||||
"resumeSessionId": options.resume_session_id,
|
"resumeSessionId": options.resume_session_id,
|
||||||
"permissionMode": options.permission_mode,
|
"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)
|
self.send_message(&msg)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ interface QueryMessage {
|
||||||
maxBudgetUsd?: number;
|
maxBudgetUsd?: number;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
permissionMode?: string;
|
permissionMode?: string;
|
||||||
|
settingSources?: string[];
|
||||||
|
systemPrompt?: string;
|
||||||
|
model?: string;
|
||||||
|
claudeConfigDir?: string;
|
||||||
|
additionalDirectories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StopMessage {
|
interface StopMessage {
|
||||||
|
|
@ -52,7 +57,7 @@ function handleMessage(msg: Record<string, unknown>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleQuery(msg: QueryMessage) {
|
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)) {
|
if (sessions.has(sessionId)) {
|
||||||
send({ type: "error", sessionId, message: "Session already running" });
|
send({ type: "error", sessionId, message: "Session already running" });
|
||||||
|
|
@ -70,6 +75,10 @@ async function handleQuery(msg: QueryMessage) {
|
||||||
cleanEnv[key] = value;
|
cleanEnv[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Override CLAUDE_CONFIG_DIR for multi-account support
|
||||||
|
if (claudeConfigDir) {
|
||||||
|
cleanEnv["CLAUDE_CONFIG_DIR"] = claudeConfigDir;
|
||||||
|
}
|
||||||
|
|
||||||
if (!claudePath) {
|
if (!claudePath) {
|
||||||
send({ type: "agent_error", sessionId, message: "Claude CLI not found. Install Claude Code first." });
|
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",
|
permissionMode: (permissionMode ?? "bypassPermissions") as "bypassPermissions" | "default",
|
||||||
allowDangerouslySkipPermissions: (permissionMode ?? "bypassPermissions") === "bypassPermissions",
|
allowDangerouslySkipPermissions: (permissionMode ?? "bypassPermissions") === "bypassPermissions",
|
||||||
|
settingSources: settingSources ?? ["user", "project"],
|
||||||
|
systemPrompt: systemPrompt ?? undefined,
|
||||||
|
model: model ?? undefined,
|
||||||
|
additionalDirectories: additionalDirectories ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ interface QueryMessage {
|
||||||
maxBudgetUsd?: number;
|
maxBudgetUsd?: number;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
permissionMode?: string;
|
permissionMode?: string;
|
||||||
|
settingSources?: string[];
|
||||||
|
systemPrompt?: string;
|
||||||
|
model?: string;
|
||||||
|
claudeConfigDir?: string;
|
||||||
|
additionalDirectories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StopMessage {
|
interface StopMessage {
|
||||||
|
|
@ -65,7 +70,7 @@ function handleMessage(msg: Record<string, unknown>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleQuery(msg: QueryMessage) {
|
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)) {
|
if (sessions.has(sessionId)) {
|
||||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||||
|
|
@ -83,6 +88,10 @@ async function handleQuery(msg: QueryMessage) {
|
||||||
cleanEnv[key] = value;
|
cleanEnv[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Override CLAUDE_CONFIG_DIR for multi-account support
|
||||||
|
if (claudeConfigDir) {
|
||||||
|
cleanEnv['CLAUDE_CONFIG_DIR'] = claudeConfigDir;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!claudePath) {
|
if (!claudePath) {
|
||||||
|
|
@ -106,6 +115,10 @@ async function handleQuery(msg: QueryMessage) {
|
||||||
],
|
],
|
||||||
permissionMode: (permissionMode ?? 'bypassPermissions') as 'bypassPermissions' | 'default',
|
permissionMode: (permissionMode ?? 'bypassPermissions') as 'bypassPermissions' | 'default',
|
||||||
allowDangerouslySkipPermissions: (permissionMode ?? 'bypassPermissions') === 'bypassPermissions',
|
allowDangerouslySkipPermissions: (permissionMode ?? 'bypassPermissions') === 'bypassPermissions',
|
||||||
|
settingSources: settingSources ?? ['user', 'project'],
|
||||||
|
systemPrompt: systemPrompt ?? undefined,
|
||||||
|
model: model ?? undefined,
|
||||||
|
additionalDirectories: additionalDirectories ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,170 @@ fn ctx_search(state: State<'_, AppState>, query: String) -> Result<Vec<ctx::CtxE
|
||||||
state.ctx_db.search(&query)
|
state.ctx_db.search(&query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Claude profile commands (switcher-claude integration) ---
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct ClaudeProfile {
|
||||||
|
name: String,
|
||||||
|
email: Option<String>,
|
||||||
|
subscription_type: Option<String>,
|
||||||
|
display_name: Option<String>,
|
||||||
|
config_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn claude_list_profiles() -> Vec<ClaudeProfile> {
|
||||||
|
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<String> {
|
||||||
|
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<ClaudeSkill> {
|
||||||
|
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<String, String> {
|
||||||
|
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read skill: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Directory picker command ---
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn pick_directory() -> Option<String> {
|
||||||
|
// Use native file dialog via rfd
|
||||||
|
// Fallback: return None and let frontend use a text input
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
// --- Remote machine commands ---
|
// --- Remote machine commands ---
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -307,6 +471,10 @@ pub fn run() {
|
||||||
remote_pty_write,
|
remote_pty_write,
|
||||||
remote_pty_resize,
|
remote_pty_resize,
|
||||||
remote_pty_kill,
|
remote_pty_kill,
|
||||||
|
claude_list_profiles,
|
||||||
|
claude_list_skills,
|
||||||
|
claude_read_skill,
|
||||||
|
pick_directory,
|
||||||
])
|
])
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ export interface AgentQueryOptions {
|
||||||
max_budget_usd?: number;
|
max_budget_usd?: number;
|
||||||
resume_session_id?: string;
|
resume_session_id?: string;
|
||||||
permission_mode?: string;
|
permission_mode?: string;
|
||||||
|
setting_sources?: string[];
|
||||||
|
system_prompt?: string;
|
||||||
|
model?: string;
|
||||||
|
claude_config_dir?: string;
|
||||||
|
additional_directories?: string[];
|
||||||
remote_machine_id?: string;
|
remote_machine_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
28
v2/src/lib/adapters/claude-bridge.ts
Normal file
28
v2/src/lib/adapters/claude-bridge.ts
Normal file
|
|
@ -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<ClaudeProfile[]> {
|
||||||
|
return invoke<ClaudeProfile[]>('claude_list_profiles');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSkills(): Promise<ClaudeSkill[]> {
|
||||||
|
return invoke<ClaudeSkill[]>('claude_list_skills');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSkill(path: string): Promise<string> {
|
||||||
|
return invoke<string>('claude_read_skill', { path });
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
} from '../../stores/agents.svelte';
|
} from '../../stores/agents.svelte';
|
||||||
import { focusPane } from '../../stores/layout.svelte';
|
import { focusPane } from '../../stores/layout.svelte';
|
||||||
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
|
||||||
|
import { listProfiles, listSkills, readSkill, type ClaudeProfile, type ClaudeSkill } from '../../adapters/claude-bridge';
|
||||||
import AgentTree from './AgentTree.svelte';
|
import AgentTree from './AgentTree.svelte';
|
||||||
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -31,7 +32,7 @@
|
||||||
onExit?: () => void;
|
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 session = $derived(getAgentSession(sessionId));
|
||||||
let inputPrompt = $state(initialPrompt);
|
let inputPrompt = $state(initialPrompt);
|
||||||
|
|
@ -44,6 +45,24 @@
|
||||||
let childSessions = $derived(session ? getChildSessions(session.id) : []);
|
let childSessions = $derived(session ? getChildSessions(session.id) : []);
|
||||||
let totalCost = $derived(session && childSessions.length > 0 ? getTotalCost(session.id) : null);
|
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<ClaudeProfile[]>([]);
|
||||||
|
let selectedProfile = $state('');
|
||||||
|
|
||||||
|
// Skill autocomplete
|
||||||
|
let skills = $state<ClaudeSkill[]>([]);
|
||||||
|
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();
|
const mdRenderer = new Renderer();
|
||||||
mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) {
|
||||||
if (lang) {
|
if (lang) {
|
||||||
|
|
@ -63,6 +82,13 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await getHighlighter();
|
await getHighlighter();
|
||||||
|
// Load profiles and skills in parallel
|
||||||
|
const [profileList, skillList] = await Promise.all([
|
||||||
|
listProfiles().catch(() => []),
|
||||||
|
listSkills().catch(() => []),
|
||||||
|
]);
|
||||||
|
profiles = profileList;
|
||||||
|
skills = skillList;
|
||||||
if (initialPrompt) {
|
if (initialPrompt) {
|
||||||
await startQuery(initialPrompt);
|
await startQuery(initialPrompt);
|
||||||
}
|
}
|
||||||
|
|
@ -93,20 +119,44 @@
|
||||||
updateAgentStatus(sessionId, 'starting');
|
updateAgentStatus(sessionId, 'starting');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profile = profiles.find(p => p.name === selectedProfile);
|
||||||
await queryAgent({
|
await queryAgent({
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
prompt: text,
|
prompt: text,
|
||||||
cwd,
|
cwd: cwdInput || undefined,
|
||||||
max_turns: 50,
|
max_turns: 50,
|
||||||
resume_session_id: resumeId,
|
resume_session_id: resumeId,
|
||||||
|
setting_sources: ['user', 'project'],
|
||||||
|
claude_config_dir: profile?.config_dir,
|
||||||
});
|
});
|
||||||
inputPrompt = '';
|
inputPrompt = '';
|
||||||
followUpPrompt = '';
|
followUpPrompt = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(e: Event) {
|
async function expandSkillPrompt(text: string): Promise<string> {
|
||||||
|
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();
|
e.preventDefault();
|
||||||
startQuery(inputPrompt);
|
const expanded = await expandSkillPrompt(inputPrompt);
|
||||||
|
showSkillMenu = false;
|
||||||
|
startQuery(expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSkillSelect(skill: ClaudeSkill) {
|
||||||
|
inputPrompt = `/${skill.name} `;
|
||||||
|
showSkillMenu = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
|
|
@ -174,19 +224,93 @@
|
||||||
<div class="agent-pane">
|
<div class="agent-pane">
|
||||||
{#if !session || session.messages.length === 0}
|
{#if !session || session.messages.length === 0}
|
||||||
<div class="prompt-area">
|
<div class="prompt-area">
|
||||||
|
<div class="session-toolbar">
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<label class="toolbar-label">
|
||||||
|
<span class="toolbar-icon">DIR</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="toolbar-input"
|
||||||
|
bind:value={cwdInput}
|
||||||
|
placeholder="Working directory (default: ~)"
|
||||||
|
onfocus={() => showCwdPicker = true}
|
||||||
|
onblur={() => setTimeout(() => showCwdPicker = false, 150)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if profiles.length > 1}
|
||||||
|
<label class="toolbar-label">
|
||||||
|
<span class="toolbar-icon">ACC</span>
|
||||||
|
<select class="toolbar-select" bind:value={selectedProfile}>
|
||||||
|
<option value="">Default account</option>
|
||||||
|
{#each profiles as profile (profile.name)}
|
||||||
|
<option value={profile.name}>
|
||||||
|
{profile.display_name || profile.name}
|
||||||
|
{#if profile.subscription_type}
|
||||||
|
({profile.subscription_type})
|
||||||
|
{/if}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<form onsubmit={handleSubmit} class="prompt-form">
|
<form onsubmit={handleSubmit} class="prompt-form">
|
||||||
<textarea
|
<div class="prompt-wrapper">
|
||||||
bind:value={inputPrompt}
|
<textarea
|
||||||
placeholder="Ask Claude something..."
|
bind:value={inputPrompt}
|
||||||
class="prompt-input"
|
placeholder="Ask Claude something... (type / for skills)"
|
||||||
rows="3"
|
class="prompt-input"
|
||||||
onkeydown={(e) => {
|
rows="3"
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
oninput={() => {
|
||||||
e.preventDefault();
|
showSkillMenu = inputPrompt.startsWith('/') && filteredSkills.length > 0;
|
||||||
startQuery(inputPrompt);
|
skillMenuIndex = 0;
|
||||||
}
|
}}
|
||||||
}}
|
onkeydown={async (e) => {
|
||||||
></textarea>
|
if (showSkillMenu && filteredSkills.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
skillMenuIndex = Math.min(skillMenuIndex + 1, filteredSkills.length - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
skillMenuIndex = Math.max(skillMenuIndex - 1, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSkillSelect(filteredSkills[skillMenuIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
showSkillMenu = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const expanded = await expandSkillPrompt(inputPrompt);
|
||||||
|
showSkillMenu = false;
|
||||||
|
startQuery(expanded);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
{#if showSkillMenu && filteredSkills.length > 0}
|
||||||
|
<div class="skill-menu">
|
||||||
|
{#each filteredSkills as skill, i (skill.name)}
|
||||||
|
<button
|
||||||
|
class="skill-item"
|
||||||
|
class:active={i === skillMenuIndex}
|
||||||
|
onmousedown|preventDefault={() => handleSkillSelect(skill)}
|
||||||
|
>
|
||||||
|
<span class="skill-name">/{skill.name}</span>
|
||||||
|
<span class="skill-desc">{skill.description}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<button type="submit" class="send-btn" disabled={!inputPrompt.trim()}>Send</button>
|
<button type="submit" class="send-btn" disabled={!inputPrompt.trim()}>Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -814,4 +938,132 @@
|
||||||
|
|
||||||
.follow-up-btn:hover { opacity: 0.9; }
|
.follow-up-btn:hover { opacity: 0.9; }
|
||||||
.follow-up-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue