improve memory page

This commit is contained in:
Muh. Fani Akbar
2026-02-05 19:51:23 +07:00
parent 5fa00bb23d
commit 04c4e9e6d9
11 changed files with 1057 additions and 198 deletions
+23
View File
@@ -0,0 +1,23 @@
# Coding Style
You are an expert software engineer who values simplicity, readability, and minimal code.
## Core Principles
* **Prioritize Clarity and Simplicity**: Write the simplest, most direct code that solves the problem.
* **Avoid Over-engineering**: Do not add unnecessary abstractions, comments, or boilerplate.
* **Focus on the Task**: Only output the requested code. Avoid lengthy explanations or conversational text in your final response unless specifically asked.
* **Follow Conventions**: Adhere to standard conventions for the specific programming language you are using.
* **Be Concise**: Use "modify in place, no new files, keep it minimal" instructions for refactoring tasks to guide the model to make only necessary changes.
### Code Change Guidelines
- **Full file read before edits**: Before editing any file, read it in full first to ensure complete context; partial reads lead to corrupted edits
- **Minimize diffs**: Prefer the smallest change that satisfies the request. Avoid unrelated refactors or style rewrites unless necessary for correctness
- **Fail fast**: Write code with fail-fast logic by default. Do not swallow exceptions with errors or warnings
- **No fallback logic**: Do not add fallback logic unless explicitly told to and agreed with the user
- **No guessing**: Do not say "The issue is..." before you actually know what the issue is. Investigate first.
## Output Format
* Respond only with the code block(s) for the requested task.
* If explanations are necessary, keep them brief and place them outside the code block.
* Do not add extensive headers, footers, or markdown that is not strictly necessary for presenting the code.
+366 -19
View File
@@ -1584,6 +1584,112 @@ pub struct MemoryFile {
pub exists: bool,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct MemoryEntry {
pub name: String,
pub path: String,
pub content: String,
pub exists: bool,
#[serde(rename = "source")]
pub source: String, // "global" | "project"
#[serde(rename = "projectPath", skip_serializing_if = "Option::is_none")]
pub project_path: Option<String>,
pub disabled: bool,
}
fn global_memory_paths(home_dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
let active = home_dir.join(".claude/CLAUDE.md");
let disabled = home_dir.join(".claude/CLAUDE.md.disabled");
(active, disabled)
}
fn project_memory_paths(project_path: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let base = std::path::Path::new(project_path);
let active = base.join("CLAUDE.md");
let disabled = base.join("CLAUDE.md.disabled");
(active, disabled)
}
fn read_memory_entry_from_paths(
active_path: &std::path::Path,
disabled_path: &std::path::Path,
name: String,
source: String,
project_path: Option<String>,
) -> Result<MemoryEntry, String> {
let (content_path, disabled) = if active_path.is_file() {
(active_path, false)
} else if disabled_path.is_file() {
(disabled_path, true)
} else {
// No file yet treat as non-existing, empty memory
return Ok(MemoryEntry {
name,
path: path_to_string(active_path),
content: String::new(),
exists: false,
source,
project_path,
disabled: false,
});
};
let content = std::fs::read_to_string(content_path).map_err(|e| {
format!(
"Failed to read memory file {}: {}",
content_path.display(),
e
)
})?;
Ok(MemoryEntry {
name,
path: path_to_string(content_path),
content,
exists: true,
source,
project_path,
disabled,
})
}
fn get_project_paths_for_memory(
home_dir: &std::path::Path,
) -> Result<Vec<String>, String> {
let claude_json_path = home_dir.join(".claude.json");
if !claude_json_path.exists() {
return Ok(vec![]);
}
let json_value = read_json_file(&claude_json_path, ".claude.json")?;
let projects_obj = json_value
.get("projects")
.and_then(|projects| projects.as_object())
.cloned()
.unwrap_or_else(serde_json::Map::new);
Ok(projects_obj.keys().cloned().collect())
}
fn resolve_memory_paths(
source: &str,
home_dir: &std::path::Path,
project_path: &Option<String>,
) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
match source {
"global" => Ok(global_memory_paths(home_dir)),
"project" => {
let project = project_path
.as_ref()
.ok_or_else(|| "Project path is required for project memory".to_string())?;
Ok(project_memory_paths(project))
}
_ => Err("Unsupported source for memory file".to_string()),
}
}
#[tauri::command]
pub async fn read_claude_memory() -> Result<MemoryFile, String> {
let home_dir = home_dir()?;
@@ -1612,16 +1718,187 @@ pub async fn read_claude_memory() -> Result<MemoryFile, String> {
#[tauri::command]
pub async fn write_claude_memory(content: String) -> Result<(), String> {
let home_dir = home_dir()?;
let claude_md_path = home_dir.join(".claude/CLAUDE.md");
let (active_path, disabled_path) = global_memory_paths(&home_dir);
// Ensure .claude directory exists
if let Some(parent) = claude_md_path.parent() {
if let Some(parent) = active_path.parent() {
ensure_dir(parent, ".claude directory")?;
}
std::fs::write(&claude_md_path, content)
// Always write enabled global memory for this legacy command
std::fs::write(&active_path, content)
.map_err(|e| format!("Failed to write CLAUDE.md file: {}", e))?;
// Remove disabled file if it exists to keep state consistent
if disabled_path.exists() {
std::fs::remove_file(&disabled_path)
.map_err(|e| format!("Failed to remove disabled CLAUDE.md file: {}", e))?;
}
Ok(())
}
#[tauri::command]
pub async fn list_claude_memory_files() -> Result<Vec<MemoryEntry>, String> {
let home_dir = home_dir()?;
let mut entries = Vec::new();
// Global memory always include an entry to mirror previous behavior
let (global_active, global_disabled) = global_memory_paths(&home_dir);
let global_entry = read_memory_entry_from_paths(
&global_active,
&global_disabled,
"global".to_string(),
"global".to_string(),
None,
)?;
entries.push(global_entry);
// Project memories based on .claude.json projects keys
let project_paths = get_project_paths_for_memory(&home_dir)?;
for project_path in project_paths {
let (active, disabled) = project_memory_paths(&project_path);
// Only include project entries if a file actually exists
if !active.is_file() && !disabled.is_file() {
continue;
}
let name = std::path::Path::new(&project_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&project_path)
.to_string();
let entry = read_memory_entry_from_paths(
&active,
&disabled,
name,
"project".to_string(),
Some(project_path.clone()),
)?;
entries.push(entry);
}
// Sort: global first, then projects by name
entries.sort_by(|a, b| {
let order_a = if a.source == "global" { 0 } else { 1 };
let order_b = if b.source == "global" { 0 } else { 1 };
if order_a != order_b {
order_a.cmp(&order_b)
} else {
a.name.cmp(&b.name)
}
});
Ok(entries)
}
#[tauri::command]
pub async fn write_claude_memory_file(
source: String,
project_path: Option<String>,
content: String,
disabled: bool,
) -> Result<(), String> {
let home_dir = home_dir()?;
let (active_path, disabled_path) =
resolve_memory_paths(source.as_str(), &home_dir, &project_path)?;
// Ensure parent directory exists
if let Some(parent) = active_path.parent() {
ensure_dir(parent, "memory directory")?;
}
if disabled {
// Write to disabled path and remove active if it exists
std::fs::write(&disabled_path, content)
.map_err(|e| format!("Failed to write disabled memory file: {}", e))?;
if active_path.exists() {
std::fs::remove_file(&active_path)
.map_err(|e| format!("Failed to remove active memory file: {}", e))?;
}
} else {
// Write to active path and remove disabled if it exists
std::fs::write(&active_path, content)
.map_err(|e| format!("Failed to write memory file: {}", e))?;
if disabled_path.exists() {
std::fs::remove_file(&disabled_path)
.map_err(|e| format!("Failed to remove disabled memory file: {}", e))?;
}
}
Ok(())
}
#[tauri::command]
pub async fn toggle_claude_memory_file(
source: String,
project_path: Option<String>,
disabled: bool,
) -> Result<(), String> {
let home_dir = home_dir()?;
let (active_path, disabled_path) =
resolve_memory_paths(source.as_str(), &home_dir, &project_path)?;
let (from, to) = if disabled {
// Disable: rename active -> disabled
(&active_path, &disabled_path)
} else {
// Enable: rename disabled -> active
(&disabled_path, &active_path)
};
if !from.exists() {
return Err(format!(
"Memory file {} does not exist",
from.display()
));
}
std::fs::rename(from, to)
.map_err(|e| format!("Failed to toggle memory file: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn delete_claude_memory_file(
source: String,
project_path: Option<String>,
) -> Result<(), String> {
let home_dir = home_dir()?;
let (active_path, disabled_path) =
resolve_memory_paths(source.as_str(), &home_dir, &project_path)?;
let mut removed_any = false;
if active_path.exists() {
std::fs::remove_file(&active_path)
.map_err(|e| format!("Failed to delete memory file {}: {}", active_path.display(), e))?;
removed_any = true;
}
if disabled_path.exists() {
std::fs::remove_file(&disabled_path).map_err(|e| {
format!(
"Failed to delete disabled memory file {}: {}",
disabled_path.display(),
e
)
})?;
removed_any = true;
}
if !removed_any {
return Err("No memory file found to delete".to_string());
}
Ok(())
}
@@ -2808,6 +3085,7 @@ pub struct AgentFile {
pub name: String,
pub content: String,
pub exists: bool,
pub disabled: bool,
}
#[derive(serde::Serialize)]
@@ -2834,28 +3112,56 @@ pub async fn read_claude_agents() -> Result<Vec<AgentFile>, String> {
let mut agent_files = Vec::new();
// Read all .md files in the agents directory
// Read all .md and .md.disabled files in the agents directory
let entries = std::fs::read_dir(&agents_dir)
.map_err(|e| format!("Failed to read agents directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let entry =
entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.is_file() && path.extension().map(|ext| ext == "md").unwrap_or(false) {
let file_name = path.file_stem()
if path.is_file() {
let file_name_str = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("unknown")
.to_string();
.unwrap_or("");
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read agent file {}: {}", path.display(), e))?;
// Check if it's a .md or .md.disabled file
let (is_agent_file, is_disabled) = if file_name_str.ends_with(".md.disabled")
{
(true, true)
} else if file_name_str.ends_with(".md") {
(true, false)
} else {
(false, false)
};
agent_files.push(AgentFile {
name: file_name,
content,
exists: true,
});
if is_agent_file {
// Extract the agent name (without .md or .md.disabled)
let agent_name = if is_disabled {
file_name_str
.trim_end_matches(".md.disabled")
.to_string()
} else {
file_name_str.trim_end_matches(".md").to_string()
};
let content = std::fs::read_to_string(&path).map_err(|e| {
format!(
"Failed to read agent file {}: {}",
path.display(),
e
)
})?;
agent_files.push(AgentFile {
name: agent_name,
content,
exists: true,
disabled: is_disabled,
});
}
}
}
@@ -2884,13 +3190,54 @@ pub async fn write_claude_agent(agent_name: String, content: String) -> Result<(
pub async fn delete_claude_agent(agent_name: String) -> Result<(), String> {
let home_dir = home_dir()?;
let agents_dir = home_dir.join(".claude/agents");
let agent_file_path = agents_dir.join(format!("{}.md", agent_name));
let active_path = agents_dir.join(format!("{}.md", agent_name));
let disabled_path = agents_dir.join(format!("{}.md.disabled", agent_name));
if agent_file_path.exists() {
std::fs::remove_file(&agent_file_path)
if active_path.exists() {
std::fs::remove_file(&active_path)
.map_err(|e| format!("Failed to delete agent file: {}", e))?;
}
if disabled_path.exists() {
std::fs::remove_file(&disabled_path)
.map_err(|e| format!("Failed to delete disabled agent file: {}", e))?;
}
Ok(())
}
#[tauri::command]
pub async fn toggle_claude_agent(
agent_name: String,
disabled: bool,
) -> Result<(), String> {
let home_dir = home_dir()?;
let agents_dir = home_dir.join(".claude/agents");
let (source_path, target_path) = if disabled {
// Disable: rename from .md to .md.disabled
(
agents_dir.join(format!("{}.md", agent_name)),
agents_dir.join(format!("{}.md.disabled", agent_name)),
)
} else {
// Enable: rename from .md.disabled to .md
(
agents_dir.join(format!("{}.md.disabled", agent_name)),
agents_dir.join(format!("{}.md", agent_name)),
)
};
if !source_path.exists() {
return Err(format!(
"Agent file {} does not exist",
source_path.display()
));
}
std::fs::rename(&source_path, &target_path)
.map_err(|e| format!("Failed to toggle agent file: {}", e))?;
Ok(())
}
+5
View File
@@ -199,6 +199,10 @@ pub fn run() {
read_project_usage_files,
read_claude_memory,
write_claude_memory,
list_claude_memory_files,
write_claude_memory_file,
toggle_claude_memory_file,
delete_claude_memory_file,
track,
get_notification_settings,
update_notification_settings,
@@ -212,6 +216,7 @@ pub fn run() {
read_claude_agents,
write_claude_agent,
delete_claude_agent,
toggle_claude_agent,
read_installed_plugins,
toggle_plugin,
read_plugin_commands,
+22 -2
View File
@@ -158,10 +158,24 @@
"mcp.source": "Source",
"mcp.title": "MCP",
"mcp.toggleStatus": "Toggle Status",
"memory.description": "Manage Claude Code global memory",
"memory.description": "Manage Claude Code memory files for global and projects",
"memory.title": "Memory",
"memory.save": "Save",
"memory.saving": "Saving...",
"memory.title": "Memory",
"memory.enabled": "Enabled",
"memory.disabled": "Disabled",
"memory.enable": "Enable",
"memory.disable": "Disable",
"memory.delete": "Delete",
"memory.error": "Error loading memory: {{error}}",
"memory.noMemories": "No memory files found yet.",
"memory.globalLabel": "Global Memory",
"memory.projectLabel": "Project Memory ({{path}})",
"memory.sourceGlobal": "Global",
"memory.sourceProject": "Project",
"memory.contentPlaceholder": "CLAUDE.md content",
"memory.deleteConfirm": "Delete {{label}}? This will remove the underlying CLAUDE.md file.",
"memory.deleteTitle": "Delete memory",
"minimax.apiKeyPlaceholder": "Enter your MiniMax API Key",
"minimax.chinaMainland": "China Mainland (minimaxi.com)",
"minimax.configInternational": "Configure MiniMax (International)",
@@ -276,6 +290,12 @@
"toast.storeSaveFailed": "Failed to save config: {{error}}",
"toast.storeSaved": "Config \"{{title}}\" saved successfully",
"toast.storeSavedAndActive": "Config \"{{title}}\" saved successfully. Config has applied, please restart the Claude Code session",
"toast.memorySaved": "Memory saved successfully",
"toast.memorySaveFailed": "Failed to save memory: {{error}}",
"toast.memoryToggled": "Memory status updated successfully",
"toast.memoryToggleFailed": "Failed to update memory status: {{error}}",
"toast.memoryDeleted": "Memory deleted successfully",
"toast.memoryDeleteFailed": "Failed to delete memory: {{error}}",
"toast.updateInstallFailed": "Failed to install update: {{error}}",
"toast.updateInstalled": "Update installed successfully. Restarting...",
"updateButton.installing": "Installing...",
+22 -2
View File
@@ -142,10 +142,24 @@
"mcp.source": "Source",
"mcp.title": "MCP",
"mcp.toggleStatus": "Basculer le statut",
"memory.description": "Gérer la mémoire globale de Claude Code",
"memory.description": "Gérer les fichiers de mémoire Claude Code pour global et projets",
"memory.title": "Mémoire",
"memory.save": "Sauvegarder",
"memory.saving": "Sauvegarde...",
"memory.title": "Mémoire",
"memory.enabled": "Activée",
"memory.disabled": "Désactivée",
"memory.enable": "Activer",
"memory.disable": "Désactiver",
"memory.delete": "Supprimer",
"memory.error": "Erreur lors du chargement de la mémoire : {{error}}",
"memory.noMemories": "Aucun fichier de mémoire trouvé.",
"memory.globalLabel": "Mémoire globale",
"memory.projectLabel": "Mémoire projet ({{path}})",
"memory.sourceGlobal": "Global",
"memory.sourceProject": "Projet",
"memory.contentPlaceholder": "Contenu de CLAUDE.md",
"memory.deleteConfirm": "Supprimer {{label}} ? Cela supprimera le fichier CLAUDE.md sous-jacent.",
"memory.deleteTitle": "Supprimer la mémoire",
"minimax.apiKeyPlaceholder": "Entrez votre clé API MiniMax",
"minimax.chinaMainland": "Chine continentale (minimaxi.com)",
"minimax.configInternational": "Configurer MiniMax (International)",
@@ -258,6 +272,12 @@
"toast.storeSaveFailed": "Échec de la sauvegarde de la configuration : {{error}}",
"toast.storeSaved": "Configuration \"{{title}}\" sauvegardée avec succès",
"toast.storeSavedAndActive": "Configuration \"{{title}}\" sauvegardée avec succès. La configuration a été appliquée, veuillez redémarrer la session Claude Code",
"toast.memorySaved": "Mémoire sauvegardée avec succès",
"toast.memorySaveFailed": "Échec de la sauvegarde de la mémoire : {{error}}",
"toast.memoryToggled": "Statut de la mémoire mis à jour avec succès",
"toast.memoryToggleFailed": "Échec de la mise à jour du statut de la mémoire : {{error}}",
"toast.memoryDeleted": "Mémoire supprimée avec succès",
"toast.memoryDeleteFailed": "Échec de la suppression de la mémoire : {{error}}",
"toast.updateInstallFailed": "Échec de l'installation de la mise à jour : {{error}}",
"toast.updateInstalled": "Mise à jour installée avec succès. Redémarrage...",
"updateButton.installing": "Installation...",
+22 -2
View File
@@ -142,10 +142,24 @@
"mcp.source": "ソース",
"mcp.title": "MCP",
"mcp.toggleStatus": "ステータスを切り替え",
"memory.description": "Claude Code グローバルメモリを管理",
"memory.description": "Claude Code グローバルおよびプロジェクト用メモリファイルを管理",
"memory.title": "メモリ",
"memory.save": "保存",
"memory.saving": "保存中...",
"memory.title": "メモリ",
"memory.enabled": "有効",
"memory.disabled": "無効",
"memory.enable": "有効化",
"memory.disable": "無効化",
"memory.delete": "削除",
"memory.error": "メモリの読み込みエラー:{{error}}",
"memory.noMemories": "メモリファイルがまだありません。",
"memory.globalLabel": "グローバルメモリ",
"memory.projectLabel": "プロジェクトメモリ({{path}}",
"memory.sourceGlobal": "グローバル",
"memory.sourceProject": "プロジェクト",
"memory.contentPlaceholder": "CLAUDE.md の内容",
"memory.deleteConfirm": "{{label}} を削除しますか?対応する CLAUDE.md ファイルも削除されます。",
"memory.deleteTitle": "メモリを削除",
"minimax.apiKeyPlaceholder": "MiniMax API キーを入力してください",
"minimax.chinaMainland": "中国大陸 (minimaxi.com)",
"minimax.configInternational": "MiniMax (国際) を設定",
@@ -258,6 +272,12 @@
"toast.storeSaveFailed": "設定の保存に失敗しました:{{error}}",
"toast.storeSaved": "設定 \"{{title}}\" が正常に保存されました",
"toast.storeSavedAndActive": "設定 \"{{title}}\" が正常に保存されました。設定が適用されました。Claude Code セッションを再起動してください",
"toast.memorySaved": "メモリが正常に保存されました",
"toast.memorySaveFailed": "メモリの保存に失敗しました:{{error}}",
"toast.memoryToggled": "メモリのステータスが正常に更新されました",
"toast.memoryToggleFailed": "メモリのステータス更新に失敗しました:{{error}}",
"toast.memoryDeleted": "メモリが正常に削除されました",
"toast.memoryDeleteFailed": "メモリの削除に失敗しました:{{error}}",
"toast.updateInstallFailed": "更新のインストールに失敗しました:{{error}}",
"toast.updateInstalled": "更新が正常にインストールされました。再起動中...",
"updateButton.installing": "インストール中...",
+22 -2
View File
@@ -154,10 +154,24 @@
"mcp.source": "源码",
"mcp.title": "MCP",
"mcp.toggleStatus": "切换状态",
"memory.description": "管理 Claude Code 全局记忆",
"memory.description": "管理 Claude Code 全局和项目记忆文件",
"memory.title": "记忆",
"memory.save": "保存",
"memory.saving": "保存中...",
"memory.title": "记忆",
"memory.enabled": "已启用",
"memory.disabled": "已禁用",
"memory.enable": "启用",
"memory.disable": "禁用",
"memory.delete": "删除",
"memory.error": "加载记忆时出错:{{error}}",
"memory.noMemories": "尚未找到记忆文件。",
"memory.globalLabel": "全局记忆",
"memory.projectLabel": "项目记忆({{path}}",
"memory.sourceGlobal": "全局",
"memory.sourceProject": "项目",
"memory.contentPlaceholder": "CLAUDE.md 内容",
"memory.deleteConfirm": "删除 {{label}}?这将删除底层的 CLAUDE.md 文件。",
"memory.deleteTitle": "删除记忆",
"minimax.apiKeyPlaceholder": "请输入您的 MiniMax API Key",
"minimax.chinaMainland": "中国大陆 (minimaxi.com)",
"minimax.configInternational": "配置 MiniMax (国际)",
@@ -270,6 +284,12 @@
"toast.storeSaveFailed": "保存配置失败:{{error}}",
"toast.storeSaved": "配置 \"{{title}}\" 保存成功",
"toast.storeSavedAndActive": "配置 \"{{title}}\" 保存成功。配置已应用,请重启 Claude Code 会话",
"toast.memorySaved": "记忆保存成功",
"toast.memorySaveFailed": "保存记忆失败:{{error}}",
"toast.memoryToggled": "记忆状态更新成功",
"toast.memoryToggleFailed": "更新记忆状态失败:{{error}}",
"toast.memoryDeleted": "记忆删除成功",
"toast.memoryDeleteFailed": "删除记忆失败:{{error}}",
"toast.updateInstallFailed": "安装更新失败:{{error}}",
"toast.updateInstalled": "更新安装成功,正在重启...",
"updateButton.installing": "安装中...",
+145 -2
View File
@@ -511,6 +511,16 @@ export interface MemoryFile {
exists: boolean;
}
export interface MemoryEntry {
name: string;
path: string;
content: string;
exists: boolean;
source: "global" | "project";
projectPath?: string;
disabled: boolean;
}
export const useClaudeMemory = () => {
return useSuspenseQuery({
queryKey: ["claude-memory"],
@@ -525,13 +535,122 @@ export const useWriteClaudeMemory = () => {
mutationFn: (content: string) =>
invoke<void>("write_claude_memory", { content }),
onSuccess: () => {
toast.success("Memory saved successfully");
toast.success(i18n.t("toast.memorySaved"));
queryClient.invalidateQueries({ queryKey: ["claude-memory"] });
queryClient.invalidateQueries({ queryKey: ["claude-memory-files"] });
},
onError: (error) => {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
i18n.t("toast.memorySaveFailed", { error: errorMessage }),
);
},
});
};
export const useClaudeMemoryFiles = () => {
return useQuery({
queryKey: ["claude-memory-files"],
queryFn: () => invoke<MemoryEntry[]>("list_claude_memory_files"),
});
};
export const useWriteClaudeMemoryFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
source,
projectPath,
content,
disabled,
}: {
source: "global" | "project";
projectPath?: string;
content: string;
disabled: boolean;
}) =>
invoke<void>("write_claude_memory_file", {
source,
projectPath,
content,
disabled,
}),
onSuccess: () => {
toast.success(i18n.t("toast.memorySaved"));
queryClient.invalidateQueries({ queryKey: ["claude-memory-files"] });
queryClient.invalidateQueries({ queryKey: ["claude-memory"] });
},
onError: (error) => {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to save memory: ${errorMessage}`);
toast.error(
i18n.t("toast.memorySaveFailed", { error: errorMessage }),
);
},
});
};
export const useToggleClaudeMemoryFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
source,
projectPath,
disabled,
}: {
source: "global" | "project";
projectPath?: string;
disabled: boolean;
}) =>
invoke<void>("toggle_claude_memory_file", {
source,
projectPath,
disabled,
}),
onSuccess: () => {
toast.success(i18n.t("toast.memoryToggled"));
queryClient.invalidateQueries({ queryKey: ["claude-memory-files"] });
queryClient.invalidateQueries({ queryKey: ["claude-memory"] });
},
onError: (error) => {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
i18n.t("toast.memoryToggleFailed", { error: errorMessage }),
);
},
});
};
export const useDeleteClaudeMemoryFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
source,
projectPath,
}: {
source: "global" | "project";
projectPath?: string;
}) =>
invoke<void>("delete_claude_memory_file", {
source,
projectPath,
}),
onSuccess: () => {
toast.success(i18n.t("toast.memoryDeleted"));
queryClient.invalidateQueries({ queryKey: ["claude-memory-files"] });
queryClient.invalidateQueries({ queryKey: ["claude-memory"] });
},
onError: (error) => {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
i18n.t("toast.memoryDeleteFailed", { error: errorMessage }),
);
},
});
};
@@ -728,6 +847,30 @@ export const useWriteClaudeAgent = () => {
});
};
export const useToggleClaudeAgent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
agentName,
disabled,
}: {
agentName: string;
disabled: boolean;
}) => invoke<void>("toggle_claude_agent", { agentName, disabled }),
onSuccess: () => {
toast.success("Agent toggled successfully");
queryClient.invalidateQueries({ queryKey: ["claude-agents"] });
queryClient.invalidateQueries({ queryKey: ["plugin-agents"] });
},
onError: (error) => {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`Failed to toggle agent: ${errorMessage}`);
},
});
};
export const useDeleteClaudeAgent = () => {
const queryClient = useQueryClient();
+111 -44
View File
@@ -27,6 +27,7 @@ import {
useClaudeAgents,
useDeleteClaudeAgent,
usePluginAgents,
useToggleClaudeAgent,
useWriteClaudeAgent,
} from "@/lib/query";
import { useCodeMirrorTheme } from "@/lib/use-codemirror-theme";
@@ -35,6 +36,7 @@ type UnifiedAgent = {
name: string;
content: string;
exists: boolean;
disabled: boolean;
source: "user" | "plugin";
pluginName?: string;
pluginScope?: string;
@@ -47,6 +49,7 @@ function AgentsPageContent() {
const { data: pluginAgents, isLoading: isLoadingPlugin, error: errorPlugin } = usePluginAgents();
const writeAgent = useWriteClaudeAgent();
const deleteAgent = useDeleteClaudeAgent();
const toggleAgent = useToggleClaudeAgent();
const [agentEdits, setAgentEdits] = useState<Record<string, string>>({});
const [isDialogOpen, setIsDialogOpen] = useState(false);
const codeMirrorTheme = useCodeMirrorTheme();
@@ -54,24 +57,40 @@ function AgentsPageContent() {
const isLoading = isLoadingUser || isLoadingPlugin;
const error = errorUser || errorPlugin;
const sourceOrder: Record<UnifiedAgent["source"], number> = {
user: 0,
plugin: 1,
};
const agents: UnifiedAgent[] = [
...(userAgents || []).map((agent): UnifiedAgent => ({
name: agent.name,
content: agent.content,
exists: agent.exists,
disabled: agent.disabled,
source: "user",
sourcePath: `~/.claude/agents/${agent.name}.md`,
sourcePath: `~/.claude/agents/${agent.name}${agent.disabled ? ".md.disabled" : ".md"}`,
})),
...(pluginAgents || []).map((agent): UnifiedAgent => ({
name: agent.name,
content: agent.content,
exists: agent.exists,
disabled: false,
source: "plugin",
pluginName: agent.pluginName,
pluginScope: agent.pluginScope,
sourcePath: agent.sourcePath,
})),
].sort((a, b) => a.name.localeCompare(b.name));
].sort((a, b) => {
const orderA = sourceOrder[a.source] ?? 99;
const orderB = sourceOrder[b.source] ?? 99;
if (orderA !== orderB) {
return orderA - orderB;
}
return a.name.localeCompare(b.name);
});
if (isLoading) {
return (
@@ -119,6 +138,17 @@ function AgentsPageContent() {
}
};
const handleToggleAgent = (agent: UnifiedAgent) => {
if (agent.source !== "user") {
return;
}
toggleAgent.mutate({
agentName: agent.name,
disabled: !agent.disabled,
});
};
return (
<div>
<div
@@ -167,26 +197,46 @@ function AgentsPageContent() {
className="bg-card"
>
<AccordionTrigger className="hover:no-underline px-4 py-2 bg-card hover:bg-accent duration-150">
<div className="flex items-center gap-2 flex-wrap">
<BotIcon size={12} />
<span className="font-medium">{agent.name}</span>
{agent.source === "user" ? (
<Badge variant="default">
{t("agents.sourceUser")}
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-wrap">
<BotIcon size={12} />
<span className="font-medium">{agent.name}</span>
<Badge
variant={agent.disabled ? "destructive" : "success"}
>
{agent.disabled ? "Disabled" : "Enabled"}
</Badge>
) : (
<>
<Badge variant="outline">
{agent.pluginName}
<span className="text-xs text-muted-foreground font-mono truncate max-w-xs">
{agent.sourcePath}
</span>
</div>
<div className="flex items-center gap-2">
{agent.source === "user" && (
<Badge variant="secondary">
{t("agents.sourceUser")}
</Badge>
<Badge variant={agent.pluginScope === "user" ? "default" : "secondary"}>
{agent.pluginScope === "user" ? t("plugins.scopeUser") : t("plugins.scopeLocal")}
</Badge>
</>
)}
<span className="text-sm text-muted-foreground font-normal">
{agent.sourcePath}
</span>
)}
{agent.source === "plugin" && agent.pluginName && (
<>
<Badge variant="secondary">
{`Plugins (${agent.pluginName})`}
</Badge>
{agent.pluginScope && (
<Badge
variant={
agent.pluginScope === "user"
? "default"
: "secondary"
}
>
{agent.pluginScope === "user"
? t("plugins.scopeUser")
: t("plugins.scopeLocal")}
</Badge>
)}
</>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -204,30 +254,47 @@ function AgentsPageContent() {
basicSetup={codeMirrorBasicSetup}
/>
</div>
<div className="flex justify-between bg-card">
<Button
variant="outline"
onClick={() => handleSaveAgent(agent.name)}
disabled={
writeAgent.isPending ||
agentEdits[agent.name] === undefined
}
size="sm"
>
<SaveIcon size={14} />
{writeAgent.isPending
? t("agents.saving")
: t("agents.save")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteAgent(agent.name)}
disabled={deleteAgent.isPending}
>
<TrashIcon size={14} />
</Button>
<div className="flex justify-between bg-card px-1 py-1">
<div className="flex items-center text-xs text-muted-foreground font-mono">
<span className="truncate max-w-xs">
{agent.sourcePath}
</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSaveAgent(agent.name)}
disabled={
writeAgent.isPending ||
agentEdits[agent.name] === undefined
}
>
<SaveIcon size={12} className="mr-1" />
{writeAgent.isPending
? t("agents.saving")
: t("agents.save")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleToggleAgent(agent)}
disabled={
agent.source !== "user" || toggleAgent.isPending
}
>
{agent.disabled ? "Enable" : "Disable"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteAgent(agent.name)}
disabled={deleteAgent.isPending}
>
<TrashIcon size={12} className="mr-1" />
Delete
</Button>
</div>
</div>
</div>
</AccordionContent>
+64 -40
View File
@@ -176,29 +176,45 @@ function CommandsPageContent() {
className="bg-card"
>
<AccordionTrigger className="hover:no-underline px-4 py-2 bg-card hover:bg-accent duration-150">
<div className="flex items-center gap-2 flex-wrap">
<TerminalIcon size={12} />
<span className="font-medium">{command.name}</span>
<Badge variant={command.disabled ? "secondary" : "success"}>
{command.disabled ? t("commands.disabled") : t("commands.enabled")}
</Badge>
{command.source === "user" ? (
<Badge variant="default">
{t("commands.sourceUser")}
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-wrap">
<TerminalIcon size={12} />
<span className="font-medium">{command.name}</span>
<Badge
variant={command.disabled ? "secondary" : "success"}
>
{command.disabled
? t("commands.disabled")
: t("commands.enabled")}
</Badge>
) : (
<>
<Badge variant="outline">
{command.pluginName}
<span className="text-xs text-muted-foreground font-mono truncate max-w-xs">
{command.sourcePath}
</span>
</div>
<div className="flex items-center gap-2">
{command.source === "user" ? (
<Badge variant="default">
{t("commands.sourceUser")}
</Badge>
<Badge variant={command.pluginScope === "user" ? "default" : "secondary"}>
{command.pluginScope === "user" ? t("plugins.scopeUser") : t("plugins.scopeLocal")}
</Badge>
</>
)}
<span className="text-sm text-muted-foreground font-normal">
{command.sourcePath}
</span>
) : (
<>
<Badge variant="outline">
{command.pluginName}
</Badge>
<Badge
variant={
command.pluginScope === "user"
? "default"
: "secondary"
}
>
{command.pluginScope === "user"
? t("plugins.scopeUser")
: t("plugins.scopeLocal")}
</Badge>
</>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -216,41 +232,49 @@ function CommandsPageContent() {
basicSetup={codeMirrorBasicSetup}
/>
</div>
<div className="flex justify-between bg-card">
<div className="flex justify-between bg-card px-1 py-1">
<div className="flex items-center text-xs text-muted-foreground font-mono">
<span className="truncate max-w-xs">
{command.sourcePath}
</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSaveCommand(command.name)}
disabled={
writeCommand.isPending ||
commandEdits[command.name] === undefined
}
size="sm"
>
<SaveIcon size={14} />
<SaveIcon size={12} className="mr-1" />
{writeCommand.isPending
? t("commands.saving")
: t("commands.save")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleToggleCommand(command.name, command.disabled)}
onClick={() =>
handleToggleCommand(command.name, command.disabled)
}
disabled={toggleCommand.isPending}
>
{command.disabled ? t("commands.enable") : t("commands.disable")}
{command.disabled
? t("commands.enable")
: t("commands.disable")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteCommand(command.name)}
disabled={deleteCommand.isPending}
>
<TrashIcon size={12} className="mr-1" />
Delete
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteCommand(command.name)}
disabled={deleteCommand.isPending}
>
<TrashIcon size={14} />
</Button>
</div>
</div>
</AccordionContent>
@@ -350,12 +374,12 @@ function CreateCommandPanel({ onClose }: CreateCommandPanelProps) {
<div className="rounded-lg overflow-hidden border">
<CodeMirror
value={commandContent}
onChange={(value) => setCommandContent(value)}
height="200px"
theme={codeMirrorTheme}
placeholder={t("commands.contentPlaceholder")}
extensions={markdownExtensions}
basicSetup={codeMirrorBasicSetup}
onChange={(value) => setCommandContent(value)}
placeholder={t("commands.contentPlaceholder")}
extensions={markdownExtensions}
basicSetup={codeMirrorBasicSetup}
/>
</div>
</div>
+255 -85
View File
@@ -1,46 +1,44 @@
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { yamlFrontmatter } from "@codemirror/lang-yaml";
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { SaveIcon } from "lucide-react";
import { Suspense, useEffect, useState } from "react";
import { ask } from "@tauri-apps/plugin-dialog";
import CodeMirror from "@uiw/react-codemirror";
import { SaveIcon, SparklesIcon, TrashIcon } from "lucide-react";
import { Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { useClaudeMemory, useWriteClaudeMemory } from "@/lib/query";
import {
type MemoryEntry,
useClaudeMemoryFiles,
useDeleteClaudeMemoryFile,
useToggleClaudeMemoryFile,
useWriteClaudeMemoryFile,
} from "@/lib/query";
import { codeMirrorBasicSetup, markdownExtensions } from "@/lib/codemirror-config";
import { useCodeMirrorTheme } from "@/lib/use-codemirror-theme";
function MemoryPageHeader({
onSave,
saving,
}: {
onSave: () => void;
saving: boolean;
}) {
function MemoryPageHeader() {
const { t } = useTranslation();
return (
<div
className="flex items-center p-3 border-b px-3 justify-between sticky top-0 bg-background z-10"
className="flex items-center justify-between sticky top-0 z-10 border-b p-3 bg-background"
data-tauri-drag-region
>
<div data-tauri-drag-region>
<h3 className="font-bold" data-tauri-drag-region>
{t("memory.title")}
</h3>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground" data-tauri-drag-region>
{t("memory.description")}
</p>
</div>
<Button
onClick={onSave}
disabled={saving}
variant="default"
size="sm"
className="flex items-center gap-2"
>
<SaveIcon className="w-4 h-4" />
{saving ? t("memory.saving") : t("memory.save")}
</Button>
</div>
);
}
@@ -76,76 +74,248 @@ function MemoryPageSkeleton() {
}
function MemoryPageContent() {
const { data: memoryData } = useClaudeMemory();
const { mutate: saveMemory, isPending: saving } = useWriteClaudeMemory();
const [content, setContent] = useState<string>("");
const { t } = useTranslation();
const { data: memories, isLoading, error } = useClaudeMemoryFiles();
const writeMemory = useWriteClaudeMemoryFile();
const toggleMemory = useToggleClaudeMemoryFile();
const deleteMemory = useDeleteClaudeMemoryFile();
const [edits, setEdits] = useState<Record<string, string>>({});
const codeMirrorTheme = useCodeMirrorTheme();
// Update local content when memory data loads
useEffect(() => {
if (memoryData?.content) {
setContent(memoryData.content);
}
}, [memoryData]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">{t("loading")}</div>
</div>
);
}
const handleSave = () => {
saveMemory(content);
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center text-red-500">
{t("memory.error", { error: (error as Error).message })}
</div>
</div>
);
}
const memoryList = (memories ?? []).slice();
const getEntryKey = (entry: MemoryEntry) =>
entry.source === "global" ? "global" : entry.projectPath ?? entry.name;
const getProjectDisplayName = (projectPath?: string, fallback?: string) => {
if (!projectPath) return fallback ?? "";
const parts = projectPath.split(/[/\\]/).filter(Boolean);
return parts[parts.length - 1] ?? projectPath;
};
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd+S or Ctrl+S to save
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
handleSave();
}
const handleSaveMemory = (entry: MemoryEntry) => {
const key = getEntryKey(entry);
const content = edits[key] ?? entry.content;
writeMemory.mutate({
source: entry.source,
projectPath: entry.projectPath,
content,
disabled: entry.disabled,
});
};
// Add keyboard event listener
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [content]);
const handleToggleMemory = (entry: MemoryEntry) => {
toggleMemory.mutate({
source: entry.source,
projectPath: entry.projectPath,
disabled: !entry.disabled,
});
};
const handleDeleteMemory = async (entry: MemoryEntry) => {
const label =
entry.source === "global"
? t("memory.globalLabel")
: t("memory.projectLabel", {
path: getProjectDisplayName(entry.projectPath, entry.name),
});
const confirmed = await ask(
t("memory.deleteConfirm", { label }),
{
title: t("memory.deleteTitle"),
kind: "warning",
},
);
if (!confirmed) {
return;
}
deleteMemory.mutate({
source: entry.source,
projectPath: entry.projectPath,
});
};
return (
<div className="flex flex-col h-screen">
<MemoryPageHeader onSave={handleSave} saving={saving} />
<div>
<MemoryPageHeader />
<div>
{memoryList.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{t("memory.noMemories")}
</div>
) : (
<ScrollArea className="h-full">
<div>
<Accordion type="multiple">
{memoryList.map((entry) => {
const key = getEntryKey(entry);
const label =
entry.source === "global"
? t("memory.globalLabel")
: t("memory.projectLabel", {
path: getProjectDisplayName(
entry.projectPath,
entry.name,
),
});
<div className="flex-1 p-4 overflow-hidden">
<div className="rounded-lg overflow-hidden border h-full">
<CodeMirror
value={content}
height="100%"
extensions={[
yamlFrontmatter({
content: markdown({
base: markdownLanguage,
}),
}),
EditorView.lineWrapping,
]}
placeholder="~/.claude/CLAUDE.md"
onChange={(value) => setContent(value)}
theme={codeMirrorTheme}
basicSetup={{
lineNumbers: false,
highlightActiveLineGutter: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
searchKeymap: false,
}}
className="h-full"
style={{ width: "100%" }}
/>
</div>
return (
<AccordionItem
key={key}
value={key}
className="bg-card"
>
<AccordionTrigger className="hover:no-underline px-4 py-2 bg-card hover:bg-accent duration-150">
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-wrap">
<SparklesIcon size={12} />
<span className="font-medium">
{label}
</span>
<Badge
variant={
entry.disabled
? "destructive"
: "success"
}
>
{entry.disabled
? t("memory.disabled")
: t("memory.enabled")}
</Badge>
{entry.source === "project" &&
entry.projectPath && (
<span className="text-xs text-muted-foreground font-mono truncate max-w-xs">
{entry.projectPath}
</span>
)}
</div>
<div className="flex items-center gap-2">
{entry.source === "global" && (
<Badge variant="secondary">
{t("memory.sourceGlobal")}
</Badge>
)}
{entry.source === "project" && (
<Badge variant="secondary">
{t("memory.sourceProject")}
</Badge>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="px-3 pt-3 space-y-3">
<div className="rounded-lg overflow-hidden border">
<CodeMirror
value={
edits[key] ?? entry.content
}
height="280px"
theme={codeMirrorTheme}
onChange={(value) =>
setEdits((prev) => ({
...prev,
[key]: value,
}))
}
placeholder={t(
"memory.contentPlaceholder",
)}
extensions={markdownExtensions}
basicSetup={codeMirrorBasicSetup}
/>
</div>
<div className="flex justify-between bg-card px-1 py-1">
<div className="flex items-center text-xs text-muted-foreground font-mono">
<span className="truncate max-w-xs">
{entry.path}
</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
handleSaveMemory(entry)
}
disabled={
writeMemory.isPending
}
>
<SaveIcon
size={12}
className="mr-1"
/>
{writeMemory.isPending
? t("memory.saving")
: t("memory.save")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
handleToggleMemory(entry)
}
disabled={
toggleMemory.isPending
}
>
{entry.disabled
? t("memory.enable")
: t("memory.disable")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
void handleDeleteMemory(
entry,
);
}}
disabled={
deleteMemory.isPending
}
>
<TrashIcon
size={12}
className="mr-1"
/>
{t("memory.delete")}
</Button>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</div>
</ScrollArea>
)}
</div>
</div>
);