diff --git a/README.md b/README.md index 9b6708e8..1679af8e 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,8 @@ cc-switch prompts list # List prompt presets cc-switch prompts current # Show current active prompt cc-switch prompts activate # Activate prompt cc-switch prompts deactivate # Deactivate current active prompt -cc-switch prompts create # Create new prompt preset +cc-switch prompts create [name] # Create a prompt preset, optionally naming it up front +cc-switch prompts rename [name] # Rename prompt preset, interactive if name is omitted cc-switch prompts edit # Edit prompt preset cc-switch prompts show # Display full content cc-switch prompts delete # Delete prompt diff --git a/README_ZH.md b/README_ZH.md index 57111d3c..8e5803fc 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -290,7 +290,8 @@ cc-switch prompts list # 列出提示词预设 cc-switch prompts current # 显示当前活动提示词 cc-switch prompts activate # 激活提示词 cc-switch prompts deactivate # 停用当前激活的提示词 -cc-switch prompts create # 创建新提示词预设 +cc-switch prompts create [name] # 创建新提示词预设,可直接指定名称 +cc-switch prompts rename [name] # 重命名提示词预设,不传名称时进入交互 cc-switch prompts edit # 编辑提示词预设 cc-switch prompts show # 显示完整内容 cc-switch prompts delete # 删除提示词 diff --git a/src-tauri/src/cli/commands/prompts.rs b/src-tauri/src/cli/commands/prompts.rs index 3e746c02..4c49f8e9 100644 --- a/src-tauri/src/cli/commands/prompts.rs +++ b/src-tauri/src/cli/commands/prompts.rs @@ -3,7 +3,6 @@ use clap::Subcommand; use crate::app_config::AppType; use crate::cli::ui::{create_table, highlight, info, success}; use crate::error::AppError; -use crate::prompt::Prompt; use crate::services::PromptService; use crate::store::AppState; @@ -21,12 +20,22 @@ pub enum PromptsCommand { /// Deactivate the current active prompt Deactivate, /// Create a new prompt preset - Create, + Create { + /// Prompt preset name + name: Option, + }, /// Edit a prompt preset Edit { /// Prompt preset ID id: String, }, + /// Rename a prompt preset + Rename { + /// Prompt preset ID + id: String, + /// New prompt name + name: Option, + }, /// Delete a prompt preset Delete { /// Prompt preset ID @@ -47,8 +56,9 @@ pub fn execute(cmd: PromptsCommand, app: Option) -> Result<(), AppError PromptsCommand::Current => show_current(app_type), PromptsCommand::Activate { id } => activate_prompt(app_type, &id), PromptsCommand::Deactivate => deactivate_prompt(app_type), - PromptsCommand::Create => create_prompt(app_type), + PromptsCommand::Create { name } => create_prompt(app_type, name), PromptsCommand::Edit { id } => edit_prompt(app_type, &id), + PromptsCommand::Rename { id, name } => rename_prompt(app_type, &id, name), PromptsCommand::Delete { id } => delete_prompt(app_type, &id), PromptsCommand::Show { id } => show_prompt(app_type, &id), } @@ -296,36 +306,37 @@ fn show_prompt(app_type: AppType, id: &str) -> Result<(), AppError> { Ok(()) } -fn create_prompt(_app_type: AppType) -> Result<(), AppError> { +fn create_prompt(app_type: AppType, name: Option) -> Result<(), AppError> { let state = get_state()?; - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - let id = format!("prompt-{timestamp}"); + let default_name = format!("Prompt {}", chrono::Local::now().format("%Y-%m-%d %H:%M")); + let name = match name { + Some(name) => name, + None => inquire::Text::new("Prompt name:") + .with_initial_value(&default_name) + .prompt() + .map_err(|e| AppError::Message(format!("Prompt failed: {}", e)))?, + }; + let trimmed_name = name.trim(); + if trimmed_name.is_empty() { + return Err(AppError::InvalidInput( + "Prompt preset name cannot be empty".to_string(), + )); + } - let name = format!("Prompt {}", chrono::Local::now().format("%Y-%m-%d %H:%M")); let initial = "# Write your prompt here\n"; println!("{}", highlight("Create New Prompt Preset")); println!("{}", info("Opening external editor...")); let edited = crate::cli::editor::open_external_editor(initial)?; + let prompt = PromptService::create_prompt(&state, app_type.clone(), trimmed_name, &edited)?; - let content = edited.trim_end().to_string(); - let prompt = Prompt { - id: id.clone(), - name, - content, - description: None, - enabled: false, - created_at: Some(timestamp), - updated_at: Some(timestamp), - }; - - PromptService::upsert_prompt(&state, _app_type.clone(), &id, prompt)?; - - println!("{}", success(&format!("✓ Created prompt preset '{id}'"))); + println!( + "{}", + success(&format!("✓ Created prompt preset '{}'", prompt.id)) + ); + println!("{}", info(&format!(" Name: {}", prompt.name))); + println!("{}", info(&format!(" Application: {}", app_type.as_str()))); println!( "{}", info("Tip: Use 'cc-switch prompts list' to view all presets.") @@ -399,3 +410,40 @@ fn edit_prompt(_app_type: AppType, id: &str) -> Result<(), AppError> { println!("{}", success(&format!("✓ Updated prompt preset '{id}'"))); Ok(()) } + +fn rename_prompt(app_type: AppType, id: &str, name: Option) -> Result<(), AppError> { + let state = get_state()?; + let prompts = PromptService::get_prompts(&state, app_type.clone())?; + let Some(prompt) = prompts.get(id) else { + return Err(AppError::InvalidInput(format!( + "Prompt preset '{id}' not found" + ))); + }; + + let new_name = match name { + Some(name) => name, + None => inquire::Text::new("New prompt name:") + .with_initial_value(&prompt.name) + .prompt() + .map_err(|e| AppError::Message(format!("Prompt failed: {}", e)))?, + }; + + let trimmed = new_name.trim(); + if trimmed.is_empty() { + return Err(AppError::InvalidInput( + "Prompt preset name cannot be empty".to_string(), + )); + } + + if trimmed == prompt.name { + println!("{}", info("No changes detected.")); + return Ok(()); + } + + PromptService::rename_prompt(&state, app_type.clone(), id, trimmed)?; + + println!("{}", success(&format!("✓ Renamed prompt preset '{id}'"))); + println!("{}", info(&format!(" Name: {}", trimmed))); + println!("{}", info(&format!(" Application: {}", app_type.as_str()))); + Ok(()) +} diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 8dcdbfec..c3928d6a 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -509,9 +509,9 @@ pub mod texts { pub fn tui_footer_action_keys_prompts() -> &'static str { if is_chinese() { - "[ ] 切换应用 Enter 查看 a 激活 x 取消激活 e 编辑 d 删除 / 过滤 Esc 返回 ? 帮助" + "[ ] 切换应用 c 新建 r 刷新 Enter 查看 a 激活 x 取消激活 n 重命名 e 编辑 d 删除 / 过滤 Esc 返回 ? 帮助" } else { - "[ ] switch app Enter view a activate x deactivate e edit d delete / filter Esc back ? help" + "[ ] switch app c create r refresh Enter view a activate x deactivate n rename e edit d delete / filter Esc back ? help" } } @@ -565,9 +565,9 @@ pub mod texts { pub fn tui_help_text() -> &'static str { if is_chinese() { - "[ ] 切换应用\n←→ 切换菜单/内容焦点\n↑↓ 移动\n/ 过滤\nEsc 返回\n? 显示/关闭帮助\n\n页面快捷键(在页面内容区顶部显示):\n- 供应商:Enter 详情,s 切换/添加移除,a 添加,e 编辑,d 删除,t 测速,c 健康检查\n- 供应商详情:s 切换/添加移除,e 编辑,t 测速,c 健康检查\n- MCP:x 启用/禁用(当前应用),m 选择应用,a 添加,e 编辑,i 导入已有,d 删除\n- 提示词:Enter 查看,a 激活,x 取消激活(当前),e 编辑,d 删除\n- 技能:Enter 详情,x 启用/禁用(当前应用),m 选择应用,d 卸载,i 导入已有\n- 配置:Enter 打开/执行,e 编辑片段\n- 设置:Enter 应用" + "[ ] 切换应用\n←→ 切换菜单/内容焦点\n↑↓ 移动\n/ 过滤\nEsc 返回\n? 显示/关闭帮助\n\n页面快捷键(在页面内容区顶部显示):\n- 供应商:Enter 详情,s 切换/添加移除,a 添加,e 编辑,d 删除,t 测速,c 健康检查\n- 供应商详情:s 切换/添加移除,e 编辑,t 测速,c 健康检查\n- MCP:x 启用/禁用(当前应用),m 选择应用,a 添加,e 编辑,i 导入已有,d 删除\n- 提示词:c 新建,r 刷新,Enter 查看,a 激活,x 取消激活(当前),n 重命名,e 编辑,d 删除\n- 技能:Enter 详情,x 启用/禁用(当前应用),m 选择应用,d 卸载,i 导入已有\n- 配置:Enter 打开/执行,e 编辑片段\n- 设置:Enter 应用" } else { - "[ ] switch app\n←→ focus menu/content\n↑↓ move\n/ filter\nEsc back\n? toggle help\n\nPage keys (shown at the top of each page):\n- Providers: Enter details, s switch/add-remove, a add, e edit, d delete, t speedtest, c stream check\n- Provider Detail: s switch/add-remove, e edit, t speedtest, c stream check\n- MCP: x toggle current, m select apps, a add, e edit, i import existing, d delete\n- Prompts: Enter view, a activate, x deactivate active, e edit, d delete\n- Skills: Enter details, x toggle current, m select apps, d uninstall, i import existing\n- Config: Enter open/run, e edit snippet\n- Settings: Enter apply" + "[ ] switch app\n←→ focus menu/content\n↑↓ move\n/ filter\nEsc back\n? toggle help\n\nPage keys (shown at the top of each page):\n- Providers: Enter details, s switch/add-remove, a add, e edit, d delete, t speedtest, c stream check\n- Provider Detail: s switch/add-remove, e edit, t speedtest, c stream check\n- MCP: x toggle current, m select apps, a add, e edit, i import existing, d delete\n- Prompts: c create, r refresh, Enter view, a activate, x deactivate active, n rename, e edit, d delete\n- Skills: Enter details, x toggle current, m select apps, d uninstall, i import existing\n- Config: Enter open/run, e edit snippet\n- Settings: Enter apply" } } @@ -2450,6 +2450,14 @@ pub mod texts { } } + pub fn tui_key_rename() -> &'static str { + if is_chinese() { + "重命名" + } else { + "rename" + } + } + pub fn tui_key_apply() -> &'static str { if is_chinese() { "应用" @@ -4499,6 +4507,38 @@ pub mod texts { } } + pub fn tui_prompt_rename_title() -> &'static str { + if is_chinese() { + "重命名提示词" + } else { + "Rename Prompt" + } + } + + pub fn tui_prompt_create_title() -> &'static str { + if is_chinese() { + "创建提示词" + } else { + "Create Prompt" + } + } + + pub fn tui_prompt_create_prompt() -> &'static str { + if is_chinese() { + "输入提示词名称:" + } else { + "Enter a prompt name:" + } + } + + pub fn tui_prompt_rename_prompt() -> &'static str { + if is_chinese() { + "输入新的提示词名称:" + } else { + "Enter a new prompt name:" + } + } + pub fn tui_toast_prompt_no_active_to_deactivate() -> &'static str { if is_chinese() { "没有可停用的活动提示词。" @@ -4547,6 +4587,14 @@ pub mod texts { } } + pub fn tui_toast_prompt_name_empty() -> &'static str { + if is_chinese() { + "提示词名称不能为空。" + } else { + "Prompt name cannot be empty." + } + } + pub fn tui_toast_prompt_not_found(id: &str) -> String { if is_chinese() { format!("未找到提示词:{}", id) @@ -5382,6 +5430,22 @@ pub mod texts { } } + pub fn tui_toast_prompt_created() -> &'static str { + if is_chinese() { + "提示词已创建。" + } else { + "Prompt created." + } + } + + pub fn tui_toast_prompt_renamed() -> &'static str { + if is_chinese() { + "提示词已重命名。" + } else { + "Prompt renamed." + } + } + pub fn tui_toast_exported_to(path: &str) -> String { if is_chinese() { format!("已导出到 {}", path) diff --git a/src-tauri/src/cli/i18n/texts/config_actions.rs b/src-tauri/src/cli/i18n/texts/config_actions.rs index 64045ab4..85a4ee2c 100644 --- a/src-tauri/src/cli/i18n/texts/config_actions.rs +++ b/src-tauri/src/cli/i18n/texts/config_actions.rs @@ -792,6 +792,38 @@ pub fn tui_prompt_title(name: &str) -> String { } } +pub fn tui_prompt_rename_title() -> &'static str { + if is_chinese() { + "重命名提示词" + } else { + "Rename Prompt" + } +} + +pub fn tui_prompt_create_title() -> &'static str { + if is_chinese() { + "创建提示词" + } else { + "Create Prompt" + } +} + +pub fn tui_prompt_create_prompt() -> &'static str { + if is_chinese() { + "输入提示词名称:" + } else { + "Enter a prompt name:" + } +} + +pub fn tui_prompt_rename_prompt() -> &'static str { + if is_chinese() { + "输入新的提示词名称:" + } else { + "Enter a new prompt name:" + } +} + pub fn tui_toast_prompt_no_active_to_deactivate() -> &'static str { if is_chinese() { "没有可停用的活动提示词。" @@ -840,6 +872,14 @@ pub fn tui_toast_prompt_edit_finished() -> &'static str { } } +pub fn tui_toast_prompt_name_empty() -> &'static str { + if is_chinese() { + "提示词名称不能为空。" + } else { + "Prompt name cannot be empty." + } +} + pub fn tui_toast_prompt_not_found(id: &str) -> String { if is_chinese() { format!("未找到提示词:{}", id) diff --git a/src-tauri/src/cli/i18n/texts/core.rs b/src-tauri/src/cli/i18n/texts/core.rs index 3230232a..b854f7ed 100644 --- a/src-tauri/src/cli/i18n/texts/core.rs +++ b/src-tauri/src/cli/i18n/texts/core.rs @@ -427,9 +427,9 @@ pub fn tui_help_title() -> &'static str { pub fn tui_help_text() -> &'static str { if is_chinese() { - "[ ] 切换应用\n←→ 切换菜单/内容焦点\n↑↓ 移动\n/ 过滤\nEsc 返回\n? 显示/关闭帮助\n\n页面快捷键(在页面内容区顶部显示):\n- 供应商:Enter 详情,s 切换,a 添加,e 编辑,d 删除,t 测速,c 健康检查\n- 供应商详情:s 切换,e 编辑,t 测速,c 健康检查\n- MCP:x 启用/禁用(当前应用),m 选择应用,a 添加,e 编辑,i 导入已有,d 删除\n- 提示词:Enter 查看,a 激活,x 取消激活(当前),e 编辑,d 删除\n- 技能:Enter 详情,x 启用/禁用(当前应用),m 选择应用,d 卸载,i 导入已有\n- 配置:Enter 打开/执行,e 编辑片段\n- 设置:Enter 应用" + "[ ] 切换应用\n←→ 切换菜单/内容焦点\n↑↓ 移动\n/ 过滤\nEsc 返回\n? 显示/关闭帮助\n\n页面快捷键(在页面内容区顶部显示):\n- 供应商:Enter 详情,s 切换,a 添加,e 编辑,d 删除,t 测速,c 健康检查\n- 供应商详情:s 切换,e 编辑,t 测速,c 健康检查\n- MCP:x 启用/禁用(当前应用),m 选择应用,a 添加,e 编辑,i 导入已有,d 删除\n- 提示词:c 新建,r 刷新,Enter 查看,a 激活,x 取消激活(当前),n 重命名,e 编辑,d 删除\n- 技能:Enter 详情,x 启用/禁用(当前应用),m 选择应用,d 卸载,i 导入已有\n- 配置:Enter 打开/执行,e 编辑片段\n- 设置:Enter 应用" } else { - "[ ] switch app\n←→ focus menu/content\n↑↓ move\n/ filter\nEsc back\n? toggle help\n\nPage keys (shown at the top of each page):\n- Providers: Enter details, s switch, a add, e edit, d delete, t speedtest, c stream check\n- Provider Detail: s switch, e edit, t speedtest, c stream check\n- MCP: x toggle current, m select apps, a add, e edit, i import existing, d delete\n- Prompts: Enter view, a activate, x deactivate active, e edit, d delete\n- Skills: Enter details, x toggle current, m select apps, d uninstall, i import existing\n- Config: Enter open/run, e edit snippet\n- Settings: Enter apply" + "[ ] switch app\n←→ focus menu/content\n↑↓ move\n/ filter\nEsc back\n? toggle help\n\nPage keys (shown at the top of each page):\n- Providers: Enter details, s switch, a add, e edit, d delete, t speedtest, c stream check\n- Provider Detail: s switch, e edit, t speedtest, c stream check\n- MCP: x toggle current, m select apps, a add, e edit, i import existing, d delete\n- Prompts: c create, r refresh, Enter view, a activate, x deactivate active, n rename, e edit, d delete\n- Skills: Enter details, x toggle current, m select apps, d uninstall, i import existing\n- Config: Enter open/run, e edit snippet\n- Settings: Enter apply" } } diff --git a/src-tauri/src/cli/i18n/texts/providers.rs b/src-tauri/src/cli/i18n/texts/providers.rs index fabcb36e..3575c028 100644 --- a/src-tauri/src/cli/i18n/texts/providers.rs +++ b/src-tauri/src/cli/i18n/texts/providers.rs @@ -630,6 +630,14 @@ pub fn tui_key_refresh() -> &'static str { } } +pub fn tui_key_rename() -> &'static str { + if is_chinese() { + "重命名" + } else { + "rename" + } +} + pub fn tui_key_start_proxy() -> &'static str { if is_chinese() { "启动代理" diff --git a/src-tauri/src/cli/i18n/texts/toasts.rs b/src-tauri/src/cli/i18n/texts/toasts.rs index 64401087..8bcdc344 100644 --- a/src-tauri/src/cli/i18n/texts/toasts.rs +++ b/src-tauri/src/cli/i18n/texts/toasts.rs @@ -746,6 +746,22 @@ pub fn tui_toast_prompt_deleted() -> &'static str { } } +pub fn tui_toast_prompt_created() -> &'static str { + if is_chinese() { + "提示词已创建。" + } else { + "Prompt created." + } +} + +pub fn tui_toast_prompt_renamed() -> &'static str { + if is_chinese() { + "提示词已重命名。" + } else { + "Prompt renamed." + } +} + pub fn tui_toast_exported_to(path: &str) -> String { if is_chinese() { format!("已导出到 {}", path) diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 12cf8b1e..e3e91fac 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -44,7 +44,7 @@ pub enum Commands { #[command(subcommand)] Mcp(commands::mcp::McpCommand), - /// Manage prompts (list, activate, edit) + /// Manage prompts (list, activate, create, rename, edit) #[command(subcommand)] Prompts(commands::prompts::PromptsCommand), diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 3c49b236..ef884f38 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -102,6 +102,10 @@ pub enum Action { PromptDeactivate { id: String, }, + PromptRename { + id: String, + name: String, + }, PromptDelete { id: String, }, diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs index 8d04615b..7308a477 100644 --- a/src-tauri/src/cli/tui/app/content_config.rs +++ b/src-tauri/src/cli/tui/app/content_config.rs @@ -1081,4 +1081,17 @@ impl App { self.editor = None; self.form = Some(FormState::McpAdd(McpAddFormState::from_server(&row.server))); } + + pub(crate) fn open_prompt_create_name_input(&mut self) { + self.filter.active = false; + self.editor = None; + self.overlay = Overlay::TextInput(TextInputState { + title: texts::tui_prompt_create_title().to_string(), + prompt: texts::tui_prompt_create_prompt().to_string(), + buffer: format!("Prompt {}", chrono::Local::now().format("%Y-%m-%d %H:%M")), + submit: TextSubmit::PromptCreateName, + secret: false, + }); + self.focus = Focus::Content; + } } diff --git a/src-tauri/src/cli/tui/app/content_entities.rs b/src-tauri/src/cli/tui/app/content_entities.rs index c712d608..89e75e51 100644 --- a/src-tauri/src/cli/tui/app/content_entities.rs +++ b/src-tauri/src/cli/tui/app/content_entities.rs @@ -330,6 +330,11 @@ impl App { pub(crate) fn on_prompts_key(&mut self, key: KeyEvent, data: &UiData) -> Action { let visible = visible_prompts(&self.filter, data); match key.code { + KeyCode::Char('c') => { + self.open_prompt_create_name_input(); + Action::None + } + KeyCode::Char('r') => Action::ReloadData, KeyCode::Up => { self.prompt_idx = self.prompt_idx.saturating_sub(1); Action::None @@ -401,6 +406,19 @@ impl App { ); Action::None } + KeyCode::Char('n') => { + let Some(row) = visible.get(self.prompt_idx) else { + return Action::None; + }; + self.overlay = Overlay::TextInput(TextInputState { + title: texts::tui_prompt_rename_title().to_string(), + prompt: texts::tui_prompt_rename_prompt().to_string(), + buffer: row.prompt.name.clone(), + submit: TextSubmit::PromptRename { id: row.id.clone() }, + secret: false, + }); + Action::None + } _ => Action::None, } } diff --git a/src-tauri/src/cli/tui/app/editor_state.rs b/src-tauri/src/cli/tui/app/editor_state.rs index 42fcaf8f..fe401209 100644 --- a/src-tauri/src/cli/tui/app/editor_state.rs +++ b/src-tauri/src/cli/tui/app/editor_state.rs @@ -8,6 +8,7 @@ pub enum EditorKind { #[derive(Debug, Clone, PartialEq)] pub enum EditorSubmit { + PromptCreate { name: String }, PromptEdit { id: String }, ProviderFormApplyJson, ProviderFormApplyOpenClawModels, diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs index 8e62a2cc..7e71afbd 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs @@ -99,7 +99,7 @@ impl App { let Overlay::TextInput(input) = &self.overlay else { return None; }; - let submit = input.submit; + let submit = input.submit.clone(); let action = match key.code { KeyCode::Esc => { @@ -154,6 +154,42 @@ impl App { } Action::ConfigExport { path: raw } } + TextSubmit::PromptCreateName => { + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + self.push_toast(texts::tui_toast_prompt_name_empty(), ToastKind::Warning); + self.overlay = Overlay::TextInput(TextInputState { + title: texts::tui_prompt_create_title().to_string(), + prompt: texts::tui_prompt_create_prompt().to_string(), + buffer: raw, + submit: TextSubmit::PromptCreateName, + secret: false, + }); + return Action::None; + } + self.open_editor( + texts::tui_prompt_title(&trimmed), + EditorKind::Plain, + "# Write your prompt here\n", + EditorSubmit::PromptCreate { name: trimmed }, + ); + Action::None + } + TextSubmit::PromptRename { id } => { + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + self.push_toast(texts::tui_toast_prompt_name_empty(), ToastKind::Warning); + self.overlay = Overlay::TextInput(TextInputState { + title: texts::tui_prompt_rename_title().to_string(), + prompt: texts::tui_prompt_rename_prompt().to_string(), + buffer: raw, + submit: TextSubmit::PromptRename { id }, + secret: false, + }); + return Action::None; + } + Action::PromptRename { id, name: trimmed } + } TextSubmit::ConfigImport => { if raw.is_empty() { self.push_toast(texts::tui_toast_import_path_empty(), ToastKind::Warning); diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index e938de90..133be221 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -19,7 +19,9 @@ mod tests { use crate::cli::tui::terminal::TuiTerminal; use crate::commands::workspace::{DailyMemoryFileInfo, DailyMemorySearchResult, ALLOWED_FILES}; use crate::error::AppError; + use crate::prompt::Prompt; use crate::provider::Provider; + use crate::services::PromptService; use crate::settings::{get_settings, update_settings, AppSettings}; use crate::test_support::{ lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock, @@ -7646,6 +7648,243 @@ mod tests { )); } + #[test] + fn prompts_c_opens_create_name_input() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Prompts; + app.focus = Focus::Content; + + let action = app.on_key(key(KeyCode::Char('c')), &UiData::default()); + assert!(matches!(action, Action::None)); + assert!(matches!( + app.overlay, + Overlay::TextInput(TextInputState { + submit: TextSubmit::PromptCreateName, + .. + }) + )); + } + + #[test] + fn prompts_r_requests_reload() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Prompts; + app.focus = Focus::Content; + + let action = app.on_key(key(KeyCode::Char('r')), &UiData::default()); + assert!(matches!(action, Action::ReloadData)); + } + + #[test] + fn prompts_create_name_submit_opens_editor() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Prompts; + app.focus = Focus::Content; + + app.overlay = Overlay::TextInput(TextInputState { + title: texts::tui_prompt_create_title().to_string(), + prompt: texts::tui_prompt_create_prompt().to_string(), + buffer: "Prompt One".to_string(), + submit: TextSubmit::PromptCreateName, + secret: false, + }); + + let action = app.on_key(key(KeyCode::Enter), &UiData::default()); + assert!(matches!(action, Action::None)); + assert!(matches!( + app.editor.as_ref().map(|editor| editor.submit.clone()), + Some(EditorSubmit::PromptCreate { name }) if name == "Prompt One" + )); + } + + #[test] + fn prompts_create_name_empty_keeps_input_open() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Prompts; + app.focus = Focus::Content; + + app.overlay = Overlay::TextInput(TextInputState { + title: texts::tui_prompt_create_title().to_string(), + prompt: texts::tui_prompt_create_prompt().to_string(), + buffer: " ".to_string(), + submit: TextSubmit::PromptCreateName, + secret: false, + }); + + let action = app.on_key(key(KeyCode::Enter), &UiData::default()); + assert!(matches!(action, Action::None)); + assert!(matches!( + app.overlay, + Overlay::TextInput(TextInputState { + submit: TextSubmit::PromptCreateName, + .. + }) + )); + } + + #[test] + fn prompts_n_opens_rename_input() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Prompts; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.prompts.rows.push(super::super::data::PromptRow { + id: "pr1".to_string(), + prompt: crate::prompt::Prompt { + id: "pr1".to_string(), + name: "Demo".to_string(), + content: "hello".to_string(), + description: None, + enabled: false, + created_at: None, + updated_at: None, + }, + }); + + let action = app.on_key(key(KeyCode::Char('n')), &data); + assert!(matches!(action, Action::None)); + assert!(matches!( + app.overlay, + Overlay::TextInput(TextInputState { + submit: TextSubmit::PromptRename { ref id }, + ref buffer, + .. + }) if id == "pr1" && buffer == "Demo" + )); + } + + #[test] + fn prompts_rename_empty_keeps_input_open() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Prompts; + app.focus = Focus::Content; + + app.overlay = Overlay::TextInput(TextInputState { + title: texts::tui_prompt_rename_title().to_string(), + prompt: texts::tui_prompt_rename_prompt().to_string(), + buffer: " ".to_string(), + submit: TextSubmit::PromptRename { + id: "pr1".to_string(), + }, + secret: false, + }); + + let action = app.on_key(key(KeyCode::Enter), &UiData::default()); + assert!(matches!(action, Action::None)); + assert!(matches!( + app.overlay, + Overlay::TextInput(TextInputState { + submit: TextSubmit::PromptRename { ref id }, + .. + }) if id == "pr1" + )); + } + + #[test] + fn prompts_rename_submit_returns_action() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Prompts; + app.focus = Focus::Content; + + app.overlay = Overlay::TextInput(TextInputState { + title: texts::tui_prompt_rename_title().to_string(), + prompt: texts::tui_prompt_rename_prompt().to_string(), + buffer: "Renamed".to_string(), + submit: TextSubmit::PromptRename { + id: "pr1".to_string(), + }, + secret: false, + }); + + let action = app.on_key(key(KeyCode::Enter), &UiData::default()); + assert!(matches!( + action, + Action::PromptRename { id, name } if id == "pr1" && name == "Renamed" + )); + } + + #[test] + #[serial] + fn prompt_create_runtime_clears_filter_when_new_prompt_is_not_visible() { + let _guard = EnvGuard::set_home(tempfile::tempdir().expect("tempdir").path()); + let state = crate::AppState::try_new().expect("load state"); + state.save().expect("persist empty state"); + + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Prompts; + app.focus = Focus::Content; + app.filter.buffer = "focus".to_string(); + app.prompt_idx = 0; + + let mut data = UiData::load(&app.app_type).expect("load ui data"); + run_runtime_action( + &mut app, + &mut data, + Action::EditorSubmit { + submit: EditorSubmit::PromptCreate { + name: "Prompt One".to_string(), + }, + content: "body".to_string(), + }, + ) + .expect("create prompt"); + + assert!(!app.filter.active); + assert!(app.filter.buffer.is_empty()); + assert_eq!(app.prompt_idx, 0); + assert_eq!(data.prompts.rows.len(), 1); + assert_eq!(data.prompts.rows[0].id, "prompt-one"); + } + + #[test] + #[serial] + fn prompt_rename_runtime_clears_filter_when_renamed_prompt_is_not_visible() { + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::set_home(temp.path()); + let state = crate::AppState::try_new().expect("load state"); + PromptService::upsert_prompt( + &state, + AppType::Claude, + "pr1", + Prompt { + id: "pr1".to_string(), + name: "Demo".to_string(), + content: "hello".to_string(), + description: None, + enabled: false, + created_at: Some(1), + updated_at: Some(1), + }, + ) + .expect("seed prompt"); + state.save().expect("persist config"); + + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Prompts; + app.focus = Focus::Content; + app.filter.buffer = "demo".to_string(); + app.prompt_idx = 0; + + let mut data = UiData::load(&app.app_type).expect("load ui data"); + run_runtime_action( + &mut app, + &mut data, + Action::PromptRename { + id: "pr1".to_string(), + name: "Renamed".to_string(), + }, + ) + .expect("rename prompt"); + + assert!(!app.filter.active); + assert!(app.filter.buffer.is_empty()); + assert_eq!(app.prompt_idx, 0); + assert_eq!(data.prompts.rows.len(), 1); + assert_eq!(data.prompts.rows[0].id, "pr1"); + assert_eq!(data.prompts.rows[0].prompt.name, "Renamed"); + } + #[test] fn prompts_editor_ctrl_shift_s_submits() { let mut app = App::new(Some(AppType::Claude)); diff --git a/src-tauri/src/cli/tui/app/types.rs b/src-tauri/src/cli/tui/app/types.rs index c14b3f7c..e91af447 100644 --- a/src-tauri/src/cli/tui/app/types.rs +++ b/src-tauri/src/cli/tui/app/types.rs @@ -82,11 +82,15 @@ pub struct ConfirmOverlay { pub action: ConfirmAction, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum TextSubmit { ConfigExport, ConfigImport, ConfigBackupName, + PromptCreateName, + PromptRename { + id: String, + }, SettingsProxyListenAddress, SettingsProxyListenPort, SettingsOpenClawConfigDir, diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index ee0f7141..3f420bd1 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -16,7 +16,9 @@ use crate::settings::{set_webdav_sync_settings, WebDavSyncSettings}; use super::super::app::{EditorSubmit, Overlay, TextViewState, ToastKind}; use super::super::data::{load_state, UiData}; use super::super::form::FormState; -use super::helpers::{refresh_openclaw_workspace_data, run_external_editor_for_current_editor}; +use super::helpers::{ + refresh_openclaw_workspace_data, run_external_editor_for_current_editor, select_prompt_by_id, +}; use super::RuntimeActionContext; fn is_codex_official_provider(provider: &Provider) -> bool { @@ -75,6 +77,7 @@ pub(super) fn submit( content: String, ) -> Result<(), AppError> { match submit { + EditorSubmit::PromptCreate { name } => submit_prompt_create(ctx, name, content), EditorSubmit::PromptEdit { id } => submit_prompt_edit(ctx, id, content), EditorSubmit::ProviderFormApplyJson => submit_provider_form_apply_json(ctx, content), EditorSubmit::ProviderFormApplyOpenClawModels => { @@ -106,6 +109,29 @@ pub(super) fn submit( } } +fn submit_prompt_create( + ctx: &mut RuntimeActionContext<'_>, + name: String, + content: String, +) -> Result<(), AppError> { + let state = load_state()?; + let prompt = + match PromptService::create_prompt(&state, ctx.app.app_type.clone(), &name, &content) { + Ok(prompt) => prompt, + Err(err) => { + ctx.app.push_toast(err.to_string(), ToastKind::Error); + return Ok(()); + } + }; + + ctx.app.editor = None; + ctx.app + .push_toast(texts::tui_toast_prompt_created(), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + select_prompt_by_id(ctx.app, ctx.data, &prompt.id); + Ok(()) +} + fn submit_openclaw_workspace_file( ctx: &mut RuntimeActionContext<'_>, filename: String, @@ -903,6 +929,49 @@ mod tests { } } + #[test] + #[serial(home_settings)] + fn submit_prompt_create_persists_prompt_and_refreshes_selection() { + let mut fixture = runtime_ctx(AppType::Claude); + + let mut ctx = RuntimeActionContext { + terminal: &mut fixture.terminal, + app: &mut fixture.app, + data: &mut fixture.data, + speedtest_req_tx: None, + stream_check_req_tx: None, + skills_req_tx: None, + proxy_req_tx: None, + proxy_loading: &mut fixture.proxy_loading, + local_env_req_tx: None, + webdav_req_tx: None, + webdav_loading: &mut fixture.webdav_loading, + update_req_tx: None, + update_check: &mut fixture.update_check, + model_fetch_req_tx: None, + }; + + submit_prompt_create(&mut ctx, "Prompt One".to_string(), "hello".to_string()) + .expect("create prompt succeeds"); + + let refreshed = UiData::load(&AppType::Claude).expect("reload ui data"); + assert!( + refreshed + .prompts + .rows + .iter() + .any(|row| row.id == "prompt-one" && row.prompt.name == "Prompt One"), + "runtime create should persist the prompt" + ); + assert!(matches!( + ctx.app.toast.as_ref(), + Some(Toast { + kind: ToastKind::Success, + .. + }) + )); + } + #[test] #[serial(home_settings)] fn submit_provider_add_generates_id_when_name_is_valid() { diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs index 3dfefa22..6eb6b3c3 100644 --- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs @@ -7,6 +7,7 @@ use crate::commands::workspace; use crate::error::AppError; use crate::services::McpService; +use super::super::app::visible_prompts; use super::super::app::{App, LoadingKind, Overlay, TextViewState, ToastKind}; use super::super::data::{load_proxy_config, load_state, UiData}; use super::super::runtime_systems::{ProxyReq, RequestTracker}; @@ -194,6 +195,26 @@ pub(super) fn text_view(title: String, content: String) -> Overlay { }) } +pub(super) fn select_prompt_by_id(app: &mut App, data: &UiData, id: &str) { + let visible = visible_prompts(&app.filter, data); + if let Some(idx) = visible.iter().position(|row| row.id == id) { + app.prompt_idx = idx; + return; + } + + if app.filter.active || !app.filter.buffer.trim().is_empty() { + app.filter.active = false; + app.filter.buffer.clear(); + let visible = visible_prompts(&app.filter, data); + if let Some(idx) = visible.iter().position(|row| row.id == id) { + app.prompt_idx = idx; + return; + } + } + + app.prompt_idx = 0; +} + pub(super) fn open_proxy_help(app: &mut App, data: &UiData) -> Result<(), AppError> { open_proxy_help_overlay_with(app, data, load_proxy_config) } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 0af0f0c6..08cb811a 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -232,6 +232,7 @@ pub(crate) fn handle_action( Action::McpImport => mcp::import_current_app(&mut ctx), Action::PromptActivate { id } => prompts::activate(&mut ctx, id), Action::PromptDeactivate { id } => prompts::deactivate(&mut ctx, id), + Action::PromptRename { id, name } => prompts::rename(&mut ctx, id, name), Action::PromptDelete { id } => prompts::delete(&mut ctx, id), Action::ConfigExport { path } => config::export(&mut ctx, path), Action::ConfigShowFull => config::show_full(&mut ctx), diff --git a/src-tauri/src/cli/tui/runtime_actions/prompts.rs b/src-tauri/src/cli/tui/runtime_actions/prompts.rs index e30186ea..029fd232 100644 --- a/src-tauri/src/cli/tui/runtime_actions/prompts.rs +++ b/src-tauri/src/cli/tui/runtime_actions/prompts.rs @@ -4,6 +4,7 @@ use crate::services::PromptService; use super::super::app::ToastKind; use super::super::data::{load_state, UiData}; +use super::helpers::select_prompt_by_id; use super::RuntimeActionContext; pub(super) fn activate(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<(), AppError> { @@ -24,6 +25,20 @@ pub(super) fn deactivate(ctx: &mut RuntimeActionContext<'_>, id: String) -> Resu Ok(()) } +pub(super) fn rename( + ctx: &mut RuntimeActionContext<'_>, + id: String, + name: String, +) -> Result<(), AppError> { + let state = load_state()?; + PromptService::rename_prompt(&state, ctx.app.app_type.clone(), &id, &name)?; + ctx.app + .push_toast(texts::tui_toast_prompt_renamed(), ToastKind::Success); + *ctx.data = UiData::load(&ctx.app.app_type)?; + select_prompt_by_id(ctx.app, ctx.data, &id); + Ok(()) +} + pub(super) fn delete(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<(), AppError> { let state = load_state()?; PromptService::delete_prompt(&state, ctx.app.app_type.clone(), &id)?; diff --git a/src-tauri/src/cli/tui/ui/prompts.rs b/src-tauri/src/cli/tui/ui/prompts.rs index a9ae13b8..bd06a01d 100644 --- a/src-tauri/src/cli/tui/ui/prompts.rs +++ b/src-tauri/src/cli/tui/ui/prompts.rs @@ -58,9 +58,12 @@ pub(super) fn render_prompts( chunks[0], theme, &[ + ("c", texts::tui_key_create()), + ("r", texts::tui_key_refresh()), ("Enter", texts::tui_key_view()), ("a", texts::tui_key_activate()), ("x", texts::tui_key_deactivate_active()), + ("n", texts::tui_key_rename()), ("e", texts::tui_key_edit()), ("d", texts::tui_key_delete()), ], diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index 05dc99fb..6962118d 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -10,6 +10,40 @@ use crate::store::AppState; pub struct PromptService; impl PromptService { + pub fn generate_prompt_id(name: &str, existing_ids: &[String]) -> String { + let mut base_id = name + .trim() + .to_lowercase() + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string(); + + if base_id.is_empty() { + base_id = "prompt".to_string(); + } + + if !existing_ids.contains(&base_id) { + return base_id; + } + + let mut counter = 1; + loop { + let candidate = format!("{base_id}-{counter}"); + if !existing_ids.contains(&candidate) { + return candidate; + } + counter += 1; + } + } + pub fn get_prompts( state: &AppState, app: AppType, @@ -77,6 +111,81 @@ impl PromptService { Ok(()) } + pub fn rename_prompt( + state: &AppState, + app: AppType, + id: &str, + name: &str, + ) -> Result<(), AppError> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(AppError::InvalidInput("提示词名称不能为空".to_string())); + } + + let mut cfg = state.config.write()?; + let prompts = match app { + AppType::Claude => &mut cfg.prompts.claude.prompts, + AppType::Codex => &mut cfg.prompts.codex.prompts, + AppType::Gemini => &mut cfg.prompts.gemini.prompts, + AppType::OpenCode => &mut cfg.prompts.opencode.prompts, + AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts, + }; + + let Some(prompt) = prompts.get_mut(id) else { + return Err(AppError::InvalidInput(format!("提示词 {id} 不存在"))); + }; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + prompt.name = trimmed.to_string(); + prompt.updated_at = Some(timestamp); + + drop(cfg); + state.save()?; + Ok(()) + } + + pub fn create_prompt( + state: &AppState, + app: AppType, + name: &str, + content: &str, + ) -> Result { + let trimmed_name = name.trim(); + if trimmed_name.is_empty() { + return Err(AppError::InvalidInput("提示词名称不能为空".to_string())); + } + + let existing_ids = Self::get_prompts(state, app.clone())? + .into_keys() + .collect::>(); + let id = Self::generate_prompt_id(trimmed_name, &existing_ids); + if id.trim().is_empty() { + return Err(AppError::InvalidInput( + "无法根据提示词名称生成有效 ID".to_string(), + )); + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let prompt = Prompt { + id: id.clone(), + name: trimmed_name.to_string(), + content: content.trim_end().to_string(), + description: None, + enabled: false, + created_at: Some(timestamp), + updated_at: Some(timestamp), + }; + + Self::upsert_prompt(state, app, &id, prompt.clone())?; + Ok(prompt) + } + pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { // 回填当前 live 文件内容到已启用的提示词,或创建备份 let target_path = prompt_file_path(&app)?; diff --git a/src-tauri/tests/prompt_commands.rs b/src-tauri/tests/prompt_commands.rs new file mode 100644 index 00000000..36b7b7dc --- /dev/null +++ b/src-tauri/tests/prompt_commands.rs @@ -0,0 +1,166 @@ +use serde_json::json; +use serial_test::serial; + +use cc_switch_lib::{ + cli::commands::prompts::{execute, PromptsCommand}, + AppType, MultiAppConfig, PromptService, +}; + +#[path = "support.rs"] +mod support; +use support::{ensure_test_home, lock_test_mutex, reset_test_fs, state_from_config}; + +#[test] +#[serial] +fn prompt_service_rename_updates_name_and_timestamp() { + let _guard = lock_test_mutex(); + reset_test_fs(); + ensure_test_home(); + + let mut config = MultiAppConfig::default(); + config.prompts.claude.prompts = serde_json::from_value(json!({ + "pr1": { + "id": "pr1", + "name": "Old Name", + "content": "hello", + "enabled": false, + "createdAt": 1, + "updatedAt": 1 + } + })) + .expect("deserialize prompts"); + let state = state_from_config(config); + + PromptService::rename_prompt(&state, AppType::Claude, "pr1", "New Name") + .expect("rename prompt succeeds"); + + let prompts = PromptService::get_prompts(&state, AppType::Claude).expect("load prompts"); + let prompt = prompts.get("pr1").expect("renamed prompt should exist"); + assert_eq!(prompt.name, "New Name"); + assert!(prompt.updated_at.unwrap_or_default() >= 1); +} + +#[test] +#[serial] +fn prompt_service_rename_rejects_empty_name() { + let _guard = lock_test_mutex(); + reset_test_fs(); + ensure_test_home(); + + let mut config = MultiAppConfig::default(); + config.prompts.claude.prompts = serde_json::from_value(json!({ + "pr1": { + "id": "pr1", + "name": "Old Name", + "content": "hello", + "enabled": false, + "createdAt": 1, + "updatedAt": 1 + } + })) + .expect("deserialize prompts"); + let state = state_from_config(config); + + let err = PromptService::rename_prompt(&state, AppType::Claude, "pr1", " ") + .expect_err("empty name should fail"); + assert!( + err.to_string().contains("不能为空"), + "unexpected error: {err}" + ); +} + +#[test] +#[serial] +fn prompt_rename_command_updates_prompt_name() { + let _guard = lock_test_mutex(); + reset_test_fs(); + ensure_test_home(); + + let mut config = MultiAppConfig::default(); + config.prompts.claude.prompts = serde_json::from_value(json!({ + "pr1": { + "id": "pr1", + "name": "Old Name", + "content": "hello", + "enabled": false, + "createdAt": 1, + "updatedAt": 1 + } + })) + .expect("deserialize prompts"); + let state = state_from_config(config); + state.save().expect("persist config"); + + execute( + PromptsCommand::Rename { + id: "pr1".to_string(), + name: Some("New Name".to_string()), + }, + Some(AppType::Claude), + ) + .expect("rename command succeeds"); + + let persisted = cc_switch_lib::AppState::try_new().expect("reload state"); + let prompts = PromptService::get_prompts(&persisted, AppType::Claude).expect("load prompts"); + assert_eq!( + prompts.get("pr1").map(|prompt| prompt.name.as_str()), + Some("New Name") + ); +} + +#[test] +#[serial] +fn prompt_create_command_uses_explicit_name() { + let _guard = lock_test_mutex(); + reset_test_fs(); + ensure_test_home(); + + let state = state_from_config(MultiAppConfig::default()); + state.save().expect("persist config"); + + let editor_script = ensure_test_home().join("fake-editor.sh"); + std::fs::write( + &editor_script, + "#!/bin/sh\nprintf 'system prompt body\\n' > \"$1\"\n", + ) + .expect("write fake editor"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&editor_script) + .expect("read fake editor metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&editor_script, perms).expect("chmod fake editor"); + } + + std::env::set_var("EDITOR", &editor_script); + std::env::set_var("VISUAL", &editor_script); + + execute( + PromptsCommand::Create { + name: Some("Prompt One".to_string()), + }, + Some(AppType::Claude), + ) + .expect("create command succeeds"); + + std::env::remove_var("EDITOR"); + std::env::remove_var("VISUAL"); + + let persisted = cc_switch_lib::AppState::try_new().expect("reload state"); + let prompts = PromptService::get_prompts(&persisted, AppType::Claude).expect("load prompts"); + let prompt = prompts + .get("prompt-one") + .expect("created prompt should exist"); + assert_eq!(prompt.name, "Prompt One"); + assert_eq!(prompt.content, "system prompt body"); +} + +#[test] +#[serial] +fn generate_prompt_id_falls_back_when_name_has_no_valid_slug_chars() { + let ids = vec!["prompt".to_string(), "prompt-1".to_string()]; + let generated = PromptService::generate_prompt_id("!!!", &ids); + assert_eq!(generated, "prompt-2"); +}