From df63a18a26477f5dfe0db2d436c51d8142fd2a5e Mon Sep 17 00:00:00 2001 From: LING71671 <1739677116@qq.com> Date: Thu, 21 May 2026 19:23:35 +0800 Subject: [PATCH 001/125] fix(tui): show effective cost currency in config view --- crates/tui/src/tui/views/mod.rs | 92 ++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 68ce1ac7..0b6a805f 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -575,6 +575,7 @@ pub struct ConfigView { filter: String, status: Option, locale: Locale, + effective_cost_currency: String, last_visible_rows: Cell, last_row_hitboxes: RefCell>, } @@ -819,6 +820,7 @@ impl ConfigView { filter: String::new(), status: None, locale: app.ui_locale, + effective_cost_currency: cost_currency_config_value(app), last_visible_rows: Cell::new(0), last_row_hitboxes: RefCell::new(Vec::new()), } @@ -841,7 +843,7 @@ impl ConfigView { let section = row.section.label().to_lowercase(); let key = row.key.to_lowercase(); - let value = row.value.to_lowercase(); + let value = self.row_display_value(row).to_lowercase(); let scope = row.scope.label().to_lowercase(); filter.split_whitespace().all(|term| { @@ -1120,6 +1122,25 @@ impl ConfigView { self.update_filter(|filter| filter.clear()); } + + fn row_display_value(&self, row: &ConfigRow) -> String { + if row.key == "cost_currency" + && row.scope == ConfigScope::Saved + && row.value != self.effective_cost_currency + { + format!("{} (effective {})", row.value, self.effective_cost_currency) + } else { + row.value.clone() + } + } +} + +fn cost_currency_config_value(app: &App) -> String { + match app.cost_currency { + crate::pricing::CostCurrency::Usd => "usd", + crate::pricing::CostCurrency::Cny => "cny", + } + .to_string() } fn config_hint_for_key(key: &str) -> &'static str { @@ -1137,6 +1158,7 @@ fn config_hint_for_key(key: &str) -> &'static str { "theme" => "system | dark | light | grayscale", "locale" => "auto | en | ja | zh-Hans | pt-BR", "background_color" => "#RRGGBB | default", + "cost_currency" => "usd | cny", "default_mode" => "agent | plan | yolo", "sidebar_width" => "10..=50", "sidebar_focus" => "auto | work | tasks | agents | context | hidden", @@ -1444,7 +1466,10 @@ impl ModalView for ConfigView { } else { Style::default().fg(palette::TEXT_PRIMARY) }; - let value = truncate_view_text(&row.value, CONFIG_VALUE_COLUMN_WIDTH); + let value = truncate_view_text( + &self.row_display_value(row), + CONFIG_VALUE_COLUMN_WIDTH, + ); let mut line = Line::from(format!( " {:, + _lock: MutexGuard<'static, ()>, + } + + impl ConfigSettingsEnvGuard { + fn new(settings_toml: &str) -> Self { + let lock = crate::test_support::lock_test_env(); + let tmp = TempDir::new().expect("settings tempdir"); + let config_path = tmp.path().join(".deepseek").join("config.toml"); + let settings_path = config_path + .parent() + .expect("settings parent") + .join("settings.toml"); + std::fs::create_dir_all(config_path.parent().expect("config parent")) + .expect("config dir"); + std::fs::write(&settings_path, settings_toml).expect("settings file"); + let previous_config_path = std::env::var_os("DEEPSEEK_CONFIG_PATH"); + unsafe { + std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path); + } + Self { + _tmp: tmp, + previous_config_path, + _lock: lock, + } + } + } + + impl Drop for ConfigSettingsEnvGuard { + fn drop(&mut self) { + unsafe { + match self.previous_config_path.take() { + Some(previous) => std::env::set_var("DEEPSEEK_CONFIG_PATH", previous), + None => std::env::remove_var("DEEPSEEK_CONFIG_PATH"), + } + } + } + } fn create_test_app() -> App { let options = TuiOptions { @@ -2231,6 +2300,25 @@ mod tests { assert_eq!(row.value, "https://ui-config-view.local/v1"); } + #[test] + fn config_view_cost_currency_shows_saved_and_effective_runtime_currency() { + let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n"); + let app = create_test_app(); + assert_eq!(app.ui_locale, Locale::ZhHans); + assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny); + + let view = ConfigView::new_for_app(&app); + let row = view + .rows + .iter() + .find(|row| row.key == "cost_currency") + .expect("cost_currency row"); + + assert_eq!(row.value, "usd"); + assert_eq!(view.row_display_value(row), "usd (effective cny)"); + assert_eq!(Settings::load().expect("settings").cost_currency, "usd"); + } + #[test] fn config_view_exposes_all_available_saved_settings() { let app = create_test_app(); From ab192fe69c9faa9a2bf4318a1d2b391f6a829f3c Mon Sep 17 00:00:00 2001 From: LING71671 <1739677116@qq.com> Date: Wed, 27 May 2026 23:34:05 +0800 Subject: [PATCH 002/125] fix(config): normalize cost currency display comparison --- crates/tui/src/tui/views/mod.rs | 73 +++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 0b6a805f..26c381d5 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -1124,14 +1124,16 @@ impl ConfigView { } fn row_display_value(&self, row: &ConfigRow) -> String { - if row.key == "cost_currency" - && row.scope == ConfigScope::Saved - && row.value != self.effective_cost_currency - { - format!("{} (effective {})", row.value, self.effective_cost_currency) - } else { - row.value.clone() + if row.key == "cost_currency" && row.scope == ConfigScope::Saved { + let saved_cost_currency = crate::pricing::CostCurrency::from_setting(&row.value); + let effective_cost_currency = + crate::pricing::CostCurrency::from_setting(&self.effective_cost_currency); + if saved_cost_currency != effective_cost_currency { + return format!("{} (effective {})", row.value, self.effective_cost_currency); + } } + + row.value.clone() } } @@ -2120,6 +2122,26 @@ mod tests { App::new(options, &Config::default()) } + fn cost_currency_row_for_settings( + settings_toml: &str, + ) -> (String, String, crate::pricing::CostCurrency, Locale) { + let _guard = ConfigSettingsEnvGuard::new(settings_toml); + let app = create_test_app(); + let view = ConfigView::new_for_app(&app); + let row = view + .rows + .iter() + .find(|row| row.key == "cost_currency") + .expect("cost_currency row"); + + ( + row.value.clone(), + view.row_display_value(row), + app.cost_currency, + app.ui_locale, + ) + } + fn type_filter(view: &mut ConfigView, text: &str) { for ch in text.chars() { let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); @@ -2319,6 +2341,43 @@ mod tests { assert_eq!(Settings::load().expect("settings").cost_currency, "usd"); } + #[test] + fn config_view_cost_currency_aliases_matching_effective_currency_are_silent() { + for alias in ["rmb", "yuan", "¥"] { + let (saved_value, display_value, effective_currency, locale) = + cost_currency_row_for_settings(&format!( + "locale = \"zh-Hans\"\ncost_currency = \"{alias}\"\n" + )); + + assert_eq!(locale, Locale::ZhHans); + assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); + assert_eq!(saved_value, alias); + assert_eq!(display_value, alias); + } + } + + #[test] + fn config_view_cost_currency_matching_cny_setting_is_silent() { + let (saved_value, display_value, effective_currency, locale) = + cost_currency_row_for_settings("locale = \"zh-Hans\"\ncost_currency = \"cny\"\n"); + + assert_eq!(locale, Locale::ZhHans); + assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); + assert_eq!(saved_value, "cny"); + assert_eq!(display_value, "cny"); + } + + #[test] + fn config_view_cost_currency_non_zh_hans_locale_uses_saved_currency() { + let (saved_value, display_value, effective_currency, locale) = + cost_currency_row_for_settings("locale = \"en\"\ncost_currency = \"cny\"\n"); + + assert_eq!(locale, Locale::En); + assert_eq!(effective_currency, crate::pricing::CostCurrency::Cny); + assert_eq!(saved_value, "cny"); + assert_eq!(display_value, "cny"); + } + #[test] fn config_view_exposes_all_available_saved_settings() { let app = create_test_app(); From bfb0ccc9b4d25b220a6d63aafe6ef0de8eec184c Mon Sep 17 00:00:00 2001 From: jayzhu Date: Thu, 28 May 2026 23:09:03 +0800 Subject: [PATCH 003/125] fix: carry tool input in ApprovalRequired event so approval dialog always shows params The ApprovalRequest params_display() relied on pending_tool_uses, which gets drained by MessageComplete before ApprovalRequired arrives in the TUI event loop. When the model streaming response contained a text block, ContentBlockStop(Text) set pending_message_complete=true, triggering the drain and leaving the approval dialog with empty {}. Fix: add input: Value to Event::ApprovalRequired and populate it from tool_input in the engine tool execution loop. The TUI now reads params directly from the event instead of searching pending_tool_uses. --- crates/tui/src/core/engine/turn_loop.rs | 1 + crates/tui/src/core/events.rs | 3 +++ crates/tui/src/runtime_threads.rs | 4 ++++ crates/tui/src/tui/ui.rs | 8 ++------ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index f9df1795..9dc2a873 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1696,6 +1696,7 @@ impl Engine { .send(Event::ApprovalRequired { id: tool_id.clone(), tool_name: tool_name.clone(), + input: tool_input.clone(), description: plan.approval_description.clone(), approval_key, approval_grouping_key, diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index b02ba2f9..f598b722 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -226,6 +226,9 @@ pub enum Event { id: String, tool_name: String, description: String, + /// Tool parameters for approval display. Carried on the event so the + /// TUI does not need to reconstruct them from `pending_tool_uses`. + input: Value, /// Exact-argument fingerprint, used to scope *denials* (#1617). approval_key: String, /// Lossy / arity-aware fingerprint, used to scope *approvals* so an diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 25196f04..6dc9c1c1 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -4172,6 +4172,7 @@ mod tests { id: "tool_stale".to_string(), tool_name: "exec_command".to_string(), description: "stale approval".to_string(), + input: serde_json::json!({}), }) .await?; @@ -4245,6 +4246,7 @@ mod tests { id: "tool_external_allow".to_string(), tool_name: "exec_command".to_string(), description: "external allow".to_string(), + input: serde_json::json!({}), }) .await?; @@ -4322,6 +4324,7 @@ mod tests { id: "tool_external_deny".to_string(), tool_name: "exec_command".to_string(), description: "external deny".to_string(), + input: serde_json::json!({}), }) .await?; @@ -4508,6 +4511,7 @@ mod tests { id: "tool_remember".to_string(), tool_name: "exec_command".to_string(), description: "remember=true".to_string(), + input: serde_json::json!({}), }) .await?; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fb89de61..64e35f66 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1893,6 +1893,7 @@ async fn run_event_loop( id, tool_name, description, + input, approval_key, approval_grouping_key, } => { @@ -1937,12 +1938,7 @@ async fn run_event_loop( app.status_message = Some(format!("Blocked tool '{tool_name}' (approval_mode=never)")); } else { - let tool_input = app - .pending_tool_uses - .iter() - .find(|(tool_id, _, _)| tool_id == &id) - .map(|(_, _, input)| input.clone()) - .unwrap_or_else(|| serde_json::json!({})); + let tool_input = input; if tool_name == "apply_patch" { maybe_add_patch_preview(app, &tool_input); From 43f1b63d5b2abcd0c21f0776980b7713450a61af Mon Sep 17 00:00:00 2001 From: hexin Date: Fri, 29 May 2026 17:21:10 +0800 Subject: [PATCH 004/125] feat(prompts): allow embedders to override constitutional prompt text via OnceLock hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An application embedding this engine may want to ship its own constitutional preamble, locale preamble, or authority-recap block without forking the prompt constants. Today the only way to change `BASE_PROMPT`, `LOCALE_PREAMBLE_ZH_HANS`, or `AUTHORITY_RECAP` is to edit the bundled markdown/strings in this crate. Add three process-global, set-once override hooks: - `set_base_prompt_override(String)` - `set_locale_preamble_zh_hans_override(String)` - `set_authority_recap_override(String)` Each is backed by a `OnceLock` and read through a small accessor (`base_prompt()` / `locale_preamble_zh_hans()` / `authority_recap()`) that falls back to the existing default constant when no override is set. Prompt assembly now calls these accessors instead of the constants directly. Default builds are unaffected (no override set → upstream constant is used). Overrides are expected to be installed once at startup, before the engine builds any prompt; `set_*` returns `Err` with the supplied value if called twice. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tui/src/prompts.rs | 54 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index c43f2e84..a156828c 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -228,6 +228,53 @@ fn load_handoff_block(workspace: &Path) -> Option { /// "When NOT to use" guidance, sub-agent sentinel protocol. pub const BASE_PROMPT: &str = include_str!("prompts/base.md"); +// ── Embedder prompt overrides ── +// Let an embedder replace these compile-time prompt constants at startup, +// so brand / slimming customizations live in the embedder crate instead of +// editing these files in-tree. Unset → the bundled constant (fully +// backward compatible). Intended to be set once at process start, before +// any engine spawns; later sets are ignored (OnceLock semantics). +static BASE_PROMPT_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); +static LOCALE_PREAMBLE_ZH_HANS_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); +static AUTHORITY_RECAP_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Replace `BASE_PROMPT` for all subsequent prompt composition. First call +/// wins; later calls are no-ops. Set before spawning any engine. +pub fn set_base_prompt_override(s: String) { + let _ = BASE_PROMPT_OVERRIDE.set(s); +} + +/// Replace the Simplified-Chinese locale preamble (`## 语言要求`). +pub fn set_locale_preamble_zh_hans_override(s: String) { + let _ = LOCALE_PREAMBLE_ZH_HANS_OVERRIDE.set(s); +} + +/// Replace the trailing `## Authority Recap` block. +pub fn set_authority_recap_override(s: String) { + let _ = AUTHORITY_RECAP_OVERRIDE.set(s); +} + +fn effective_base_prompt() -> &'static str { + BASE_PROMPT_OVERRIDE + .get() + .map(String::as_str) + .unwrap_or(BASE_PROMPT) +} + +fn effective_locale_preamble_zh_hans() -> &'static str { + LOCALE_PREAMBLE_ZH_HANS_OVERRIDE + .get() + .map(String::as_str) + .unwrap_or(LOCALE_PREAMBLE_ZH_HANS) +} + +fn effective_authority_recap() -> &'static str { + AUTHORITY_RECAP_OVERRIDE + .get() + .map(String::as_str) + .unwrap_or(AUTHORITY_RECAP) +} + /// Optional locale-native reinforcement preamble prepended to the system /// prompt when the user's UI locale is non-English. /// @@ -293,7 +340,7 @@ pub const BASE_PROMPT: &str = include_str!("prompts/base.md"); /// and the closer position would all carry over unchanged. pub(crate) fn locale_reinforcement_preamble(locale_tag: &str) -> Option<&'static str> { match locale_tag { - "zh-Hans" | "zh-CN" | "zh" => Some(LOCALE_PREAMBLE_ZH_HANS), + "zh-Hans" | "zh-CN" | "zh" => Some(effective_locale_preamble_zh_hans()), "ja" | "ja-JP" => Some(LOCALE_PREAMBLE_JA), "pt-BR" | "pt" => Some(LOCALE_PREAMBLE_PT_BR), _ => None, @@ -542,7 +589,7 @@ pub fn compose_prompt_with_approval_and_model( model_id: &str, ) -> String { let parts: [&str; 4] = [ - &apply_model_template(BASE_PROMPT.trim(), model_id), + &apply_model_template(effective_base_prompt().trim(), model_id), personality.prompt().trim(), mode_prompt(mode).trim(), approval_prompt_for_mode(mode, approval_mode).trim(), @@ -833,7 +880,8 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( // 7a. Authority recap — the final tier reminder before user messages. // Uses recency bias constructively: this is the last content the model // sees before the user's turn, reinforcing the Constitutional hierarchy. - full_prompt = format!("{full_prompt}\n\n{AUTHORITY_RECAP}"); + let authority_recap = effective_authority_recap(); + full_prompt = format!("{full_prompt}\n\n{authority_recap}"); // 8. Locale-native closing reinforcement (#1118 follow-up #2). The // opening preamble alone wasn't enough — community feedback (the From 1379d354e42227295d06fde69789011dc2259ff2 Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Sat, 30 May 2026 07:43:51 +0800 Subject: [PATCH 005/125] fix(provider): name DeepSeek in provider help text Replace the incorrect CodeWhale backend example in /provider help text with DeepSeek. CodeWhale is the app name, while deepseek is the actual provider id accepted by /provider. Add a regression test for shipped locale descriptions. --- crates/tui/src/localization.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 21809260..7cba0b4a 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -959,7 +959,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdNoteDescription => "Add, list, edit, or remove workspace notes", MessageId::CmdThemeDescription => "Switch theme or open the theme picker", MessageId::CmdProviderDescription => { - "Switch or view the active LLM backend (codewhale | nvidia-nim | ollama)" + "Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "View or edit queued messages", MessageId::CmdRecallDescription => "Search prior cycle archives (BM25 over message text)", @@ -1346,7 +1346,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { "テーマを切り替え(ダーク/ライト/グレースケール/システム)" } MessageId::CmdProviderDescription => { - "現在の LLM バックエンドを切り替え・確認(codewhale | nvidia-nim | ollama)" + "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "キューされたメッセージを確認・編集", MessageId::CmdRecallDescription => { @@ -1692,7 +1692,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdNoteDescription => "添加、列出、编辑或删除工作区笔记", MessageId::CmdThemeDescription => "切换主题:深色、浅色、灰度或系统", MessageId::CmdProviderDescription => { - "切换或查看当前 LLM 后端(codewhale | nvidia-nim | ollama)" + "切换或查看当前 LLM 后端(deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "查看或编辑已排队的消息", MessageId::CmdRecallDescription => "搜索此前的循环归档(基于消息文本的 BM25 检索)", @@ -2022,7 +2022,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdNoteDescription => "Adicionar, listar, editar ou remover notas do workspace", MessageId::CmdThemeDescription => "Alternar tema: escuro, claro, tons de cinza ou sistema", MessageId::CmdProviderDescription => { - "Trocar ou exibir o backend LLM ativo (codewhale | nvidia-nim | ollama)" + "Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "Ver ou editar mensagens enfileiradas", MessageId::CmdRecallDescription => { @@ -2414,7 +2414,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CmdNoteDescription => "Agregar nota al archivo persistente (.deepseek/notes.md)", MessageId::CmdThemeDescription => "Alternar entre tema claro y oscuro", MessageId::CmdProviderDescription => { - "Cambiar o mostrar el backend LLM activo (codewhale | nvidia-nim | ollama)" + "Cambiar o mostrar el backend LLM activo (deepseek | nvidia-nim | ollama)" } MessageId::CmdQueueDescription => "Ver o editar mensajes en cola", MessageId::CmdRecallDescription => { @@ -2770,6 +2770,23 @@ mod tests { ); } + #[test] + fn provider_description_names_deepseek_backend() { + for locale in Locale::shipped() { + let description = tr(*locale, MessageId::CmdProviderDescription); + assert!( + description.contains("deepseek"), + "{} provider description should mention deepseek: {description}", + locale.tag() + ); + assert!( + !description.contains("codewhale |"), + "{} provider description should not name codewhale as a backend: {description}", + locale.tag() + ); + } + } + #[test] fn width_truncation_handles_cjk_rtl_indic_and_latin_samples() { let samples = [ From b669ee7b94ab3af08927d216c7ad5ca3719fff51 Mon Sep 17 00:00:00 2001 From: jayzhu Date: Thu, 28 May 2026 21:05:31 +0800 Subject: [PATCH 006/125] fix: update terminal tab title from 'DeepSeek TUI' to 'CodeWhale' --- crates/tui/src/core/engine/turn_loop.rs | 2 +- crates/tui/src/tui/notifications.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index f9df1795..f9729753 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -23,7 +23,7 @@ impl Engine { // Signal to the terminal / taskbar that a turn is in progress // (OSC 9 ; 4 indeterminate progress + title spinner). crate::tui::notifications::set_taskbar_progress_busy(); - crate::tui::notifications::start_title_animation("DeepSeek TUI"); + crate::tui::notifications::start_title_animation("CodeWhale"); let client = self .deepseek_client diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 4db152fe..6461046d 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -315,7 +315,7 @@ pub fn stop_title_animation() { // terminal-level visual indicator (flash/icon). let mode = COMPLETION_SOUND_MODE.load(Ordering::SeqCst); if mode == 1 { - set_terminal_title("✅ DeepSeek TUI"); + set_terminal_title("✅ CodeWhale"); } play_completion_sound(); } @@ -326,7 +326,7 @@ pub fn stop_title_animation() { /// marker doesn't persist once the user is back at the terminal. pub fn reset_title_on_interaction() { if COMPLETION_MARKER_SHOWN.swap(false, Ordering::SeqCst) { - set_terminal_title("DeepSeek TUI"); + set_terminal_title("CodeWhale"); } } From 5de174ac8bf9786ee9acbaba9d11988b9c13e5d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 21:31:41 +0000 Subject: [PATCH 007/125] chore(deps): bump tar in the cargo group across 1 directory Bumps the cargo group with 1 update in the / directory: [tar](https://github.com/composefs/tar-rs). Updates `tar` from 0.4.45 to 0.4.46 - [Release notes](https://github.com/composefs/tar-rs/releases) - [Commits](https://github.com/composefs/tar-rs/compare/0.4.45...0.4.46) --- updated-dependencies: - dependency-name: tar dependency-version: 0.4.46 dependency-type: direct:production dependency-group: cargo ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83777fd1..239d0518 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4912,9 +4912,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", From 0a97f18e781442a444661a30750d829c4a2e8a6b Mon Sep 17 00:00:00 2001 From: cyq <15000851237@163.com> Date: Sat, 30 May 2026 09:22:49 +0800 Subject: [PATCH 008/125] test(tui): make composer history flush deterministic --- crates/tui/src/composer_history.rs | 96 +++++++++++++++++++----------- 1 file changed, 61 insertions(+), 35 deletions(-) diff --git a/crates/tui/src/composer_history.rs b/crates/tui/src/composer_history.rs index 4f8bb1ce..12741f30 100644 --- a/crates/tui/src/composer_history.rs +++ b/crates/tui/src/composer_history.rs @@ -79,29 +79,44 @@ pub fn append_history(entry: &str) { /// write if the channel send fails) so callers never block on disk I/O. fn append_history_dispatched(path: &Path, entry: &str) { let entry = entry.to_string(); - if writer_sender() - .send((path.to_path_buf(), entry.clone())) - .is_err() - { - append_history_to(path, &entry); + if let Err(err) = writer_sender().send(HistoryWrite::Append(path.to_path_buf(), entry)) { + match err.0 { + HistoryWrite::Append(path, entry) => append_history_to(&path, &entry), + #[cfg(test)] + HistoryWrite::Flush(_) => unreachable!("flush messages are only sent by tests"), + } } } +enum HistoryWrite { + Append(PathBuf, String), + #[cfg(test)] + Flush(Sender<()>), +} + /// Lazy singleton sender for the dedicated composer-history writer /// thread. Initialised on first use; the thread runs for the lifetime /// of the process and drains queued writes in arrival order. -fn writer_sender() -> &'static Sender<(PathBuf, String)> { - static SENDER: OnceLock> = OnceLock::new(); +fn writer_sender() -> &'static Sender { + static SENDER: OnceLock> = OnceLock::new(); SENDER.get_or_init(|| { - let (tx, rx) = channel::<(PathBuf, String)>(); + let (tx, rx) = channel::(); let spawn_result = std::thread::Builder::new() .name("composer-history-writer".to_string()) .spawn(move || { // recv() returns Err when all senders have dropped, which // only happens at process shutdown because the singleton // sender lives in a static for the lifetime of the process. - while let Ok(first) = rx.recv() { - append_history_batch(&rx, first); + while let Ok(message) = rx.recv() { + match message { + HistoryWrite::Append(path, entry) => { + append_history_batch(&rx, (path, entry)); + } + #[cfg(test)] + HistoryWrite::Flush(done) => { + let _ = done.send(()); + } + } } }); if let Err(err) = spawn_result { @@ -111,12 +126,19 @@ fn writer_sender() -> &'static Sender<(PathBuf, String)> { }) } -fn append_history_batch(rx: &Receiver<(PathBuf, String)>, first: (PathBuf, String)) { +fn append_history_batch(rx: &Receiver, first: (PathBuf, String)) { let mut pending = vec![first]; + #[cfg(test)] + let mut flush = None; loop { match rx.recv_timeout(Duration::from_millis(2)) { - Ok(next) => pending.push(next), + Ok(HistoryWrite::Append(path, entry)) => pending.push((path, entry)), + #[cfg(test)] + Ok(HistoryWrite::Flush(done)) => { + flush = Some(done); + break; + } Err(RecvTimeoutError::Timeout) => break, Err(RecvTimeoutError::Disconnected) => break, } @@ -125,6 +147,11 @@ fn append_history_batch(rx: &Receiver<(PathBuf, String)>, first: (PathBuf, Strin for (path, entries) in group_history_writes_by_path(pending) { append_history_entries_to(&path, entries.iter().map(String::as_str)); } + + #[cfg(test)] + if let Some(done) = flush { + let _ = done.send(()); + } } fn group_history_writes_by_path(writes: Vec<(PathBuf, String)>) -> Vec<(PathBuf, Vec)> { @@ -201,6 +228,7 @@ fn append_history_entries_to<'a>( #[cfg(test)] mod tests { use super::*; + use std::time::{Duration, Instant}; /// Tests use the path-injecting `*_from` / `*_to` helpers so they /// don't have to mutate `HOME` (which is not honored by @@ -213,6 +241,16 @@ mod tests { (tmp, path) } + fn flush_history_writer_for_tests(timeout: Duration) { + let (done_tx, done_rx) = channel(); + writer_sender() + .send(HistoryWrite::Flush(done_tx)) + .expect("history writer accepts flush"); + done_rx + .recv_timeout(timeout) + .expect("history writer flush timed out"); + } + #[test] fn append_and_load_round_trip() { let (_tmp, path) = temp_history_path(); @@ -283,8 +321,6 @@ mod tests { /// stall the user reports. #[test] fn append_history_dispatched_does_not_block_the_caller() { - use std::time::{Duration, Instant}; - let (_tmp, path) = temp_history_path(); // Seed close to the cap so a synchronous rewrite is non-trivial. let seed = (0..(MAX_HISTORY_ENTRIES - 50)) @@ -311,26 +347,16 @@ mod tests { (likely re-introduced #1927: caller blocked on disk write)" ); - // Give the writer thread time to drain the queue, then verify the - // new entries landed. - // Use 10s on Windows (slow CI I/O) vs 5s on other platforms. - let deadline = Instant::now() + Duration::from_secs(if cfg!(windows) { 10 } else { 5 }); - loop { - let loaded = load_history_from(&path); - if loaded.iter().any(|line| line == "new entry 49") { - // Last dispatched entry observed; queue is drained. - assert!(loaded.iter().any(|line| line == "new entry 0")); - break; - } - if Instant::now() >= deadline { - panic!( - "writer thread did not persist the dispatched entries; \ - loaded {} entries, last = {:?}", - loaded.len(), - loaded.last() - ); - } - std::thread::sleep(Duration::from_millis(25)); - } + flush_history_writer_for_tests(Duration::from_secs(if cfg!(windows) { 10 } else { 5 })); + + let loaded = load_history_from(&path); + assert!( + loaded.iter().any(|line| line == "new entry 49"), + "writer thread did not persist the dispatched entries; \ + loaded {} entries, last = {:?}", + loaded.len(), + loaded.last() + ); + assert!(loaded.iter().any(|line| line == "new entry 0")); } } From 76089db1dc84374ac4635a314d1d006f57aae535 Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Fri, 29 May 2026 22:16:08 +0800 Subject: [PATCH 009/125] fix(mcp): avoid nested runtime panic on stdio shutdown Run the stdio MCP server inside a blocking section when launched from the async CLI entrypoint. This prevents Tokio from panicking when the MCP server's internal runtime is dropped after stdin closes. --- crates/tui/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 99579eac..8bc03acc 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -934,7 +934,7 @@ async fn main() -> Result<()> { bail!("Choose exactly one server mode: --mcp, --http, or --acp"); } if args.mcp { - mcp_server::run_mcp_server(workspace) + tokio::task::block_in_place(|| mcp_server::run_mcp_server(workspace)) } else if args.http { let config = load_config_from_cli(&cli)?; let cors_origins = resolve_cors_origins(&config, &args.cors_origin); From 4bacda64fc0fdd18bf5c837b7611bad310e86007 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Fri, 29 May 2026 11:46:49 +0800 Subject: [PATCH 010/125] fix(tools): raise tool search default results --- crates/tui/src/core/engine.rs | 2 +- crates/tui/src/core/engine/tests.rs | 90 ++++++++++++++++++++++ crates/tui/src/core/engine/tool_catalog.rs | 51 +++++++++--- 3 files changed, 132 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 02737eb7..be48a804 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -2115,7 +2115,7 @@ use self::tool_catalog::{ }; #[cfg(test)] use self::tool_catalog::{ - TOOL_SEARCH_BM25_NAME, maybe_activate_requested_deferred_tool, + TOOL_SEARCH_BM25_NAME, TOOL_SEARCH_REGEX_NAME, maybe_activate_requested_deferred_tool, preflight_requested_deferred_tool, should_default_defer_tool, }; use self::tool_execution::emit_tool_audit; diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index b0294be2..b92f4faf 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -2097,6 +2097,96 @@ fn tool_search_activates_discovered_deferred_tools() { assert!(active.contains("read_file")); } +fn tool_search_catalog_with_matches(count: usize) -> Vec { + let mut catalog = (0..count) + .map(|idx| Tool { + tool_type: None, + name: format!("matching_tool_{idx:03}"), + description: "Matching deferred test tool".to_string(), + input_schema: json!({"type":"object","properties":{"query":{"type":"string"}}}), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(true), + input_examples: None, + strict: None, + cache_control: None, + }) + .collect::>(); + let always_load = HashSet::new(); + ensure_advanced_tooling(&mut catalog, AppMode::Agent, &always_load); + catalog +} + +fn tool_search_reference_count(result: &ToolResult) -> usize { + result + .metadata + .as_ref() + .and_then(|metadata| metadata.get("tool_references")) + .and_then(|references| references.as_array()) + .map_or(0, Vec::len) +} + +#[test] +fn tool_search_defaults_to_twenty_results_for_regex_and_bm25() { + let catalog = tool_search_catalog_with_matches(25); + + for tool_name in [TOOL_SEARCH_REGEX_NAME, TOOL_SEARCH_BM25_NAME] { + let mut active = initial_active_tools(&catalog); + let result = execute_tool_search( + tool_name, + &json!({"query":"matching"}), + &catalog, + &mut active, + ) + .expect("search succeeds"); + + assert_eq!(tool_search_reference_count(&result), 20); + } +} + +#[test] +fn tool_search_respects_and_caps_max_results() { + let catalog = tool_search_catalog_with_matches(120); + + let mut active = initial_active_tools(&catalog); + let limited = execute_tool_search( + TOOL_SEARCH_BM25_NAME, + &json!({"query":"matching","max_results":7}), + &catalog, + &mut active, + ) + .expect("search succeeds"); + assert_eq!(tool_search_reference_count(&limited), 7); + + let mut active = initial_active_tools(&catalog); + let capped = execute_tool_search( + TOOL_SEARCH_REGEX_NAME, + &json!({"query":"matching","max_results":999}), + &catalog, + &mut active, + ) + .expect("search succeeds"); + assert_eq!(tool_search_reference_count(&capped), 100); +} + +#[test] +fn tool_search_schema_exposes_max_results_default_and_cap() { + let mut catalog = Vec::new(); + let always_load = HashSet::new(); + ensure_advanced_tooling(&mut catalog, AppMode::Agent, &always_load); + + for tool_name in [TOOL_SEARCH_REGEX_NAME, TOOL_SEARCH_BM25_NAME] { + let tool = catalog + .iter() + .find(|tool| tool.name == tool_name) + .expect("tool search definition exists"); + let schema = &tool.input_schema["properties"]["max_results"]; + + assert_eq!(schema["default"], 20); + assert_eq!(schema["maximum"], 100); + assert_eq!(schema["minimum"], 1); + } +} + #[tokio::test] async fn code_execution_runs_python_and_returns_result_payload() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 65b194ce..474abc36 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -12,7 +12,7 @@ use std::time::Duration; use serde_json::{Value, json}; use crate::models::Tool; -use crate::tools::spec::{ToolError, ToolResult, required_str}; +use crate::tools::spec::{ToolError, ToolResult, optional_u64, required_str}; use crate::tui::app::AppMode; pub(super) const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel"; @@ -20,10 +20,12 @@ pub(super) const REQUEST_USER_INPUT_NAME: &str = "request_user_input"; pub(super) const CODE_EXECUTION_TOOL_NAME: &str = "code_execution"; const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825"; pub(super) use crate::tools::js_execution::JS_EXECUTION_TOOL_NAME; -const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex"; +pub(super) const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex"; const TOOL_SEARCH_REGEX_TYPE: &str = "tool_search_tool_regex_20251119"; pub(super) const TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25"; const TOOL_SEARCH_BM25_TYPE: &str = "tool_search_tool_bm25_20251119"; +const TOOL_SEARCH_DEFAULT_MAX_RESULTS: usize = 20; +const TOOL_SEARCH_MAX_RESULTS_LIMIT: usize = 100; pub(super) fn is_tool_search_tool(name: &str) -> bool { matches!(name, TOOL_SEARCH_REGEX_NAME | TOOL_SEARCH_BM25_NAME) @@ -178,7 +180,14 @@ pub(super) fn ensure_advanced_tooling( input_schema: json!({ "type": "object", "properties": { - "query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." } + "query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." }, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": TOOL_SEARCH_MAX_RESULTS_LIMIT, + "default": TOOL_SEARCH_DEFAULT_MAX_RESULTS, + "description": "Maximum number of matching tool references to return." + } }, "required": ["query"] }), @@ -198,7 +207,14 @@ pub(super) fn ensure_advanced_tooling( input_schema: json!({ "type": "object", "properties": { - "query": { "type": "string", "description": "Natural language query for tool discovery." } + "query": { "type": "string", "description": "Natural language query for tool discovery." }, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": TOOL_SEARCH_MAX_RESULTS_LIMIT, + "default": TOOL_SEARCH_DEFAULT_MAX_RESULTS, + "description": "Maximum number of matching tool references to return." + } }, "required": ["query"] }), @@ -280,7 +296,11 @@ fn tool_search_haystack(tool: &Tool) -> String { ) } -fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result, ToolError> { +fn discover_tools_with_regex( + catalog: &[Tool], + query: &str, + max_results: usize, +) -> Result, ToolError> { let regex = regex::Regex::new(query) .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?; @@ -293,14 +313,14 @@ fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result= 5 { + if matches.len() >= max_results { break; } } Ok(matches) } -fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec { +fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str, max_results: usize) -> Vec { let terms: Vec = query .split_whitespace() .map(|term| term.trim().to_lowercase()) @@ -330,7 +350,11 @@ fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec { } } scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); - scored.into_iter().take(5).map(|(_, name)| name).collect() + scored + .into_iter() + .take(max_results) + .map(|(_, name)| name) + .collect() } fn edit_distance(a: &str, b: &str) -> usize { @@ -645,10 +669,17 @@ pub(super) fn execute_tool_search( active_tools: &mut HashSet, ) -> Result { let query = required_str(input, "query")?; + let max_results = usize::try_from(optional_u64( + input, + "max_results", + TOOL_SEARCH_DEFAULT_MAX_RESULTS as u64, + )) + .unwrap_or(TOOL_SEARCH_DEFAULT_MAX_RESULTS) + .clamp(1, TOOL_SEARCH_MAX_RESULTS_LIMIT); let discovered = if tool_name == TOOL_SEARCH_REGEX_NAME { - discover_tools_with_regex(catalog, query)? + discover_tools_with_regex(catalog, query, max_results)? } else { - discover_tools_with_bm25_like(catalog, query) + discover_tools_with_bm25_like(catalog, query, max_results) }; for name in &discovered { From d58f10102ace58a7dd0246fc0f5830db4bf95198 Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Thu, 28 May 2026 18:49:44 +0800 Subject: [PATCH 011/125] fix(composer): allow slash-space messages Treat inputs like `/ hello` as plain user messages instead of slash commands. This lets users start a message with a literal slash while preserving normal slash command behavior for `/help`, `/model ...`, and other commands. --- crates/tui/src/tui/app.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3c49df0e..47193855 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -86,6 +86,9 @@ pub(crate) fn looks_like_slash_command_input(input: &str) -> bool { let Some(rest) = input.trim_start().strip_prefix('/') else { return false; }; + if rest.chars().next().is_some_and(|ch| ch.is_whitespace()) { + return false; + } let Some(command) = rest.split_whitespace().next() else { return rest.is_empty(); }; @@ -5010,6 +5013,8 @@ mod tests { assert!(looks_like_slash_command_input("/")); assert!(looks_like_slash_command_input("/help")); assert!(looks_like_slash_command_input("/model deepseek-v4-pro")); + assert!(!looks_like_slash_command_input("/ hello")); + assert!(!looks_like_slash_command_input(" / hello")); assert!(!looks_like_slash_command_input( "/usr/lib/x86_64-linux-gnu/ 是标准路径吗?" )); From e2d6d2253ad94a65527d38d5f0f590ed22fcf996 Mon Sep 17 00:00:00 2001 From: jayzhu Date: Thu, 28 May 2026 22:06:04 +0800 Subject: [PATCH 012/125] fix(i18n): localize right-click context menu labels and descriptions The right-click context menu rendered all entries in English regardless of ui_locale. Added 26 MessageId variants with translations for en, ja, zh-Hans, zh-Hant, pt-BR, es-419. ContextMenuView now accepts a localized title. build_context_menu_entries() uses app.tr() instead of hardcoded strings. --- crates/tui/src/localization.rs | 182 +++++++++++++++++++++++++++++ crates/tui/src/tui/context_menu.rs | 15 ++- crates/tui/src/tui/mouse_ui.rs | 59 +++++----- 3 files changed, 227 insertions(+), 29 deletions(-) diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 7cba0b4a..e0c30cea 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -455,6 +455,32 @@ pub enum MessageId { OnboardTipsLine4, OnboardTipsFooterEnter, OnboardTipsFooterAction, + // Context menu. + CtxMenuTitle, + CtxMenuCopySelection, + CtxMenuCopySelectionDesc, + CtxMenuOpenSelection, + CtxMenuOpenSelectionDesc, + CtxMenuClearSelection, + CtxMenuOpenDetails, + CtxMenuCopyMessage, + CtxMenuCopyMessageDesc, + CtxMenuOpenInEditor, + CtxMenuOpenInEditorDesc, + CtxMenuShowCell, + CtxMenuShowCellDesc, + CtxMenuHideCell, + CtxMenuHideCellDesc, + CtxMenuShowHidden, + CtxMenuShowHiddenDesc, + CtxMenuPaste, + CtxMenuPasteDesc, + CtxMenuCmdPalette, + CtxMenuCmdPaletteDesc, + CtxMenuContextInspector, + CtxMenuContextInspectorDesc, + CtxMenuHelp, + CtxMenuHelpDesc, } #[allow(dead_code)] @@ -690,6 +716,32 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::OnboardTipsLine4, MessageId::OnboardTipsFooterEnter, MessageId::OnboardTipsFooterAction, + // Context menu. + MessageId::CtxMenuTitle, + MessageId::CtxMenuCopySelection, + MessageId::CtxMenuCopySelectionDesc, + MessageId::CtxMenuOpenSelection, + MessageId::CtxMenuOpenSelectionDesc, + MessageId::CtxMenuClearSelection, + MessageId::CtxMenuOpenDetails, + MessageId::CtxMenuCopyMessage, + MessageId::CtxMenuCopyMessageDesc, + MessageId::CtxMenuOpenInEditor, + MessageId::CtxMenuOpenInEditorDesc, + MessageId::CtxMenuShowCell, + MessageId::CtxMenuShowCellDesc, + MessageId::CtxMenuHideCell, + MessageId::CtxMenuHideCellDesc, + MessageId::CtxMenuShowHidden, + MessageId::CtxMenuShowHiddenDesc, + MessageId::CtxMenuPaste, + MessageId::CtxMenuPasteDesc, + MessageId::CtxMenuCmdPalette, + MessageId::CtxMenuCmdPaletteDesc, + MessageId::CtxMenuContextInspector, + MessageId::CtxMenuContextInspectorDesc, + MessageId::CtxMenuHelp, + MessageId::CtxMenuHelpDesc, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1218,6 +1270,32 @@ fn english(id: MessageId) -> &'static str { } MessageId::OnboardTipsFooterEnter => "Press Enter", MessageId::OnboardTipsFooterAction => " to open the workspace", + // Context menu. + MessageId::CtxMenuTitle => " Right click ", + MessageId::CtxMenuCopySelection => "Copy selection", + MessageId::CtxMenuCopySelectionDesc => "write selected transcript text", + MessageId::CtxMenuOpenSelection => "Open selection", + MessageId::CtxMenuOpenSelectionDesc => "show selected text in pager", + MessageId::CtxMenuClearSelection => "Clear selection", + MessageId::CtxMenuOpenDetails => "Open details", + MessageId::CtxMenuCopyMessage => "Copy message", + MessageId::CtxMenuCopyMessageDesc => "write clicked transcript cell", + MessageId::CtxMenuOpenInEditor => "Open in editor", + MessageId::CtxMenuOpenInEditorDesc => "open file:line in $EDITOR", + MessageId::CtxMenuShowCell => "Show cell", + MessageId::CtxMenuShowCellDesc => "unhide this transcript cell", + MessageId::CtxMenuHideCell => "Hide cell", + MessageId::CtxMenuHideCellDesc => "collapse this transcript cell", + MessageId::CtxMenuShowHidden => "Show hidden", + MessageId::CtxMenuShowHiddenDesc => "unhide all collapsed cells", + MessageId::CtxMenuPaste => "Paste", + MessageId::CtxMenuPasteDesc => "insert clipboard into composer", + MessageId::CtxMenuCmdPalette => "Command palette", + MessageId::CtxMenuCmdPaletteDesc => "commands, skills, and tools", + MessageId::CtxMenuContextInspector => "Context inspector", + MessageId::CtxMenuContextInspectorDesc => "active context and cache hints", + MessageId::CtxMenuHelp => "Help", + MessageId::CtxMenuHelpDesc => "keybindings and commands", } } @@ -1606,6 +1684,32 @@ fn japanese(id: MessageId) -> Option<&'static str> { } MessageId::OnboardTipsFooterEnter => "Enter を押す", MessageId::OnboardTipsFooterAction => " とワークスペースが開きます", + // Context menu. + MessageId::CtxMenuTitle => " 右クリック ", + MessageId::CtxMenuCopySelection => "選択をコピー", + MessageId::CtxMenuCopySelectionDesc => "選択したトランスクリプトのテキストを書き込む", + MessageId::CtxMenuOpenSelection => "選択を開く", + MessageId::CtxMenuOpenSelectionDesc => "選択したテキストをページャで表示", + MessageId::CtxMenuClearSelection => "選択を解除", + MessageId::CtxMenuOpenDetails => "詳細を開く", + MessageId::CtxMenuCopyMessage => "メッセージをコピー", + MessageId::CtxMenuCopyMessageDesc => "クリックしたトランスクリプトセルを書き込む", + MessageId::CtxMenuOpenInEditor => "エディタで開く", + MessageId::CtxMenuOpenInEditorDesc => "$EDITOR で file:line を開く", + MessageId::CtxMenuShowCell => "セルを表示", + MessageId::CtxMenuShowCellDesc => "このトランスクリプトセルを再表示", + MessageId::CtxMenuHideCell => "セルを隠す", + MessageId::CtxMenuHideCellDesc => "このトランスクリプトセルを折りたたむ", + MessageId::CtxMenuShowHidden => "非表示を表示", + MessageId::CtxMenuShowHiddenDesc => "すべての折りたたまれたセルを再表示", + MessageId::CtxMenuPaste => "貼り付け", + MessageId::CtxMenuPasteDesc => "クリップボードをコンポーザに挿入", + MessageId::CtxMenuCmdPalette => "コマンドパレット", + MessageId::CtxMenuCmdPaletteDesc => "コマンド、スキル、ツール", + MessageId::CtxMenuContextInspector => "コンテキストインスペクタ", + MessageId::CtxMenuContextInspectorDesc => "アクティブなコンテキストとキャッシュヒント", + MessageId::CtxMenuHelp => "ヘルプ", + MessageId::CtxMenuHelpDesc => "キー操作とコマンド", }) } @@ -1914,6 +2018,32 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::OnboardTipsLine4 => "Ctrl+R 恢复历史会话,Esc 退出当前输入或弹层。", MessageId::OnboardTipsFooterEnter => "按 Enter", MessageId::OnboardTipsFooterAction => " 进入工作区", + // Context menu. + MessageId::CtxMenuTitle => " 右键菜单 ", + MessageId::CtxMenuCopySelection => "复制所选", + MessageId::CtxMenuCopySelectionDesc => "将选中的记录区域文本写入剪贴板", + MessageId::CtxMenuOpenSelection => "打开所选", + MessageId::CtxMenuOpenSelectionDesc => "在翻阅器中查看选中文本", + MessageId::CtxMenuClearSelection => "清除选择", + MessageId::CtxMenuOpenDetails => "打开详情", + MessageId::CtxMenuCopyMessage => "复制消息", + MessageId::CtxMenuCopyMessageDesc => "将点击的记录条目写入剪贴板", + MessageId::CtxMenuOpenInEditor => "在编辑器中打开", + MessageId::CtxMenuOpenInEditorDesc => "在 $EDITOR 中打开 file:line", + MessageId::CtxMenuShowCell => "显示条目", + MessageId::CtxMenuShowCellDesc => "取消隐藏此记录条目", + MessageId::CtxMenuHideCell => "隐藏条目", + MessageId::CtxMenuHideCellDesc => "折叠此记录条目", + MessageId::CtxMenuShowHidden => "显示已隐藏", + MessageId::CtxMenuShowHiddenDesc => "取消隐藏所有已折叠条目", + MessageId::CtxMenuPaste => "粘贴", + MessageId::CtxMenuPasteDesc => "将剪贴板插入输入框", + MessageId::CtxMenuCmdPalette => "命令面板", + MessageId::CtxMenuCmdPaletteDesc => "命令、技能和工具", + MessageId::CtxMenuContextInspector => "上下文检查器", + MessageId::CtxMenuContextInspectorDesc => "活动上下文和缓存提示", + MessageId::CtxMenuHelp => "帮助", + MessageId::CtxMenuHelpDesc => "快捷键和命令", }) } @@ -2302,6 +2432,32 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { } MessageId::OnboardTipsFooterEnter => "Pressione Enter", MessageId::OnboardTipsFooterAction => " para abrir o workspace", + // Context menu. + MessageId::CtxMenuTitle => " Clique direito ", + MessageId::CtxMenuCopySelection => "Copiar seleção", + MessageId::CtxMenuCopySelectionDesc => "copiar texto selecionado da transcrição", + MessageId::CtxMenuOpenSelection => "Abrir seleção", + MessageId::CtxMenuOpenSelectionDesc => "mostrar texto selecionado no visualizador", + MessageId::CtxMenuClearSelection => "Limpar seleção", + MessageId::CtxMenuOpenDetails => "Abrir detalhes", + MessageId::CtxMenuCopyMessage => "Copiar mensagem", + MessageId::CtxMenuCopyMessageDesc => "copiar célula da transcrição clicada", + MessageId::CtxMenuOpenInEditor => "Abrir no editor", + MessageId::CtxMenuOpenInEditorDesc => "abrir file:line no $EDITOR", + MessageId::CtxMenuShowCell => "Mostrar célula", + MessageId::CtxMenuShowCellDesc => "reexibir esta célula da transcrição", + MessageId::CtxMenuHideCell => "Ocultar célula", + MessageId::CtxMenuHideCellDesc => "recolher esta célula da transcrição", + MessageId::CtxMenuShowHidden => "Mostrar ocultas", + MessageId::CtxMenuShowHiddenDesc => "reexibir todas as células recolhidas", + MessageId::CtxMenuPaste => "Colar", + MessageId::CtxMenuPasteDesc => "inserir área de transferência no compositor", + MessageId::CtxMenuCmdPalette => "Paleta de comandos", + MessageId::CtxMenuCmdPaletteDesc => "comandos, habilidades e ferramentas", + MessageId::CtxMenuContextInspector => "Inspetor de contexto", + MessageId::CtxMenuContextInspectorDesc => "contexto ativo e dicas de cache", + MessageId::CtxMenuHelp => "Ajuda", + MessageId::CtxMenuHelpDesc => "atalhos de teclado e comandos", }) } @@ -2696,6 +2852,32 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { } MessageId::OnboardTipsFooterEnter => "Presiona Enter", MessageId::OnboardTipsFooterAction => " para abrir el workspace", + // Context menu. + MessageId::CtxMenuTitle => " Clic derecho ", + MessageId::CtxMenuCopySelection => "Copiar selección", + MessageId::CtxMenuCopySelectionDesc => "copiar texto seleccionado de la transcripción", + MessageId::CtxMenuOpenSelection => "Abrir selección", + MessageId::CtxMenuOpenSelectionDesc => "mostrar texto seleccionado en el visor", + MessageId::CtxMenuClearSelection => "Limpiar selección", + MessageId::CtxMenuOpenDetails => "Abrir detalles", + MessageId::CtxMenuCopyMessage => "Copiar mensaje", + MessageId::CtxMenuCopyMessageDesc => "copiar celda de transcripción seleccionada", + MessageId::CtxMenuOpenInEditor => "Abrir en editor", + MessageId::CtxMenuOpenInEditorDesc => "abrir file:line en $EDITOR", + MessageId::CtxMenuShowCell => "Mostrar celda", + MessageId::CtxMenuShowCellDesc => "volver a mostrar esta celda de transcripción", + MessageId::CtxMenuHideCell => "Ocultar celda", + MessageId::CtxMenuHideCellDesc => "colapsar esta celda de transcripción", + MessageId::CtxMenuShowHidden => "Mostrar ocultas", + MessageId::CtxMenuShowHiddenDesc => "volver a mostrar todas las celdas colapsadas", + MessageId::CtxMenuPaste => "Pegar", + MessageId::CtxMenuPasteDesc => "insertar portapapeles en el compositor", + MessageId::CtxMenuCmdPalette => "Paleta de comandos", + MessageId::CtxMenuCmdPaletteDesc => "comandos, habilidades y herramientas", + MessageId::CtxMenuContextInspector => "Inspector de contexto", + MessageId::CtxMenuContextInspectorDesc => "contexto activo y sugerencias de caché", + MessageId::CtxMenuHelp => "Ayuda", + MessageId::CtxMenuHelpDesc => "atajos de teclado y comandos", }) } diff --git a/crates/tui/src/tui/context_menu.rs b/crates/tui/src/tui/context_menu.rs index e897577c..20543551 100644 --- a/crates/tui/src/tui/context_menu.rs +++ b/crates/tui/src/tui/context_menu.rs @@ -28,16 +28,18 @@ pub struct ContextMenuView { column: u16, row: u16, last_rect: Cell>, + title: String, } impl ContextMenuView { - pub fn new(entries: Vec, column: u16, row: u16) -> Self { + pub fn new(entries: Vec, column: u16, row: u16, title: String) -> Self { Self { entries, selected: 0, column, row, last_rect: Cell::new(None), + title, } } @@ -199,7 +201,7 @@ impl ModalView for ContextMenuView { .collect::>(); let block = Block::default() - .title(" Right click ") + .title(self.title.as_str()) .borders(Borders::ALL) .border_style(Style::default().fg(palette::DEEPSEEK_SKY)) .style(Style::default().bg(palette::SURFACE_ELEVATED)) @@ -256,6 +258,7 @@ mod tests { ], 5, 5, + " Right click ".to_string(), ); view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); @@ -271,7 +274,12 @@ mod tests { #[test] fn menu_clamps_to_render_area() { - let view = ContextMenuView::new(vec![entry("Paste", ContextMenuAction::Paste)], 200, 80); + let view = ContextMenuView::new( + vec![entry("Paste", ContextMenuAction::Paste)], + 200, + 80, + " Right click ".to_string(), + ); let rect = view.menu_rect(Rect { x: 0, @@ -293,6 +301,7 @@ mod tests { ], 2, 2, + " Right click ".to_string(), ); let area = Rect { x: 0, diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index a22b2b61..8d742b87 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -5,6 +5,7 @@ use ratatui::layout::Rect; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +use crate::localization::MessageId; use crate::tui::app::App; use crate::tui::command_palette::{ CommandPaletteView, build_entries as build_command_palette_entries, @@ -434,8 +435,13 @@ pub(crate) fn open_context_menu(app: &mut App, mouse: MouseEvent) { if entries.is_empty() { return; } - app.view_stack - .push(ContextMenuView::new(entries, mouse.column, mouse.row)); + let title = app.tr(MessageId::CtxMenuTitle).to_string(); + app.view_stack.push(ContextMenuView::new( + entries, + mouse.column, + mouse.row, + title, + )); app.needs_redraw = true; } @@ -444,17 +450,17 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec Vec Vec