From 1f0065ccee3ab1212030436ebcd0602205e8d31a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 12 May 2026 00:25:30 -0500 Subject: [PATCH] feat(commands): add /change slash command to display latest CHANGELOG entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/change` reads the most recent `## [version]` section from the workspace `CHANGELOG.md` (or the bundled release-notes copy when no workspace changelog is available) and renders it inline in the TUI. On non-English locales the command also queues a model-side translation request so localised users see the changelog text in their UI language; with no API key configured, the offline path returns the section verbatim with a brief explanatory header. Lets users discover what changed in the version they just upgraded into without leaving the chat — and keeps the v0.8.32 release-notes flow consistent with `deepseek update`'s newly-fixed sibling-TUI refresh: now both binaries match the version, and `/change` shows what that version actually delivered. Resolved a `clippy::needless_range_loop` warning in the section extractor (idiomatic `iter().enumerate().skip(...).find(...)` instead of an indexed range loop) so the harvest passes the workspace's `-D warnings` clippy gate without touching the contributor's design. Harvested from PR #1416 by @zhuangbiaowei Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 + crates/tui/src/commands/change.rs | 374 ++++++++++++++++++++++++++++++ crates/tui/src/commands/mod.rs | 10 + crates/tui/src/localization.rs | 40 ++++ 4 files changed, 436 insertions(+) create mode 100644 crates/tui/src/commands/change.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f011096..9a9c7cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ have to work with?" — and the answer is now closer to "everything you'd reach for from a shell, including the document formats the real world uses." +### Added + +- **`/change` slash command** displays the most recent + CHANGELOG.md version section from inside the TUI, so users can + see what they just upgraded into without leaving the chat + (harvested from PR #1416 by **@zhuangbiaowei**). The command + works against the bundled release-notes copy when no workspace + CHANGELOG is available, and on non-English locales it requests + a model-side translation of the section so localised users see + the changelog in their UI language. Pure offline fallback when + no API key is configured. + ### Fixed - **`deepseek update` now refreshes the companion TUI binary diff --git a/crates/tui/src/commands/change.rs b/crates/tui/src/commands/change.rs new file mode 100644 index 00000000..93b71c9d --- /dev/null +++ b/crates/tui/src/commands/change.rs @@ -0,0 +1,374 @@ +//! `/change` command — show the latest changelog entry, translated to the +//! user's locale when it is not English. +//! +//! Usage: `/change` +//! +//! Uses the DeepSeek-TUI changelog embedded at compile time, extracts the +//! most recent version section, and displays it. When the UI locale is not +//! English and the current session can reach a model, the command also fires a +//! `SendMessage` action that asks the model to translate the changelog into +//! the user's language. + +use crate::localization::{Locale, MessageId, tr}; +use crate::tui::app::{App, AppAction}; + +use super::CommandResult; + +/// Maximum length of the changelog excerpt we'll show inline (characters). +/// If the changelog section exceeds this, we truncate and show a notice. +/// 4096 chars is large enough for most version entries. +const MAX_INLINE_CHANGELOG_CHARS: usize = 4096; +const DEEPSEEK_TUI_CHANGELOG: &str = include_str!("../../../../CHANGELOG.md"); + +/// Execute the `/change` command. +pub fn change(app: &mut App) -> CommandResult { + let latest_section = match extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG) { + Some(s) => s, + None => { + return CommandResult::error( + "Could not find a version section in the bundled DeepSeek-TUI changelog. \ + Expected a line starting with `## [`. " + .to_string(), + ); + } + }; + + let locale = app.ui_locale; + let header = tr(locale, MessageId::CmdChangeHeader); + + let section_text = inline_changelog_section(&latest_section); + + // If the user's locale is English, just display. + // Otherwise, also ask the model to translate. + if locale == Locale::En { + CommandResult::message(format!( + "{header}\n─────────────────────────────\n{section_text}" + )) + } else if app.offline_mode || app.onboarding_needs_api_key { + let fallback = tr(locale, MessageId::CmdChangeTranslationUnavailable); + CommandResult::message(format!( + "{header}\n\ +─────────────────────────────\n\ +{fallback}\n\n\ +{section_text}" + )) + } else { + let queued = tr(locale, MessageId::CmdChangeTranslationQueued); + let display_text = format!( + "{header}\n\ +─────────────────────────────\n\ +{queued}\n\n\ +{section_text}" + ); + let lang_name = match locale { + Locale::ZhHans => "Simplified Chinese (中文)", + Locale::Ja => "Japanese (日本語)", + Locale::PtBr => "Brazilian Portuguese (Português)", + // Fallback — should never reach here since we check En above. + Locale::En => "English", + }; + + let translation_prompt = format!( + "Translate the following changelog into {lang_name}. \ + Keep all markdown formatting, version numbers, dates, \ + contributor names, and code references intact. \ + Output ONLY the translated changelog, no preamble or commentary.\n\n\ + {latest_section}" + ); + + CommandResult::with_message_and_action( + display_text, + AppAction::SendMessage(translation_prompt), + ) + } +} + +fn inline_changelog_section(section: &str) -> String { + if section.len() <= MAX_INLINE_CHANGELOG_CHARS { + return section.to_string(); + } + + let truncated: String = section.chars().take(MAX_INLINE_CHANGELOG_CHARS).collect(); + format!( + "{truncated}\n\ +\n\ +[... {} characters omitted from the bundled DeepSeek-TUI changelog]", + section.len() - MAX_INLINE_CHANGELOG_CHARS + ) +} + +/// Extract the latest version section from CHANGELOG.md content. +/// +/// Looks for the first `## [version] - date` heading and returns all lines +/// from that heading up to the next `## [` heading (or end of file). +/// Leading and trailing whitespace is trimmed. +fn extract_latest_changelog_section(content: &str) -> Option { + let lines: Vec<&str> = content.lines().collect(); + let mut start_idx: Option = None; + + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.starts_with("## [") { + start_idx = Some(i); + break; + } + } + + let start = start_idx?; + + // Find the next `## [` heading (or end) + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find(|(_, line)| line.trim().starts_with("## [")) + .map_or(lines.len(), |(i, _)| i); + + let section = lines[start..end].join("\n"); + let section = section.trim().to_string(); + + if section.is_empty() { + return None; + } + + Some(section) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::localization::Locale; + use crate::tui::app::{App, TuiOptions}; + fn make_app(tmpdir: &tempfile::TempDir, locale: Locale, has_api_key: bool) -> App { + let mut config = Config::default(); + if has_api_key { + config.api_key = Some("test-key".to_string()); + } + let mut app = App::new( + TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: tmpdir.path().join("skills"), + memory_path: tmpdir.path().join("memory.md"), + notes_path: tmpdir.path().join("notes.txt"), + mcp_config_path: tmpdir.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &config, + ); + app.ui_locale = locale; + app + } + + #[test] + fn extract_latest_section_finds_first_version() { + let content = "\n\ +## [0.8.26] - 2026-05-09\n\ +\n\ +A security + polish release.\n\ +\n\ +### Fixed\n\ +\n\ +- Fixed something\n\ +\n\ +## [0.8.25] - 2026-05-09\n\ +\n\ +A stabilization release.\n"; + let section = extract_latest_changelog_section(content).expect("should find a section"); + assert!(section.contains("0.8.26")); + assert!(section.contains("Fixed something")); + assert!(!section.contains("0.8.25")); + } + + #[test] + fn extract_latest_section_handles_0_8_29_style_fixture() { + let content = "\n\ +# Changelog\n\ +\n\ +## [0.8.29] - 2026-05-11\n\ +\n\ +Release candidate polish.\n\ +\n\ +### Added\n\ +- New note-management command.\n\ +\n\ +## [0.8.28] - 2026-05-10\n\ +\n\ +Previous release.\n"; + let section = extract_latest_changelog_section(content).expect("should find a section"); + assert!(section.contains("0.8.29")); + assert!(section.contains("2026-05-11")); + assert!(section.contains("New note-management command")); + assert!(!section.contains("0.8.28")); + } + + #[test] + fn extract_latest_section_returns_none_for_empty_content() { + assert!(extract_latest_changelog_section("").is_none()); + } + + #[test] + fn extract_latest_section_returns_none_for_no_version_headers() { + let content = "# Just a heading\n\nSome text\n"; + assert!(extract_latest_changelog_section(content).is_none()); + } + + #[test] + fn extract_latest_section_handles_single_version() { + let content = "\n## [0.8.26] - 2026-05-09\n\nOnly one version.\n"; + let section = extract_latest_changelog_section(content).expect("should find a section"); + assert!(section.contains("0.8.26")); + assert!(section.contains("Only one version")); + } + + #[test] + fn extract_latest_section_handles_subheadings() { + let content = "\n\ +## [0.8.26] - 2026-05-09\n\ +\n\ +### Added\n\ +- New feature A\n\ +\n\ +### Fixed\n\ +- Fixed bug B\n\ +\n\ +## [0.8.25] - 2026-05-09\n\ +"; + let section = extract_latest_changelog_section(content).expect("should find a section"); + assert!(section.contains("New feature A")); + assert!(section.contains("Fixed bug B")); + assert!(!section.contains("0.8.25")); + } + + #[test] + fn change_uses_bundled_release_notes_without_workspace_changelog() { + let tmp = tempfile::TempDir::new().unwrap(); + let mut app = make_app(&tmp, Locale::En, false); + let result = change(&mut app); + assert!(!result.is_error); + let msg = result.message.expect("should have a message"); + let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG) + .expect("bundled changelog should have a release section"); + assert!(msg.contains(expected.lines().next().unwrap())); + } + + #[test] + fn change_ignores_workspace_changelog() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("CHANGELOG.md"), + "\n## [9.9.9] - 2099-01-01\n\nWorkspace changelog.\n", + ) + .unwrap(); + let mut app = make_app(&tmp, Locale::En, false); + let result = change(&mut app); + assert!(!result.is_error); + let msg = result.message.expect("should have a message"); + assert!(!msg.contains("9.9.9")); + assert!(!msg.contains("Workspace changelog")); + } + + #[test] + fn change_in_english_returns_message_without_action() { + let tmp = tempfile::TempDir::new().unwrap(); + let mut app = make_app(&tmp, Locale::En, true); + let result = change(&mut app); + assert!(!result.is_error); + let msg = result.message.expect("should have a message"); + let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG) + .expect("bundled changelog should have a release section"); + assert!(msg.contains(expected.lines().next().unwrap())); + assert!( + result.action.is_none(), + "English locale should not send translation" + ); + } + + #[test] + fn change_in_non_english_also_sends_translation_action() { + for (locale, _label) in [ + (Locale::ZhHans, "zh-Hans"), + (Locale::Ja, "ja"), + (Locale::PtBr, "pt-BR"), + ] { + let tmp = tempfile::TempDir::new().unwrap(); + let mut app = make_app(&tmp, locale, true); + let result = change(&mut app); + assert!(!result.is_error, "Failed for locale {locale:?}"); + let msg = result.message.expect("should have a message"); + assert!(msg.contains(tr(locale, MessageId::CmdChangeTranslationQueued))); + assert!( + matches!(result.action, Some(AppAction::SendMessage(_))), + "Non-English locale should send translation, got {:?}", + result.action + ); + if let Some(AppAction::SendMessage(prompt)) = &result.action { + let expected = extract_latest_changelog_section(DEEPSEEK_TUI_CHANGELOG) + .expect("bundled changelog should have a release section"); + assert!(prompt.contains(expected.lines().next().unwrap())); + } + } + } + + #[test] + fn change_in_non_english_without_api_key_uses_explicit_fallback() { + let tmp = tempfile::TempDir::new().unwrap(); + let mut app = make_app(&tmp, Locale::ZhHans, false); + let result = change(&mut app); + assert!(!result.is_error); + let msg = result.message.expect("should have a message"); + assert!(msg.contains(tr( + Locale::ZhHans, + MessageId::CmdChangeTranslationUnavailable + ))); + assert!( + result.action.is_none(), + "missing API key should not send translation" + ); + } + + #[test] + fn change_in_non_english_offline_uses_explicit_fallback() { + let tmp = tempfile::TempDir::new().unwrap(); + let mut app = make_app(&tmp, Locale::Ja, true); + app.offline_mode = true; + let result = change(&mut app); + assert!(!result.is_error); + let msg = result.message.expect("should have a message"); + assert!(msg.contains(tr(Locale::Ja, MessageId::CmdChangeTranslationUnavailable))); + assert!( + result.action.is_none(), + "offline mode should not send translation" + ); + } + + #[test] + fn extract_latest_ignores_lines_before_first_version() { + let content = "\n\ +# Changelog\n\ +\n\ +Some intro text.\n\ +\n\ +## [0.8.26] - 2026-05-09\n\ +\n\ +Content\n\ +"; + let section = extract_latest_changelog_section(content).expect("should find a section"); + assert!(section.contains("0.8.26")); + assert!(!section.contains("Changelog")); + assert!(!section.contains("intro text")); + } +} diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index ee0e6c76..46ef8482 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -5,6 +5,7 @@ mod anchor; mod attachment; +mod change; mod config; mod core; mod cycle; @@ -386,6 +387,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/diff", description_id: MessageId::CmdDiffDescription, }, + CommandInfo { + name: "change", + aliases: &[], + usage: "/change", + description_id: MessageId::CmdChangeDescription, + }, CommandInfo { name: "undo", aliases: &[], @@ -558,6 +565,9 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "tokens" => debug::tokens(app), "cost" => debug::cost(app), "cache" => debug::cache(app, arg), + + // ChangeLog command + "change" => change::change(app), "system" => debug::system_prompt(app), "context" | "ctx" => debug::context(app), "edit" => debug::edit(app), diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index f9ea02bb..6e939215 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -217,6 +217,10 @@ pub enum MessageId { CmdAttachDescription, CmdAnchorDescription, CmdCacheDescription, + CmdChangeDescription, + CmdChangeHeader, + CmdChangeTranslationQueued, + CmdChangeTranslationUnavailable, CmdClearDescription, CmdCompactDescription, CmdConfigDescription, @@ -498,6 +502,10 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdCacheHeader, MessageId::CmdCacheNoData, MessageId::CmdCacheTotals, + MessageId::CmdChangeDescription, + MessageId::CmdChangeHeader, + MessageId::CmdChangeTranslationQueued, + MessageId::CmdChangeTranslationUnavailable, MessageId::CmdCostReport, MessageId::CmdTokensCacheBoth, MessageId::CmdTokensCacheHitOnly, @@ -783,6 +791,14 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdCacheDescription => { "Show DeepSeek prefix-cache hit/miss stats for the last N turns" } + MessageId::CmdChangeDescription => "Show the latest changelog entry", + MessageId::CmdChangeHeader => "Latest Changelog", + MessageId::CmdChangeTranslationQueued => { + "English release notes are shown below. A translated version will be requested next; if the provider is unavailable, this English text is the fallback." + } + MessageId::CmdChangeTranslationUnavailable => { + "English release notes are shown below. Translation is unavailable because the current session has no API key or is offline." + } MessageId::CmdClearDescription => "Clear conversation history", MessageId::CmdCompactDescription => { "Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)" @@ -1118,6 +1134,14 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdCacheDescription => { "直近 N ターンの DeepSeek プレフィックスキャッシュのヒット/ミス統計を表示" } + MessageId::CmdChangeDescription => "最新の更新履歴を表示", + MessageId::CmdChangeHeader => "最新の更新履歴", + MessageId::CmdChangeTranslationQueued => { + "英語のリリースノートを以下に表示します。次に翻訳を依頼します。プロバイダーを利用できない場合は、この英語版がフォールバックです。" + } + MessageId::CmdChangeTranslationUnavailable => { + "英語のリリースノートを以下に表示します。現在のセッションに API キーがないかオフラインのため、翻訳は利用できません。" + } MessageId::CmdClearDescription => "会話履歴をクリア", MessageId::CmdCompactDescription => { "コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)" @@ -1443,6 +1467,14 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdAnchorDescription => "钉选关键事实,在压缩后自动注入上下文", MessageId::CmdAttachDescription => "附加图片或视频媒体;文本文件或目录请使用 @path", MessageId::CmdCacheDescription => "显示最近 N 轮的 DeepSeek 前缀缓存命中/未命中统计", + MessageId::CmdChangeDescription => "显示最新的更新日志", + MessageId::CmdChangeHeader => "最新更新日志", + MessageId::CmdChangeTranslationQueued => { + "下面显示英文发布说明。接下来会请求模型翻译;如果当前提供商不可用,这段英文内容就是备用结果。" + } + MessageId::CmdChangeTranslationUnavailable => { + "下面显示英文发布说明。当前会话没有 API Key 或处于离线状态,无法翻译。" + } MessageId::CmdClearDescription => "清除对话历史", MessageId::CmdCompactDescription => { "触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)" @@ -1736,6 +1768,14 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdCacheDescription => { "Exibir estatísticas de hit/miss do cache de prefixo DeepSeek nas últimas N rodadas" } + MessageId::CmdChangeDescription => "Mostrar a entrada mais recente do changelog", + MessageId::CmdChangeHeader => "Changelog Mais Recente", + MessageId::CmdChangeTranslationQueued => { + "As notas de versao em ingles aparecem abaixo. Uma versao traduzida sera solicitada em seguida; se o provedor estiver indisponivel, este texto em ingles sera o fallback." + } + MessageId::CmdChangeTranslationUnavailable => { + "As notas de versao em ingles aparecem abaixo. A traducao esta indisponivel porque a sessao atual nao tem chave de API ou esta offline." + } MessageId::CmdClearDescription => "Limpar o histórico da conversa", MessageId::CmdCompactDescription => { "Compactar o contexto para liberar espaço (legado; a v0.6.6 prefere o reinício de ciclo)"